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

131
tests/test_swap_cleanup.cc Normal file
View File

@@ -0,0 +1,131 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
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());
}
TEST (SwapCleanup_ResetJournalOnSave)
{
ktet::InstallDefaultCommandsOnce();
const fs::path xdg_root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_state_swap_cleanup_") + std::to_string((int) ::getpid()));
fs::remove_all(xdg_root);
fs::create_directories(xdg_root);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
const std::string path = (xdg_root / "work" / "file.txt").string();
fs::create_directories((xdg_root / "work"));
std::remove(path.c_str());
write_file_bytes(path, "base\n");
Editor ed;
ed.SetDimensions(24, 80);
// Seed scratch buffer so OpenFile can reuse it.
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to ensure swap is created.
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "X"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Save should reset/delete the journal.
ASSERT_TRUE(Execute(ed, CommandId::Save));
ed.Swap()->Flush(b);
ASSERT_TRUE(!fs::exists(swp));
// Subsequent edits should recreate a fresh swap.
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Y"));
ed.Swap()->Flush(b);
ASSERT_TRUE(fs::exists(swp));
// Cleanup.
ed.Swap()->Detach(b);
std::remove(path.c_str());
std::remove(swp.c_str());
if (!old_xdg.empty())
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(xdg_root);
}
TEST (SwapCleanup_PruneSwapDir_ByAge)
{
const fs::path xdg_root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_state_swap_prune_") + std::to_string((int) ::getpid()));
fs::remove_all(xdg_root);
fs::create_directories(xdg_root);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_s.c_str(), 1);
const fs::path swapdir = xdg_root / "kte" / "swap";
fs::create_directories(swapdir);
const fs::path oldp = swapdir / "old.swp";
const fs::path newp = swapdir / "new.swp";
const fs::path keep = swapdir / "note.txt";
write_file_bytes(oldp.string(), "x");
write_file_bytes(newp.string(), "y");
write_file_bytes(keep.string(), "z");
// Make old.swp look old (2 days ago) and new.swp recent.
std::error_code ec;
fs::last_write_time(oldp, fs::file_time_type::clock::now() - std::chrono::hours(48), ec);
fs::last_write_time(newp, fs::file_time_type::clock::now(), ec);
kte::SwapManager sm;
kte::SwapConfig cfg;
cfg.prune_on_startup = false;
cfg.prune_max_age_days = 1;
cfg.prune_max_files = 0; // disable count-based pruning for this test
sm.SetConfig(cfg);
sm.PruneSwapDir();
ASSERT_TRUE(!fs::exists(oldp));
ASSERT_TRUE(fs::exists(newp));
ASSERT_TRUE(fs::exists(keep));
// Cleanup.
std::remove(newp.string().c_str());
std::remove(keep.string().c_str());
if (!old_xdg.empty())
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(xdg_root);
}

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);
}

View File

@@ -3,9 +3,11 @@
#include "Buffer.h"
#include "Swap.h"
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
static void
@@ -37,6 +39,30 @@ buffer_bytes_via_views(const Buffer &b)
}
static std::vector<std::uint8_t>
record_types_from_bytes(const std::string &bytes)
{
std::vector<std::uint8_t> types;
if (bytes.size() < 64)
return types;
std::size_t off = 64;
while (off < bytes.size()) {
if (bytes.size() - off < 8)
break;
const std::uint8_t type = static_cast<std::uint8_t>(bytes[off + 0]);
const std::uint32_t len = (std::uint32_t) static_cast<std::uint8_t>(bytes[off + 1]) |
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 2]) << 8) |
((std::uint32_t) static_cast<std::uint8_t>(bytes[off + 3]) << 16);
const std::size_t crc_off = off + 4 + (std::size_t) len;
if (crc_off + 4 > bytes.size())
break;
types.push_back(type);
off = crc_off + 4;
}
return types;
}
TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
{
const std::string path = "./.kte_ut_swap_replay_1.txt";
@@ -111,4 +137,91 @@ TEST (SwapReplay_TruncatedLog_FailsSafely)
std::remove(path.c_str());
std::remove(swap_path.c_str());
std::remove(trunc_path.c_str());
}
TEST (SwapReplay_Checkpoint_Midstream_ExactBytesMatch)
{
const std::string path = "./.kte_ut_swap_replay_chkpt_1.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\nline2\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
// Some edits, then an explicit checkpoint, then more edits.
b.insert_text(0, 0, std::string("X"));
sm.Checkpoint(&b);
b.insert_text(1, 0, std::string("ZZ"));
b.delete_text(0, 0, 1);
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);
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapCompaction_RewritesToSingleCheckpoint)
{
const std::string path = "./.kte_ut_swap_compact_1.txt";
std::remove(path.c_str());
write_file_bytes(path, "base\n");
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
kte::SwapManager sm;
kte::SwapConfig cfg;
cfg.checkpoint_bytes = 0;
cfg.checkpoint_interval_ms = 0;
cfg.compact_bytes = 1; // force compaction on any checkpoint
sm.SetConfig(cfg);
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
// Ensure there is at least one non-checkpoint record on disk first.
b.insert_text(0, 0, std::string("abc"));
sm.Flush(&b);
// Now emit a checkpoint; compaction should rewrite the file to just that checkpoint.
sm.Checkpoint(&b);
sm.Flush(&b);
const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b);
const std::string expected = buffer_bytes_via_views(b);
// Close journal.
b.SetSwapRecorder(nullptr);
sm.Detach(&b);
const std::string bytes = read_file_bytes(swap_path);
const std::vector<std::uint8_t> types = record_types_from_bytes(bytes);
ASSERT_EQ(types.size(), (std::size_t) 1);
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::CHKPT);
Buffer b2;
ASSERT_TRUE(b2.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err));
ASSERT_EQ(buffer_bytes_via_views(b2), expected);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}

View File

@@ -71,8 +71,10 @@ TEST (SwapWriter_Header_Records_And_CRC)
(std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid()));
std::filesystem::remove_all(xdg_root);
const char *old_xdg = std::getenv("XDG_STATE_HOME");
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_root_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
std::filesystem::create_directories((xdg_root / "work"));
@@ -148,14 +150,15 @@ TEST (SwapWriter_Header_Records_And_CRC)
off = crc_off + 4;
}
ASSERT_EQ(types.size(), (std::size_t) 2);
ASSERT_EQ(types.size(), (std::size_t) 3);
ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS);
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
ASSERT_EQ(types[2], (std::uint8_t) kte::SwapRecType::CHKPT);
std::remove(path.c_str());
std::remove(swp.c_str());
if (old_xdg) {
setenv("XDG_STATE_HOME", old_xdg, 1);
if (!old_xdg.empty()) {
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
} else {
unsetenv("XDG_STATE_HOME");
}
@@ -171,8 +174,10 @@ TEST (SwapWriter_NoStomp_SameBasename)
std::filesystem::remove_all(xdg_root);
std::filesystem::create_directories(xdg_root);
const char *old_xdg = std::getenv("XDG_STATE_HOME");
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string();
const std::string xdg_root_s = xdg_root.string();
setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1);
const std::filesystem::path d1 = xdg_root / "p1";
const std::filesystem::path d2 = xdg_root / "p2";
@@ -227,8 +232,8 @@ TEST (SwapWriter_NoStomp_SameBasename)
std::remove(swp2.c_str());
std::remove(f1.string().c_str());
std::remove(f2.string().c_str());
if (old_xdg) {
setenv("XDG_STATE_HOME", old_xdg, 1);
if (!old_xdg.empty()) {
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
} else {
unsetenv("XDG_STATE_HOME");
}