Swap: keep the journal replayable across failures and crashes

- Replay recovers every complete record before a torn final record (the
  normal result of a crash mid-append) and reports it through err,
  instead of rejecting the whole journal. SwapReplay_TruncatedLog is
  updated to the new behaviour.
- A record that cannot be written (open/write failure, breaker open)
  marks the journal as having a gap. Later position-based records are
  dropped until a checkpoint re-establishes the content; recorders
  request one on the next edit (at most once per second).
- A failed append is truncated back off the file, and a failed header
  write closes the file so the next attempt starts fresh.
- Inserts over the 24-bit record limit are split into consecutive
  records; buffers too large to checkpoint are no longer snapshotted
  and rejected on every checkpoint.
- Declining to delete an unreadable swap moves it to .corrupt so new
  edits are not appended behind the bad data.
- NotifyFilenameChanged no longer writes a full checkpoint into the
  journal it is about to delete.

Audit findings D7, P5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent 8cac2e9af8
commit aa53d75f96
7 changed files with 304 additions and 39 deletions
+11 -1
View File
@@ -386,8 +386,16 @@ Editor::ResolveRecoveryPrompt(const bool yes)
return true;
}
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
std::string kept_as;
if (yes) {
(void) std::remove(swp.c_str());
} else {
// Keep the unreadable journal for inspection, but out of the way:
// left in place, the new session's records would be appended
// behind the corrupt data and could never be replayed.
kept_as = swp + ".corrupt";
if (std::rename(swp.c_str(), kept_as.c_str()) != 0)
kept_as.clear();
}
if (!OpenFile(req.path, err)) {
SetStatus(err);
@@ -395,7 +403,9 @@ Editor::ResolveRecoveryPrompt(const bool yes)
}
apply_pending_line(*this, req.line1);
// Include a short hint that the swap was corrupt.
if (!rerr_s.empty()) {
if (!kept_as.empty()) {
SetStatus("Opened " + req.path + " (unreadable swap kept as " + kept_as + ")");
} else if (!rerr_s.empty()) {
SetStatus("Opened " + req.path + " (swap unreadable)");
} else {
SetStatus("Opened " + req.path);