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.
174 lines
5.5 KiB
Go
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() }
|