Swap/recovery: second-pass fixes from the follow-up review

- Recovery works when another named buffer is current. On that path
  OpenFile's NotifyFilenameChanged deleted the journal (old path ==
  new path) that was about to be replayed; the swap was lost and the
  file opened without the edits (pre-existing).
- After recovering from a journal with a torn final record, the torn
  bytes are truncated before the session appends to it (ReplayFile now
  reports the valid prefix length). A journal that recovers nothing is
  removed instead of appended to.
- Reload restarts the journal; recovery used to replay the edits the
  reload discarded.
- Opening a file that is already open switches to its buffer (in
  OpenFile and before the recovery check). Two buffers for one file
  interleaved records in one journal, and closing either unlinked it.
- Swap file names escape '!' and '%', so distinct paths no longer
  share a journal ('/x/a!b' vs '/x/a/b'). Swap files for paths that
  contain those characters get new names.
- The gap flag is cleared when the journal restarts, and a gap that
  cannot be closed (buffer too large to checkpoint) is reported once.
- crc32's table is initialised thread-safely; writer-thread error
  reports identify the buffer by journal path instead of racing on
  Buffer::Filename(); Close() no longer retries on EINTR (the fd is
  already released and may have been reused).

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 9f3da72765
commit 061e836fa4
8 changed files with 365 additions and 35 deletions
+75 -2
View File
@@ -1,5 +1,6 @@
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <utility>
@@ -50,6 +51,46 @@ apply_pending_line(Editor &ed, const std::size_t line1)
} // namespace
namespace {
// Same normalization as Buffer::OpenFromFile (expand "~", canonical path if
// the file exists, else absolute), so a path can be compared with the
// Filename() of open buffers.
std::string
normalize_open_path(const std::string &in)
{
std::string expanded = in;
if (!expanded.empty() && expanded[0] == '~') {
const char *home = std::getenv("HOME");
if (home && expanded.size() >= 2 && (expanded[1] == '/' || expanded[1] == '\\'))
expanded = std::string(home) + expanded.substr(1);
else if (home && expanded.size() == 1)
expanded = std::string(home);
}
try {
std::filesystem::path p(expanded);
if (std::filesystem::exists(p))
return std::filesystem::canonical(p).string();
return std::filesystem::absolute(p).string();
} catch (...) {
return expanded;
}
}
} // namespace
std::size_t
Editor::FindOpenBuffer(const std::string &path) const
{
const std::string norm = normalize_open_path(path);
const auto &bufs = Buffers();
for (std::size_t i = 0; i < bufs.size(); ++i) {
if (!bufs[i].Filename().empty() && bufs[i].Filename() == norm)
return i;
}
return static_cast<std::size_t>(-1);
}
Editor::Editor()
{
swap_ = std::make_unique<kte::SwapManager>();
@@ -228,6 +269,14 @@ Editor::AddBuffer(Buffer &&buf)
bool
Editor::OpenFile(const std::string &path, std::string &err)
{
// A file that is already open is switched to, not opened again: two
// buffers for one file would share (and corrupt) one swap journal, and
// closing either would delete the other's journal.
if (const std::size_t open_idx = FindOpenBuffer(path); open_idx != static_cast<std::size_t>(-1)) {
SwitchTo(open_idx);
return true;
}
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// it instead of creating a new one.
auto &bufs_ref = Buffers();
@@ -372,10 +421,20 @@ Editor::ResolveRecoveryPrompt(const bool yes)
return false;
}
std::string rerr;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
std::uint64_t valid_bytes = 0;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
SetStatus("Swap recovery failed: " + rerr);
return false;
}
// This session keeps appending to the same journal. Drop a torn
// final record first, or new records would land behind it and the
// journal could not be replayed after another crash.
try {
if (std::filesystem::file_size(swp) > valid_bytes)
std::filesystem::resize_file(swp, valid_bytes);
} catch (...) {
// Best effort; the replay itself succeeded.
}
b->SetDirty(true);
apply_pending_line(*this, req.line1);
SetStatus("Recovered " + req.path);
@@ -436,6 +495,15 @@ Editor::ProcessPendingOpens()
pending_open_.pop_front();
if (req.path.empty())
continue;
// Already open: switch to it. Its swap file is this session's live
// journal, not something to recover.
if (const std::size_t open_idx = FindOpenBuffer(req.path); open_idx != static_cast<std::size_t>(-1)) {
SwitchTo(open_idx);
apply_pending_line(*this, req.line1);
SetStatus("Switched to " + Buffers()[open_idx].Filename());
opened_any = true;
return opened_any;
}
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
bool swp_exists = false;
@@ -452,7 +520,12 @@ Editor::ProcessPendingOpens()
std::string rerr;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
const std::string rec = buffer_bytes_via_views(tmp);
if (rec != orig) {
if (rec == orig) {
// Nothing to recover. Remove the journal rather than
// appending this session's records to it (it may end in a
// torn record, which would make them unreplayable).
(void) std::remove(swp.c_str());
} else {
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp;