Pad/internal/test/e2e/real_file_fuzz_test.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

424 lines
12 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
CtxStart int
Context string
OffsetRune int
}
raw, err := h.Inspect(func(st *editor.State) any {
return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText,
st.Editor.IMEContextStartByte, st.Editor.IMEContext, st.Editor.IMEOffsetRune}
})
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))]))
}
// Context consistency: it is the slice immediately before the
// window, and the snippet's Range.Start is the rune offset of
// the context start.
if len(w.Context) > 0 {
if w.CtxStart+len(w.Context) != w.Start ||
w.Context != model[w.CtxStart:w.CtxStart+len(w.Context)] {
t.Fatalf("batch %d op %d: IME context desync: ctxStart=%d", batch, j, w.CtxStart)
}
}
if w.OffsetRune != countRunes(model[:w.CtxStart]) {
t.Fatalf("batch %d op %d: IME offset desync: got=%d want=%d", batch, j, w.OffsetRune, countRunes(model[:w.CtxStart]))
}
// The real IME reports ABSOLUTE file runes: the pushed snippet
// (context+window) starts at OffsetRune, so a snippet-local
// range [sa,sb) is absolute [OffsetRune+sa, OffsetRune+sb).
ctxRunes := countRunes(w.Context)
total := ctxRunes + countRunes(w.Text)
sa := rng.Intn(total + 1)
sb := sa + rng.Intn(total-sa+1)
absByte := func(snipRune int) int {
if snipRune <= ctxRunes {
return w.CtxStart + runeToByte(w.Context, snipRune)
}
return w.Start + runeToByte(w.Text, snipRune-ctxRunes)
}
text := randomFuzzText(rng, 0, 12)
if err := h.WithState(func(st *editor.State) {
editor.HandleReplaceRange(w.OffsetRune+sa, w.OffsetRune+sb, text)
}); err != nil {
t.Fatalf("batch %d op %d: replace: %v", batch, j, err)
}
bs := absByte(sa)
be := absByte(sb)
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
}