Pad/internal/io/pool/real/filesystem.go
Greg Pomerantz 5e40378072 Fix Android: publish current mtime after the atomic-write rename
Edits on the phone were not picked up by syncthing for minutes to hours
("edited a file, but it never synced to my other devices").

Reproduced on device (Pixel 9 Pro, API 35, Syncthing-Fork, shared
storage /storage/emulated/0):

- The app's temp+rename save lands the new content (inode changes,
  bytes are correct), yet the file's mtime served through the FUSE
  layer reverts to the PREVIOUS file's mtime -- exactly, to the
  nanosecond. MediaProvider's media-scan DB row for the path is not
  updated when the staging file is renamed into place (logcat:
  "Database update failed while renaming .<name>.tmp.<pid>.<seq>")
  and the stale row wins. This reproduces for ANY writer, not just
  the app (shell temp+rename included).
- The fork's inotify watcher ignores these rename-based writes for a
  long time (a 15:48 edit reached the peer only at 16:39; five
  consecutive app writes over 6 minutes were never propagated), but
  it acts on an explicit timestamp update immediately (a touch
  synced in exactly the 10 s fsWatcherDelayS). Inotify events
  themselves are delivered fine (verified with an on-device inotify
  watcher: IN_MOVED_TO arrives).

Fix: after the rename in RealFileSystem.WriteFileAtomic, set the
file's atime/mtime to now (os.Chtimes, best-effort). utimensat
sticks through the FUSE layer and is the update the watcher reacts
to. Verified on device after the fix: edit -> peer in ~11-12 s,
mtime stays current.

Also pins the timestamp contract in
TestWriteFileAtomic_PublishesCurrentMtime (mtime is current after a
write and advances on rewrite) and documents the invariant in
architecture.md 6.5.
2026-09-02 17:12:54 -04:00

174 lines
5.5 KiB
Go

package real
import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
"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
}
// The Android shared-storage layer (FUSE backed by MediaProvider
// metadata) re-serves the PREVIOUS file's mtime after a rename: its
// media-scan DB row for the path is not updated when the staging file
// is renamed into place (logcat: "Database update failed while
// renaming"). Change detectors that combine inotify events with a
// stat compare (syncthing) then treat the new content as unmodified
// and skip it until the next full rescan — up to an hour of "edited
// but not synced" on a phone. Setting the timestamps explicitly after
// the rename publishes the current mtime through the FUSE layer, and
// the timestamp update is the event the watcher acts on immediately
// (verified on device: a plain rename's mtime is reverted to the old
// file's, while utimensat sticks and triggers the sync at once).
if err := os.Chtimes(fullPath, time.Now(), time.Now()); err != nil {
// The content is already safely in place; the timestamps are a
// best-effort hint for external change detectors, not data.
return nil
}
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() }