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).
398 lines
11 KiB
Go
398 lines
11 KiB
Go
package e2e_test
|
|
|
|
// End-to-end data-corruption fuzz against the REAL filesystem: random edit
|
|
// sequences (through the production edit entry points, including IME replace
|
|
// ranges relative to the visible window) are mirrored on a shadow model;
|
|
// after every batch the on-disk file must equal the model byte-for-byte
|
|
// (forced via Flush), and the visible IME window must equal the model's slice
|
|
// at its recorded start (a window/content desync is what made IME edits land
|
|
// in the wrong place historically — Phase 3). Finally a SECOND logic instance
|
|
// (simulating an app restart) reopens the file and must see exactly the
|
|
// model, with a consistent line index — proving the full
|
|
// edit -> persist -> reload -> re-render data path is lossless.
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/editor"
|
|
"pad/internal/io/pool/real"
|
|
"pad/internal/test/e2e"
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
var fuzzWords = []string{
|
|
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog",
|
|
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot",
|
|
}
|
|
|
|
// buildFuzzFileContent builds a deterministic multi-line document mixing
|
|
// ASCII and multi-byte content.
|
|
func buildFuzzFileContent(seed int64, nLines int) string {
|
|
rng := rand.New(rand.NewSource(seed))
|
|
var sb strings.Builder
|
|
for i := 0; i < nLines; i++ {
|
|
sb.WriteString(fmt.Sprintf("L%03d ", i))
|
|
n := rng.Intn(8) + 3
|
|
for w := 0; w < n; w++ {
|
|
sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))])
|
|
sb.WriteByte(' ')
|
|
}
|
|
switch i % 17 {
|
|
case 0:
|
|
sb.WriteString("中文内容 ")
|
|
case 1:
|
|
sb.WriteString("héllo wörld ")
|
|
case 2:
|
|
sb.WriteString("😀 emoji ")
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// loadRealFile opens /name in h and waits until the load is fully applied
|
|
// (content + line index), the same air-tight predicate the other real-file
|
|
// tests use.
|
|
func loadRealFile(t *testing.T, h *e2e.Harness, name string) {
|
|
t.Helper()
|
|
if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil {
|
|
t.Fatalf("GoToBrowser: %v", err)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil {
|
|
t.Fatalf("OpenFile: %v", err)
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
v, err := h.Inspect(func(st *editor.State) any {
|
|
cb := st.Editor.ChunkedBuffer
|
|
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
|
|
return false
|
|
}
|
|
full, err := cb.FullContent()
|
|
return err == nil && int64(len(full)) == cb.FileLen() &&
|
|
cb.LineIndex.Size == cb.FileLen()
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Inspect: %v", err)
|
|
}
|
|
if v.(bool) {
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Fatal("timed out waiting for real file to load")
|
|
}
|
|
|
|
// compactMismatch is a short failure message for two differing strings.
|
|
func compactMismatch(got, want string) string {
|
|
at := 0
|
|
for at < len(got) && at < len(want) && got[at] == want[at] {
|
|
at++
|
|
}
|
|
return fmt.Sprintf("first diff at byte %d (got %d bytes, want %d)\ngot [%d:%d]=%q\nwant [%d:%d]=%q",
|
|
at, len(got), len(want),
|
|
lo(at, 40), hi(at, 40), seg(got, at, 40),
|
|
lo(at, 40), hi(at, 40), seg(want, at, 40))
|
|
}
|
|
|
|
func TestRealFile_Fuzz_EditSaveReload(t *testing.T) {
|
|
const (
|
|
name = "fuzz.txt"
|
|
batches = 15
|
|
opsPer = 6
|
|
)
|
|
content := buildFuzzFileContent(11, 400) // ~15 KB
|
|
dir := t.TempDir()
|
|
diskPath := filepath.Join(dir, name)
|
|
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
|
|
h.Run()
|
|
defer h.Cleanup()
|
|
loadRealFile(t, h, name)
|
|
|
|
model := content
|
|
rng := rand.New(rand.NewSource(77))
|
|
|
|
for batch := 0; batch < batches; batch++ {
|
|
for j := 0; j < opsPer; j++ {
|
|
switch rng.Intn(100) {
|
|
case 0: // 30%: insert at a random rune-aligned position
|
|
pos := randomRunePos(rng, model)
|
|
text := randomFuzzText(rng, 1, 24)
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = pos
|
|
editor.HandleInsert(text)
|
|
}); err != nil {
|
|
t.Fatalf("batch %d op %d: insert: %v", batch, j, err)
|
|
}
|
|
model = model[:pos] + text + model[pos:]
|
|
|
|
case 1: // 15%: backspace
|
|
pos := randomRunePos(rng, model)
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = pos
|
|
editor.HandleBackspace()
|
|
}); err != nil {
|
|
t.Fatalf("batch %d op %d: backspace: %v", batch, j, err)
|
|
}
|
|
if pos > 0 {
|
|
model = model[:modelRuneBack(model, pos)] + model[pos:]
|
|
}
|
|
|
|
case 2: // 15%: delete
|
|
pos := randomRunePos(rng, model)
|
|
if err := h.WithState(func(st *editor.State) {
|
|
st.Editor.CursorPosition = pos
|
|
editor.HandleDelete()
|
|
}); err != nil {
|
|
t.Fatalf("batch %d op %d: delete: %v", batch, j, err)
|
|
}
|
|
if pos < len(model) {
|
|
model = model[:pos] + model[modelRuneNext(model, pos):]
|
|
}
|
|
|
|
default: // 40%: IME replace, WINDOW-relative rune range (the real
|
|
// contract: the IME only knows the visible snippet).
|
|
// Force a fresh layout frame first: WithState edits do not
|
|
// emit frames, so the window would otherwise be stale.
|
|
fb := h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{})
|
|
if _, ferr := h.WaitForFrameCount(fb+1, 5*time.Second); ferr != nil {
|
|
t.Fatalf("batch %d op %d: no frame: %v", batch, j, ferr)
|
|
}
|
|
type win struct {
|
|
Start int
|
|
Text string
|
|
}
|
|
raw, err := h.Inspect(func(st *editor.State) any {
|
|
return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("batch %d op %d: inspect: %v", batch, j, err)
|
|
}
|
|
w := raw.(win)
|
|
if w.Text == "" {
|
|
t.Fatalf("batch %d op %d: IME window empty after load+frames", batch, j)
|
|
}
|
|
// Window consistency: the snippet must be exactly the model's
|
|
// slice at the recorded start.
|
|
if w.Start < 0 || w.Start+len(w.Text) > len(model) ||
|
|
w.Text != model[w.Start:w.Start+len(w.Text)] {
|
|
t.Fatalf("batch %d op %d: IME window desync: start=%d len=%d; %s",
|
|
batch, j, w.Start, len(w.Text),
|
|
compactMismatch(w.Text, model[w.Start:min(len(model), w.Start+len(w.Text))]))
|
|
}
|
|
wr := countRunes(w.Text)
|
|
ra := rng.Intn(wr + 1)
|
|
rb := ra + rng.Intn(wr-ra+1)
|
|
text := randomFuzzText(rng, 0, 12)
|
|
if err := h.WithState(func(st *editor.State) {
|
|
editor.HandleReplaceRange(ra, rb, text)
|
|
}); err != nil {
|
|
t.Fatalf("batch %d op %d: replace: %v", batch, j, err)
|
|
}
|
|
bs := w.Start + runeToByte(w.Text, ra)
|
|
be := w.Start + runeToByte(w.Text, rb)
|
|
model = model[:bs] + text + model[be:]
|
|
}
|
|
}
|
|
|
|
// Force a fresh layout frame so IMEWindowText reflects the new model,
|
|
// then verify the window once per batch.
|
|
before := h.FrameCount()
|
|
h.SendInput([]ui.InputEvent{})
|
|
if _, err := h.WaitForFrameCount(before+1, 5*time.Second); err != nil {
|
|
t.Fatalf("batch %d: no frame after input: %v", batch, err)
|
|
}
|
|
w, err := h.Inspect(func(st *editor.State) any {
|
|
return struct {
|
|
Start int
|
|
Text string
|
|
}{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("batch %d: inspect: %v", batch, err)
|
|
}
|
|
ws := w.(struct {
|
|
Start int
|
|
Text string
|
|
})
|
|
if ws.Text != "" && (ws.Start < 0 || ws.Start+len(ws.Text) > len(model) ||
|
|
ws.Text != model[ws.Start:ws.Start+len(ws.Text)]) {
|
|
t.Fatalf("batch %d: window desync after edits: start=%d len=%d", batch, ws.Start, len(ws.Text))
|
|
}
|
|
|
|
// Persistence: forced flush, then the on-disk file must be EXACTLY
|
|
// the model (no lost bytes, no duplication, no reorder).
|
|
if err := h.Flush(); err != nil {
|
|
t.Fatalf("batch %d: flush: %v", batch, err)
|
|
}
|
|
if disk := readDisk(t, diskPath); disk != model {
|
|
t.Fatalf("batch %d: disk != model: %s", batch, compactMismatch(disk, model))
|
|
}
|
|
}
|
|
|
|
// --- Restart simulation: a fresh logic instance (new process state) ---
|
|
h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
|
|
h2.Run()
|
|
defer h2.Cleanup()
|
|
loadRealFile(t, h2, name)
|
|
|
|
got, err := h2.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("restart FullContent: %v", err)
|
|
}
|
|
if got != model {
|
|
t.Fatalf("after restart: buffer != model: %s", compactMismatch(got, model))
|
|
}
|
|
if disk := readDisk(t, diskPath); disk != model {
|
|
t.Fatalf("after restart: disk != model: %s", compactMismatch(disk, model))
|
|
}
|
|
|
|
// Line index after reload must match a fresh recomputation.
|
|
diag, err := h2.Inspect(func(st *editor.State) any {
|
|
cb := st.Editor.ChunkedBuffer
|
|
li := cb.LineIndex
|
|
if li == nil {
|
|
return "nil index"
|
|
}
|
|
want := freshLineOffsets(model)
|
|
if len(li.Offsets) != len(want) || li.Size != int64(len(model)) {
|
|
return fmt.Sprintf("size=%d lines=%d, want size=%d lines=%d", li.Size, len(li.Offsets), len(model), len(want))
|
|
}
|
|
for i := range want {
|
|
if li.Offsets[i] != want[i] {
|
|
return fmt.Sprintf("offset %d: got %d want %d", i, li.Offsets[i], want[i])
|
|
}
|
|
}
|
|
return "ok"
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("restart inspect: %v", err)
|
|
}
|
|
if diag.(string) != "ok" {
|
|
t.Fatalf("after restart: line index inconsistent: %s", diag)
|
|
}
|
|
|
|
// No temp files left behind by the atomic writes.
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
// Matches both the current ".<name>.tmp.<pid>.<seq>" pattern and the
|
|
// legacy ".<name>.tmp" one.
|
|
if strings.Contains(e.Name(), ".tmp") {
|
|
t.Fatalf("stray temp file left behind: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// randomRunePos returns a random rune-start byte offset in s.
|
|
func randomRunePos(rng *rand.Rand, s string) int {
|
|
runeStarts := []int{0}
|
|
for i := 0; i < len(s); {
|
|
_, size := decodeRuneAt(s[i:])
|
|
i += size
|
|
runeStarts = append(runeStarts, i)
|
|
}
|
|
return runeStarts[rng.Intn(len(runeStarts))]
|
|
}
|
|
|
|
func decodeRuneAt(s string) (rune, int) {
|
|
if len(s) == 0 {
|
|
return 0, 0
|
|
}
|
|
c := s[0]
|
|
switch {
|
|
case c < 0x80:
|
|
return rune(c), 1
|
|
case c < 0xC0:
|
|
// Invalid lead (should not happen with valid content); treat as 1
|
|
// byte so the fuzzer never advances past it in a loop.
|
|
return rune(c), 1
|
|
case c < 0xE0:
|
|
return rune(c), 2
|
|
case c < 0xF0:
|
|
return rune(c), 3
|
|
default:
|
|
return rune(c), 4
|
|
}
|
|
}
|
|
|
|
// modelRuneBack returns the byte offset of the start of the rune ending at
|
|
// pos (pos must be a rune start > 0).
|
|
func modelRuneBack(s string, pos int) int {
|
|
i := pos - 1
|
|
for i > 0 && s[i] >= 0x80 && s[i] < 0xC0 {
|
|
i--
|
|
}
|
|
return i
|
|
}
|
|
|
|
// modelRuneNext returns the byte offset after the rune starting at pos.
|
|
func modelRuneNext(s string, pos int) int {
|
|
_, size := decodeRuneAt(s[pos:])
|
|
return pos + size
|
|
}
|
|
|
|
func countRunes(s string) int {
|
|
n := 0
|
|
for i := 0; i < len(s); {
|
|
_, size := decodeRuneAt(s[i:])
|
|
i += size
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// runeToByte returns the byte offset of the n-th rune start in s (n may be
|
|
// the rune count, yielding len(s)).
|
|
func runeToByte(s string, n int) int {
|
|
pos := 0
|
|
for i := 0; i < n && pos < len(s); i++ {
|
|
_, size := decodeRuneAt(s[pos:])
|
|
pos += size
|
|
}
|
|
return pos
|
|
}
|
|
|
|
func randomFuzzText(rng *rand.Rand, min, max int) string {
|
|
n := rng.Intn(max-min+1) + min
|
|
sb := strings.Builder{}
|
|
for i := 0; i < n; i++ {
|
|
switch rng.Intn(10) {
|
|
case 0:
|
|
sb.WriteString("中文")
|
|
case 1:
|
|
sb.WriteString("é😀")
|
|
case 2:
|
|
sb.WriteByte('\n')
|
|
default:
|
|
sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))])
|
|
sb.WriteByte(' ')
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// freshLineOffsets computes the ground-truth line-start offsets for s.
|
|
func freshLineOffsets(s string) []int32 {
|
|
off := []int32{0}
|
|
for i, b := range s {
|
|
if b == '\n' {
|
|
off = append(off, int32(i+1))
|
|
}
|
|
}
|
|
return off
|
|
}
|