Pad/internal/io/pool/real/filesystem.go
Greg Pomerantz 6c6a0c1a27 Fix write-concurrency race: per-file write protocol + unique temps
The review-identified race: saves are async (owner snapshots content,
worker pool writes), nothing serialized per file, and every write of a
file used the SAME deterministic temp ('.<name>.tmp'). Two overlapping
writes (autosave x autosave, retry x autosave, or the synchronous
FlushAll on GoToBrowser/Shutdown x a worker write) interleaved on the
shared temp and could rename a byte-mixture into place; even without
interleaving, last-rename-wins could promote a STALE snapshot.

Owner-side protocol (logic.go, requestSave + result handler):
- at most one write in flight per file (writeInFlight maps filename ->
  the file version whose content the in-flight write carries);
- a save requested while one is in flight is deferred (savePending) and
  re-issued by the write's result handler with a FRESH snapshot, so
  'last rename wins' coincides with 'newest snapshot wins';
- on success the SNAPSHOT version (not the current one) is recorded as
  written, so an edit that arrived during the write leaves the file
  dirty and triggers the re-issue;
- FlushAll (GoToBrowser, Shutdown) defers via the same protocol instead
  of writing concurrently on the shared temp;
- shutdown drain: on done the owner waits (bounded 5 s) for in-flight
  writes and armed retries to settle before exiting, so the post-exit
  synchronous FlushAll and workerPool.Stop cannot race a straggling
  worker write;
- retry timer now sends a non-blocking token (no timer-goroutine stall
  on a full channel); emitFrame no longer blocks on a slow/gone main
  (frames are snapshots; the next emission wins) - also required so the
  drain can never deadlock on frame delivery.

Mechanism (real/filesystem.go):
- WriteFileAtomic uses a unique per-call temp ('.<name>.tmp.<pid>.<seq>'),
  making same-file staging-file interleaving structurally impossible even
  if the serialization regressed (defense in depth);
- each successful write best-effort removes stale temps of the same file
  (crash leftovers, plus the legacy deterministic name for upgraded
  installs); a failed write removes its own temp.

Tests:
- write_serialization_test.go (e2e): a counting FS wrapper proves the
  peak concurrent same-file saves is 1 across two deliberately
  overlapping autosaves (2 s saves; the second edit lands inside the
  first save's window and its token is deferred, then re-issued with the
  newer content), and that a Flush during an in-flight save adds no
  concurrent writer and the newest snapshot still wins. Mutation-verified:
  disabling the deferral fails it with peak = 2. (The pool's WriteFileTask
  calls FS.WriteFile, not WriteFileAtomic - the real FS is atomic only
  because WriteFile delegates to WriteFileAtomic; the wrapper mirrors that
  delegation or the overlap window does not exist.)
- filesystem_test.go: stale-temp test updated to the new pattern, also
  covering the legacy name and asserting a different file's temp is
  untouched.
- real_file_fuzz_test.go: stray-temp check matches both patterns.

Docs: architecture.md 6.5 rewritten (protocol invariants), spec.md
autosave line, development_plan.md v11 + Phase 12.

On-device smoke: open, type, autosave lands exact content on disk, no
temp files left, clean relaunch. Full suite green under -race.

Residuals (documented): no fsync before rename (power-loss window only);
external-change detection absent; a drain-deadline exit with a straggling
write can only lose freshness (unique temps keep every rename a complete
snapshot).
2026-08-17 16:29:47 -04:00

156 lines
4.5 KiB
Go

