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.
251 lines
7.9 KiB
Go
251 lines
7.9 KiB
Go
package real
|
|
|
|
// Data-integrity tests for the persistence layer's atomicity contract. The
|
|
// editor's corruption protection against crashes rests on WriteFileAtomic's
|
|
// write-sibling-temp-then-rename scheme; these tests pin the observable
|
|
// consequences of that contract:
|
|
//
|
|
// - a written file is byte-exact (no truncation/padding at any size),
|
|
// - a concurrent reader NEVER observes a torn file: every read returns
|
|
// exactly one of the known complete payloads (the "crash/kill mid-write
|
|
// leaves a complete snapshot" property, without needing a real crash),
|
|
// - a FAILED write leaves the original file byte-identical,
|
|
// - a stale leftover temp file is consumed by the next successful write.
|
|
|
|
import (
|
|
"bytes"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func randomPayload(t *testing.T, seed int64, size int) []byte {
|
|
t.Helper()
|
|
rng := rand.New(rand.NewSource(seed))
|
|
b := make([]byte, size)
|
|
rng.Read(b)
|
|
return b
|
|
}
|
|
|
|
func TestWriteFileAtomic_RoundTripExact(t *testing.T) {
|
|
for _, size := range []int{0, 1, 2, 65537, 5 * 1024 * 1024} {
|
|
d := t.TempDir()
|
|
fs := NewRealFileSystem(d)
|
|
data := randomPayload(t, int64(size), size)
|
|
if err := fs.WriteFileAtomic("/rt.txt", data); err != nil {
|
|
t.Fatalf("size %d: write: %v", size, err)
|
|
}
|
|
got, err := fs.ReadFile("/rt.txt")
|
|
if err != nil {
|
|
t.Fatalf("size %d: read: %v", size, err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Fatalf("size %d: round trip mismatch (got %d bytes)", size, len(got))
|
|
}
|
|
// Overwrite with different content of the same size.
|
|
data2 := randomPayload(t, int64(size)+1, size)
|
|
if err := fs.WriteFileAtomic("/rt.txt", data2); err != nil {
|
|
t.Fatalf("size %d: overwrite: %v", size, err)
|
|
}
|
|
got, err = fs.ReadFile("/rt.txt")
|
|
if err != nil {
|
|
t.Fatalf("size %d: read after overwrite: %v", size, err)
|
|
}
|
|
if !bytes.Equal(got, data2) {
|
|
t.Fatalf("size %d: overwrite mismatch", size)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteFileAtomic_ConcurrentReader_NeverTorn hammers the file with two
|
|
// alternating 2 MB payloads while a reader spins; every single read must
|
|
// return exactly one of the two payloads in full. Any torn read (a mix, a
|
|
// truncation, a padding) is a data-corruption failure.
|
|
func TestWriteFileAtomic_ConcurrentReader_NeverTorn(t *testing.T) {
|
|
d := t.TempDir()
|
|
fs := NewRealFileSystem(d)
|
|
const size = 2 * 1024 * 1024
|
|
payloadA := randomPayload(t, 1, size)
|
|
payloadB := randomPayload(t, 2, size)
|
|
|
|
if err := fs.WriteFileAtomic("/torn.txt", payloadA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
var (
|
|
wg sync.WaitGroup
|
|
reads atomic.Int64
|
|
torn atomic.Int64
|
|
tornDetail string // written only by the reader, read after wg.Wait()
|
|
)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
data, err := fs.ReadFile("/torn.txt")
|
|
if err != nil {
|
|
continue // transient (rename boundary); next read decides
|
|
}
|
|
reads.Add(1)
|
|
if !bytes.Equal(data, payloadA) && !bytes.Equal(data, payloadB) {
|
|
torn.Store(1)
|
|
at := 0
|
|
ref := payloadA
|
|
if len(data) < len(ref) {
|
|
ref = data
|
|
}
|
|
for at < len(data) && at < len(ref) && data[at] == ref[at] {
|
|
at++
|
|
}
|
|
tornDetail = "first diff at " + itoa(at) + " (read " + itoa(len(data)) + " bytes, want " + itoa(size) + ")"
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
const iters = 150
|
|
for i := 0; i < iters; i++ {
|
|
payload := payloadA
|
|
if i%2 == 1 {
|
|
payload = payloadB
|
|
}
|
|
if err := fs.WriteFileAtomic("/torn.txt", payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
close(stop)
|
|
wg.Wait()
|
|
|
|
if torn.Load() == 1 {
|
|
t.Fatalf("torn read observed: %s", tornDetail) // safe: reader exited (wg.Wait)
|
|
}
|
|
if n := reads.Load(); n < 20 {
|
|
t.Fatalf("only %d concurrent reads happened; test not meaningful", n)
|
|
}
|
|
}
|
|
|
|
// TestWriteFileAtomic_FailedWriteLeavesOriginalIntact verifies the crash
|
|
// property directly: when the write cannot complete (read-only directory),
|
|
// the original file is untouched, byte for byte.
|
|
func TestWriteFileAtomic_FailedWriteLeavesOriginalIntact(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("running as root: permission bits are bypassed")
|
|
}
|
|
d := t.TempDir()
|
|
fs := NewRealFileSystem(d)
|
|
original := []byte("original content that must survive\n")
|
|
if err := fs.WriteFileAtomic("/f.txt", original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(d, 0o555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.Chmod(d, 0o755) }()
|
|
|
|
if err := fs.WriteFileAtomic("/f.txt", []byte("new content")); err == nil {
|
|
t.Fatal("expected write to fail in read-only directory")
|
|
}
|
|
got, err := fs.ReadFile("/f.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, original) {
|
|
t.Fatalf("failed write modified the original file: got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestWriteFileAtomic_StaleTempFileIsConsumed verifies a crash-leftover temp
|
|
// file (garbage from an interrupted write) does not poison the next
|
|
// successful write and does not survive it.
|
|
func TestWriteFileAtomic_StaleTempFileIsConsumed(t *testing.T) {
|
|
d := t.TempDir()
|
|
fs := NewRealFileSystem(d)
|
|
// Stale temps in both the legacy deterministic pattern (older app
|
|
// versions) and the current unique pattern (another/crashed process).
|
|
stale := []string{".f.txt.tmp", ".f.txt.tmp.99999.7"}
|
|
for _, name := range stale {
|
|
if err := os.WriteFile(filepath.Join(d, name), []byte("stale garbage from a crashed write"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// A different file's temp must be untouched.
|
|
if err := os.WriteFile(filepath.Join(d, ".other.txt.tmp.99999.8"), []byte("not ours"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh := []byte("fresh content")
|
|
if err := fs.WriteFileAtomic("/f.txt", fresh); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := fs.ReadFile("/f.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(got, fresh) {
|
|
t.Fatalf("target file != fresh content: %q", got)
|
|
}
|
|
entries, err := os.ReadDir(d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
seen[e.Name()] = true
|
|
}
|
|
if seen[".f.txt.tmp"] || seen[".f.txt.tmp.99999.7"] {
|
|
t.Fatalf("stale temp file(s) were not consumed by the successful write: %v", seen)
|
|
}
|
|
if !seen[".other.txt.tmp.99999.8"] {
|
|
t.Fatalf("another file's temp was removed: %v", seen)
|
|
}
|
|
}
|
|
|
|
// TestWriteFileAtomic_PublishesCurrentMtime pins the timestamp contract:
|
|
// after a successful write the file's mtime is current, and a subsequent
|
|
// write ADVANCES it. On Android shared storage a bare rename can leave the
|
|
// PREVIOUS file's mtime visible through the FUSE layer (the MediaProvider
|
|
// metadata row is not updated on rename), which makes stat-based change
|
|
// detectors (syncthing) treat the new content as unmodified until the next
|
|
// full rescan. WriteFileAtomic's post-rename Chtimes is what publishes the
|
|
// current mtime; if that line is ever removed, this test documents what the
|
|
// phone then experiences (it cannot reproduce the clobber itself on ext4).
|
|
func TestWriteFileAtomic_PublishesCurrentMtime(t *testing.T) {
|
|
d := t.TempDir()
|
|
fs := NewRealFileSystem(d)
|
|
if err := fs.WriteFileAtomic("/m.txt", []byte("v1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := os.Stat(filepath.Join(d, "m.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if delta := time.Since(first.ModTime()); delta > 30*time.Second || delta < 0 {
|
|
t.Fatalf("first write mtime not current: %v (delta %v)", first.ModTime(), delta)
|
|
}
|
|
// Cross a whole-second boundary: filesystems with 1 s mtime granularity
|
|
// (Android FUSE) must still show an advance.
|
|
time.Sleep(1100 * time.Millisecond)
|
|
if err := fs.WriteFileAtomic("/m.txt", []byte("v2, longer content")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := os.Stat(filepath.Join(d, "m.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !second.ModTime().After(first.ModTime()) {
|
|
t.Fatalf("mtime did not advance after rewrite: first=%v second=%v (Android FUSE would report the stale mtime and syncthing would never see the change)", first.ModTime(), second.ModTime())
|
|
}
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|