Persist edits immediately so a fast app-kill loses no state

Reproduced on device: open a file, scroll, long-press-highlight a
word, then recents-wipe the app ~1s after the highlight. The session
file held the file but lost the selection and cursor: the 1s
rate-limit had not elapsed since the previous save, so the final
changes never flushed before the process died (a recents-wipe is a
kill, not a clean shutdown, so Shutdown's final save never runs).

Two changes in saveSessionIfChanged:

- Urgent path: save immediately when the file changes or a selection
  appears/vanishes. These are low-frequency, high-value changes
  (the user just opened a file or highlighted/cleared text), and they
  are exactly what 'where I left off' means.
- sessionSaveInterval 1s -> 250ms: bounds how stale scroll/cursor can
  be at an unlucky kill. The file is a few hundred bytes, so a few
  small writes a second during active scrolling is negligible.

TestRestore_UrgentSaveOnSelection drives the kill race: a selection
made right after the file-open save must land in the saver without
waiting out the interval (fails with the urgent path disabled).
This commit is contained in:
Greg Pomerantz 2026-08-20 18:06:29 -04:00
parent 03595af27d
commit 2eb18b5c70
2 changed files with 98 additions and 5 deletions

View File

@ -30,8 +30,12 @@ import (
)
// sessionSaveInterval rate-limits the periodic session save. The file is
// tiny (a few hundred bytes), so one write a second at most is negligible.
const sessionSaveInterval = time.Second
// tiny (a few hundred bytes), so a few writes a second at most is
// negligible. The window bounds how stale the persisted state can be if
// the process is killed (recents-wipe) right after a change: at 1s, a
// kill less than a second after the last edit lost the cursor, selection
// and final scroll. See saveSessionIfChanged for the urgent path.
const sessionSaveInterval = 250 * time.Millisecond
// SessionState is the relaunch snapshot (see the file doc above). All
// positions are absolute file byte offsets; Scroll is in Dp. It is a plain
@ -101,13 +105,21 @@ func (l *Logic) saveSessionIfChanged() {
return
}
now := time.Now()
if now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
s := l.SnapshotSession()
if s == l.lastSession {
return
}
// Save immediately when the file changes or a selection appears or
// vanishes — low-frequency, high-value changes (the user just opened a
// file or highlighted/cleared text). Everything else (scroll ticks,
// cursor moves, selection-handle drags) is high-frequency and stays
// rate-limited; a kill within the window then loses at most a fraction
// of a second of motion, not the edit state.
urgent := s.File != l.lastSession.File ||
(s.SelStart >= 0) != (l.lastSession.SelStart >= 0)
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
l.sessionSaver(s)
l.lastSession = s
l.lastSessionSave = now

View File

@ -471,3 +471,84 @@ func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) {
}
}
}
// TestRestore_UrgentSaveOnSelection verifies the kill-race fix: a selection
// appearing (the user just highlighted text) saves immediately, even inside
// the rate-limit window that the file-open save opened. Without the urgent
// path, killing the app (recents-wipe) less than a second after highlighting
// lost the selection and cursor.
func TestRestore_UrgentSaveOnSelection(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {})
l.SetSessionSaver(func(s editor.SessionState) { saves <- s })
go l.Run()
defer l.Shutdown()
// Open the file: the file change is itself urgent, so a save lands as
// the load settles. Drain everything up to and including the loaded
// snapshot.
if _, ok := l.Inspect(func(st *editor.State) any {
editor.OpenFile("/a.txt")
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
loaded := false
for i := 0; i < 100 && !loaded; i++ {
v, ok := l.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() == 6
})
if ok {
loaded = v.(bool)
}
if loaded {
break
}
time.Sleep(20 * time.Millisecond)
}
if !loaded {
t.Fatal("file did not load")
}
// Drain saves up to the first one describing the loaded file.
deadline := time.After(3 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/a.txt" {
goto drainDone
}
case <-deadline:
t.Fatal("timed out: no save for the opened file")
}
}
drainDone:
// Immediately (well inside any rate-limit window after the saves above),
// make a selection appear and force a frame: the urgent path must save it
// now, not after the interval elapses.
if _, ok := l.Inspect(func(st *editor.State) any {
st.Editor.SelectionAnchor = 1
st.Editor.SelectionStart = 1
st.Editor.SelectionEnd = 4
st.Editor.CursorPosition = 4
st.ScrollOffset = 3
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
l.ConfigChan() <- editor.ConfigEvent{PixelWidth: 780, PixelHeight: 1688} // force emitFrame
select {
case s := <-saves:
if s.SelStart == 1 && s.SelEnd == 4 && s.Cursor == 4 {
return
}
t.Fatalf("unexpected snapshot: %+v", s)
case <-time.After(2 * time.Second):
t.Fatal("timed out: selection appearance was not saved immediately (urgent path)")
}
}