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).
156 lines
4.5 KiB
Go
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() }
|