diff --git a/Editor.cc b/Editor.cc index 686b5b0..b84c67a 100644 --- a/Editor.cc +++ b/Editor.cc @@ -493,6 +493,10 @@ Editor::ProcessPendingOpens() // runs from every frontend's loop, outside command dispatch: report the // failure instead of letting it end the editor. try { + // Also the once-per-frame hook for the journal: retry the + // checkpoint of a journal that lost a record. + if (kte::SwapManager *sm = Swap()) + sm->RetryGapCheckpoints(); return process_pending_opens_(); } catch (const std::exception &e) { kte::ErrorHandler::Instance().Error("Editor", std::string("open failed: ") + e.what(), ""); diff --git a/Swap.cc b/Swap.cc index f433bda..e076132 100644 --- a/Swap.cc +++ b/Swap.cc @@ -1172,6 +1172,27 @@ SwapManager::maybe_request_checkpoint(Buffer &buf, const std::size_t approx_edit } +void +SwapManager::RetryGapCheckpoints() +{ + std::vector due; + { + std::lock_guard lg(mtx_); + const std::uint64_t now = now_ns(); + for (auto &[b, ctx]: journals_) { + if (!b || !ctx.gap || ctx.suspended || ctx.locked_out || ctx.gap_unfixable_reported) + continue; + if (now - ctx.gap_chkpt_request_ns < kGapCheckpointIntervalNs) + continue; + ctx.gap_chkpt_request_ns = now; + due.push_back(b); + } + } + for (Buffer *b: due) + RecordCheckpoint(*b, false); +} + + void SwapManager::RecordCheckpoint(Buffer &buf, const bool urgent_flush) { diff --git a/Swap.h b/Swap.h index f7a08b0..f5e53c1 100644 --- a/Swap.h +++ b/Swap.h @@ -80,6 +80,13 @@ public: // This is best-effort and asynchronous; call Flush() if you need it written before continuing. void Checkpoint(Buffer *buf = nullptr); + // Main thread, called once per frame: request the recovery checkpoint of + // any journal left with a gap (a lost record) whose last attempt is more + // than the retry interval ago. Otherwise that checkpoint is only + // requested by the next edit, and a journal that lost a record just + // before the user stopped typing would stay behind indefinitely. + void RetryGapCheckpoints(); + void SetConfig(const SwapConfig &cfg) { diff --git a/docs/audits/claude-findings-20260923.md b/docs/audits/claude-findings-20260923.md index 0379656..c21ce70 100644 --- a/docs/audits/claude-findings-20260923.md +++ b/docs/audits/claude-findings-20260923.md @@ -424,6 +424,12 @@ before the fix (unless noted). the skip now applies only to search-as-you-type (at 2,000 bytes, where the worst case is well under a second), says so in the status line, and moving to the next/previous match searches every line. +- After a lost journal record, the recovery checkpoint was requested + only by later edits, at most once a second; if the first attempt + failed and the user then stopped typing, the journal stayed behind + indefinitely. The editor loop now retries it each second while idle + (this was also why SwapReplay_LostRecord_ResyncsWithCheckpoint was + flaky under load). **Known limitations (not fixed)** - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very diff --git a/tests/test_swap_replay.cc b/tests/test_swap_replay.cc index cdf5d41..207bcde 100644 --- a/tests/test_swap_replay.cc +++ b/tests/test_swap_replay.cc @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include @@ -313,6 +315,12 @@ TEST(SwapReplay_LostRecord_ResyncsWithCheckpoint) sm.Flush(&b); b.insert_text(0, 3, std::string("D")); sm.Flush(&b); + // If the writer handled B before its edit asked for a checkpoint, that + // checkpoint failed too and the next attempt is rate-limited; the + // per-frame retry then brings the journal back in step without an edit. + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + sm.RetryGapCheckpoints(); + sm.Flush(&b); const std::string expected = buffer_bytes_via_views(b); ASSERT_EQ(expected, std::string("ABCDbase\n")); // Replay the journal as it stands mid-session (as after a crash); Detach