Introduce swap journaling crash recovery system with tests.

- Added detailed journaling system (`SwapManager`) for crash recovery, including edit recording and replay.
- Integrated recovery prompts for handling swap files during file open flows.
- Implemented swap file cleanup, checkpointing, and compaction mechanisms.
- Added extensive unit tests for swap-related behaviors such as recovery prompts, file pruning, and corruption handling.
- Updated CMake to include new test files.
This commit is contained in:
2026-02-13 08:44:35 -08:00
parent 895e4ccb1e
commit 2a6ff2a862
19 changed files with 1697 additions and 144 deletions

View File

@@ -0,0 +1,280 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include "Swap.h"
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace {
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
static std::string
read_file_bytes(const std::string &path)
{
std::ifstream in(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
}
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const auto &rows = b.Rows();
std::string out;
for (std::size_t i = 0; i < rows.size(); i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
struct ScopedXdgStateHome {
std::string old;
bool had{false};
explicit ScopedXdgStateHome(const std::string &p)
{
const char *old_p = std::getenv("XDG_STATE_HOME");
had = (old_p && *old_p);
old = old_p ? std::string(old_p) : std::string();
setenv("XDG_STATE_HOME", p.c_str(), 1);
}
~ScopedXdgStateHome()
{
if (had && !old.empty()) {
setenv("XDG_STATE_HOME", old.c_str(), 1);
} else {
unsetenv("XDG_STATE_HOME");
}
}
};
} // namespace
TEST (SwapRecoveryPrompt_Recover_ReplaysSwap)
{
ktet::InstallDefaultCommandsOnce();
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_recover_") +
std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const ScopedXdgStateHome scoped(xdg_root.string());
const std::filesystem::path work = xdg_root / "work";
std::filesystem::create_directories(work);
const std::string file_path = (work / "recover.txt").string();
write_file_bytes(file_path, "base\nline2\n");
// Create a swap journal with unsaved edits.
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(file_path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("X"));
b.insert_text(1, 0, std::string("ZZ"));
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
const std::string expected = buffer_bytes_via_views(b);
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
// Now attempt to open via Editor deferred-open; this should trigger a recovery prompt.
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(b.Filename());
ASSERT_EQ(ed.ProcessPendingOpens(), false);
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
ASSERT_EQ(ed.PromptActive(), true);
// Answer 'y' to recover.
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(ed.PromptActive(), false);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), expected);
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), true);
ASSERT_TRUE(std::filesystem::exists(swap_path));
std::remove(file_path.c_str());
std::remove(swap_path.c_str());
std::filesystem::remove_all(xdg_root);
}
TEST (SwapRecoveryPrompt_Discard_DeletesSwapAndOpensClean)
{
ktet::InstallDefaultCommandsOnce();
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_discard_") +
std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const ScopedXdgStateHome scoped(xdg_root.string());
const std::filesystem::path work = xdg_root / "work";
std::filesystem::create_directories(work);
const std::string file_path = (work / "discard.txt").string();
write_file_bytes(file_path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(file_path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("X"));
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
ASSERT_TRUE(std::filesystem::exists(swap_path));
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(b.Filename());
ASSERT_EQ(ed.ProcessPendingOpens(), false);
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
ASSERT_EQ(ed.PromptActive(), true);
// Default answer (empty) is 'no' => discard.
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(ed.PromptActive(), false);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
ASSERT_EQ(ed.CurrentBuffer()->Dirty(), false);
ASSERT_EQ(std::filesystem::exists(swap_path), false);
std::remove(file_path.c_str());
std::filesystem::remove_all(xdg_root);
}
TEST (SwapRecoveryPrompt_Cancel_AbortsOpen)
{
ktet::InstallDefaultCommandsOnce();
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_cancel_") +
std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const ScopedXdgStateHome scoped(xdg_root.string());
const std::filesystem::path work = xdg_root / "work";
std::filesystem::create_directories(work);
const std::string file_path = (work / "cancel.txt").string();
write_file_bytes(file_path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(file_path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, std::string("X"));
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(b.Filename());
ASSERT_EQ(ed.ProcessPendingOpens(), false);
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::RecoverOrDiscard);
ASSERT_EQ(ed.PromptActive(), true);
// Cancel the prompt (C-g / Refresh).
ASSERT_TRUE(Execute(ed, CommandId::Refresh));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(ed.PromptActive(), false);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(ed.CurrentBuffer()->Filename().empty(), true);
ASSERT_TRUE(std::filesystem::exists(swap_path));
std::remove(file_path.c_str());
std::remove(swap_path.c_str());
std::filesystem::remove_all(xdg_root);
}
TEST (SwapRecoveryPrompt_CorruptSwap_OffersDelete)
{
ktet::InstallDefaultCommandsOnce();
const std::filesystem::path xdg_root = std::filesystem::temp_directory_path() /
(std::string("kte_ut_xdg_state_corrupt_") +
std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const ScopedXdgStateHome scoped(xdg_root.string());
const std::filesystem::path work = xdg_root / "work";
std::filesystem::create_directories(work);
const std::string file_path = (work / "corrupt.txt").string();
write_file_bytes(file_path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(file_path, err));
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
// Write a corrupt swap file at the expected location.
try {
std::filesystem::create_directories(std::filesystem::path(swap_path).parent_path());
} catch (...) {
// ignore
}
write_file_bytes(swap_path, "x");
ASSERT_TRUE(std::filesystem::exists(swap_path));
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
ed.RequestOpenFile(b.Filename());
ASSERT_EQ(ed.ProcessPendingOpens(), false);
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::DeleteCorruptSwap);
ASSERT_EQ(ed.PromptActive(), true);
// Answer 'y' to delete the corrupt swap and proceed.
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "y"));
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(ed.PendingRecoveryPrompt(), Editor::RecoveryPromptKind::None);
ASSERT_EQ(ed.PromptActive(), false);
ASSERT_TRUE(ed.CurrentBuffer() != nullptr);
ASSERT_EQ(buffer_bytes_via_views(*ed.CurrentBuffer()), read_file_bytes(b.Filename()));
ASSERT_EQ(std::filesystem::exists(swap_path), false);
std::remove(file_path.c_str());
std::filesystem::remove_all(xdg_root);
}