package real
import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"pad/internal/io/pool/types"
)
// RealFileSystem implements the pool.FileSystem interface using the real OS filesystem.
type RealFileSystem struct {
WorkingDir string
}
// NewRealFileSystem creates a RealFileSystem rooted at the given working directory.
// If workingDir is empty, it defaults to "/".
func NewRealFileSystem(workingDir string) *RealFileSystem {
if workingDir == "" {
workingDir = "/"
}
abs, err := filepath.Abs(workingDir)
if err != nil {
abs = workingDir
}
return &RealFileSystem{WorkingDir: abs}
}
func (fs *RealFileSystem) ReadDir(path string) ([]types.DirEntry, error) {
entries, err := os.ReadDir(filepath.Join(fs.WorkingDir, path))
if err != nil {
return nil, err
}
var res []types.DirEntry
for _, e := range entries {
res = append(res, &realDirEntry{e})
}
return res, nil
}
func (fs *RealFileSystem) DirExists(path string) bool {
info, err := os.Stat(filepath.Join(fs.WorkingDir, path))
return err == nil && info.IsDir()
}
func (fs *RealFileSystem) FileExists(path string) bool {
info, err := os.Stat(filepath.Join(fs.WorkingDir, path))
return err == nil && !info.IsDir()
}
func (fs *RealFileSystem) ReadFile(path string) ([]byte, error) {
return os.ReadFile(filepath.Join(fs.WorkingDir, path))
}
func (fs *RealFileSystem) ReadFileAt(path string, offset, size int) ([]byte, error) {
fullPath := filepath.Join(fs.WorkingDir, path)
f, err := os.Open(fullPath)
if err != nil {
return nil, err
}
defer f.Close()
buf := make([]byte, size)
n, err := f.ReadAt(buf, int64(offset))
if err != nil && err != io.EOF {
return nil, err
}
return buf[:n], nil
}
func (fs *RealFileSystem) WriteFile(path string, content []byte) error {
// Simple write for backward compatibility if needed,
// but defer to Atomic implementation.
return fs.WriteFileAtomic(path, content)
}
// tmpSeq numbers the per-call temp files so two writes never share a staging
// path. Combined with the owner-side per-file write serialization this makes
// same-file write interleaving structurally impossible.
var tmpSeq atomic.Uint64
func (fs *RealFileSystem) WriteFileAtomic(path string, content []byte) error {
// Atomic write implementation
fullPath := filepath.Join(fs.WorkingDir, path)
// Temp file in the same directory as the target to ensure same filesystem
// rename. Unique per call (".<name>.tmp.<pid>.<seq>"): even a concurrent
// write or a crashed process's leftover cannot interleave with this one
// on the staging file, and rename promotes only this call's complete
// content.
base := filepath.Base(fullPath)
tmpPath := filepath.Join(filepath.Dir(fullPath),
"."+base+".tmp."+strconv.Itoa(os.Getpid())+"."+strconv.FormatUint(tmpSeq.Add(1), 10))
if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil {
return err
}
if err := os.WriteFile(tmpPath, content, 0644); err != nil {
os.Remove(tmpPath)
return err
}
fs.removeStaleTemps(filepath.Dir(fullPath), base, tmpPath)
if err := os.Rename(tmpPath, fullPath); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// removeStaleTemps best-effort removes leftover staging files for the same
// file: any ".<base>.tmp.<pid>.<seq>" that is not the caller's own temp, plus
// the legacy deterministic name ".<base>.tmp" from older app versions. A
// live write never owns either: same-file writes are serialized by the logic
// owner, and a live temp always carries the caller's own pid/seq (excluded
// via keepTmp). Errors are ignored.
func (fs *RealFileSystem) removeStaleTemps(dir, base, keepTmp string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
keep := filepath.Base(keepTmp)
prefix := "." + base + ".tmp."
legacy := "." + base + ".tmp"
for _, e := range entries {
name := e.Name()
if name == keep {
continue // this call's own live temp file
}
if name == legacy || strings.HasPrefix(name, prefix) {
os.Remove(filepath.Join(dir, name))
}
}
}
func (fs *RealFileSystem) DeleteFile(path string) error {
return os.Remove(filepath.Join(fs.WorkingDir, path))
}
func (fs *RealFileSystem) CreateDir(path string) error {
return os.MkdirAll(filepath.Join(fs.WorkingDir, path), 0755)
}
// realDirEntry wraps os.DirEntry
type realDirEntry struct {
entry os.DirEntry
}
func (e *realDirEntry) Name() string { return e.entry.Name() }
func (e *realDirEntry) IsDir() bool { return e.entry.IsDir() }
func (e *realDirEntry) Info() (os.FileInfo, error) { return e.entry.Info() }