From 1e312fcd0b643dc5260d3309f465e9dd824dd6ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 07:18:26 +0000 Subject: [PATCH] Swap: retry a journal's recovery checkpoint while idle After a lost record the journal drops position-based records until a full checkpoint. That checkpoint was only requested from later edits, rate-limited to once a second; when the writer handled the failing record before its edit asked for a checkpoint, the checkpoint failed as well and consumed the slot, so edits made within the next second were not recovered and, if the user stopped typing, the journal stayed behind indefinitely. SwapManager::RetryGapCheckpoints(), called once per frame from Editor::ProcessPendingOpens, requests it once the interval has passed. This also made SwapReplay_LostRecord_ResyncsWithCheckpoint flaky under load; the test now exercises the retry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB --- Editor.cc | 4 ++++ Swap.cc | 21 +++++++++++++++++++++ Swap.h | 7 +++++++ docs/audits/claude-findings-20260923.md | 6 ++++++ tests/test_swap_replay.cc | 8 ++++++++ 5 files changed, 46 insertions(+) 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