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:
parent
03595af27d
commit
2eb18b5c70
|
|
@ -30,8 +30,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionSaveInterval rate-limits the periodic session save. The file is
|
// sessionSaveInterval rate-limits the periodic session save. The file is
|
||||||
// tiny (a few hundred bytes), so one write a second at most is negligible.
|
// tiny (a few hundred bytes), so a few writes a second at most is
|
||||||
const sessionSaveInterval = time.Second
|
// 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
|
// 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
|
// positions are absolute file byte offsets; Scroll is in Dp. It is a plain
|
||||||
|
|
@ -101,13 +105,21 @@ func (l *Logic) saveSessionIfChanged() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(l.lastSessionSave) < sessionSaveInterval {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s := l.SnapshotSession()
|
s := l.SnapshotSession()
|
||||||
if s == l.lastSession {
|
if s == l.lastSession {
|
||||||
return
|
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.sessionSaver(s)
|
||||||
l.lastSession = s
|
l.lastSession = s
|
||||||
l.lastSessionSave = now
|
l.lastSessionSave = now
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user