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.
424 lines
12 KiB
Go
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
|
|
}
|