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:
10
Buffer.cc
10
Buffer.cc
@@ -575,6 +575,16 @@ Buffer::delete_row(int row)
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::replace_all_bytes(const std::string_view bytes)
|
||||
{
|
||||
content_.Clear();
|
||||
if (!bytes.empty())
|
||||
content_.Append(bytes.data(), bytes.size());
|
||||
rows_cache_dirty_ = true;
|
||||
}
|
||||
|
||||
|
||||
// Undo system accessors
|
||||
UndoSystem *
|
||||
Buffer::Undo()
|
||||
|
||||
11
Buffer.h
11
Buffer.h
@@ -494,6 +494,12 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||
{
|
||||
return swap_rec_;
|
||||
}
|
||||
|
||||
|
||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
@@ -508,6 +514,11 @@ public:
|
||||
|
||||
void delete_row(int row);
|
||||
|
||||
// Replace the entire buffer content with raw bytes.
|
||||
// Intended for crash recovery (swap replay) and test harnesses.
|
||||
// This does not trigger swap or undo recording.
|
||||
void replace_all_bytes(std::string_view bytes);
|
||||
|
||||
// Undo system accessors (created per-buffer)
|
||||
[[nodiscard]] UndoSystem *Undo();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.6.1")
|
||||
set(KTE_VERSION "1.6.2")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -308,6 +308,8 @@ if (BUILD_TESTS)
|
||||
tests/test_swap_recorder.cc
|
||||
tests/test_swap_writer.cc
|
||||
tests/test_swap_replay.cc
|
||||
tests/test_swap_recovery_prompt.cc
|
||||
tests/test_swap_cleanup.cc
|
||||
tests/test_piece_table.cc
|
||||
tests/test_search.cc
|
||||
tests/test_search_replace_flow.cc
|
||||
|
||||
41
Command.cc
41
Command.cc
@@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx)
|
||||
return false;
|
||||
}
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||
return true;
|
||||
}
|
||||
@@ -632,6 +634,8 @@ cmd_save(CommandContext &ctx)
|
||||
return false;
|
||||
}
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
@@ -686,6 +690,10 @@ cmd_save_as(CommandContext &ctx)
|
||||
ctx.editor.SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
@@ -789,6 +797,7 @@ cmd_refresh(CommandContext &ctx)
|
||||
ctx.editor.SetCloseConfirmPending(false);
|
||||
ctx.editor.SetCloseAfterSave(false);
|
||||
ctx.editor.ClearPendingOverwritePath();
|
||||
ctx.editor.CancelRecoveryPrompt();
|
||||
ctx.editor.CancelPrompt();
|
||||
ctx.editor.SetStatus("Canceled");
|
||||
return true;
|
||||
@@ -2441,7 +2450,6 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.SetSearchIndex(-1);
|
||||
return true;
|
||||
} else if (kind == Editor::PromptKind::OpenFile) {
|
||||
std::string err;
|
||||
// Expand "~" to the user's home directory
|
||||
auto expand_user_path = [](const std::string &in) -> std::string {
|
||||
if (!in.empty() && in[0] == '~') {
|
||||
@@ -2458,15 +2466,20 @@ cmd_newline(CommandContext &ctx)
|
||||
value = expand_user_path(value);
|
||||
if (value.empty()) {
|
||||
ctx.editor.SetStatus("Open canceled (empty)");
|
||||
} else if (!ctx.editor.OpenFile(value, err)) {
|
||||
ctx.editor.SetStatus(err.empty() ? std::string("Failed to open ") + value : err);
|
||||
} else {
|
||||
ctx.editor.SetStatus(std::string("Opened ") + value);
|
||||
ctx.editor.RequestOpenFile(value);
|
||||
const bool opened = ctx.editor.ProcessPendingOpens();
|
||||
if (ctx.editor.PromptActive()) {
|
||||
// A recovery confirmation prompt was started.
|
||||
return true;
|
||||
}
|
||||
if (opened) {
|
||||
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
||||
cmd_center_on_cursor(ctx);
|
||||
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
||||
ctx.editor.CancelPrompt();
|
||||
}
|
||||
}
|
||||
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
||||
// Resolve to a buffer index by exact match against path or basename;
|
||||
// if multiple partial matches, prefer exact; if none, keep status.
|
||||
@@ -2579,6 +2592,10 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.SetStatus(err);
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
ctx.editor.SetStatus("Saved as " + target);
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
@@ -2612,6 +2629,16 @@ cmd_newline(CommandContext &ctx)
|
||||
ctx.editor.ClearPendingOverwritePath();
|
||||
// Regardless of answer, end any close-after-save pending state for safety.
|
||||
ctx.editor.SetCloseAfterSave(false);
|
||||
} else if (ctx.editor.PendingRecoveryPrompt() != Editor::RecoveryPromptKind::None) {
|
||||
bool yes = false;
|
||||
if (!value.empty()) {
|
||||
char c = value[0];
|
||||
yes = (c == 'y' || c == 'Y');
|
||||
}
|
||||
(void) ctx.editor.ResolveRecoveryPrompt(yes);
|
||||
ctx.editor.CancelPrompt();
|
||||
// Continue any queued opens (e.g., startup argv files).
|
||||
ctx.editor.ProcessPendingOpens();
|
||||
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
||||
bool yes = false;
|
||||
if (!value.empty()) {
|
||||
@@ -2630,6 +2657,8 @@ cmd_newline(CommandContext &ctx)
|
||||
proceed_to_close = false;
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap())
|
||||
sm->ResetJournal(*buf);
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
}
|
||||
@@ -2639,6 +2668,10 @@ cmd_newline(CommandContext &ctx)
|
||||
proceed_to_close = false;
|
||||
} else {
|
||||
buf->SetDirty(false);
|
||||
if (auto *sm = ctx.editor.Swap()) {
|
||||
sm->NotifyFilenameChanged(*buf);
|
||||
sm->ResetJournal(*buf);
|
||||
}
|
||||
if (auto *u = buf->Undo())
|
||||
u->mark_saved();
|
||||
}
|
||||
|
||||
214
Editor.cc
214
Editor.cc
@@ -1,6 +1,7 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
@@ -8,6 +9,41 @@
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
namespace {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||
{
|
||||
if (line1 == 0)
|
||||
return;
|
||||
Buffer *b = ed.CurrentBuffer();
|
||||
if (!b)
|
||||
return;
|
||||
const std::size_t nrows = b->Nrows();
|
||||
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||
if (nrows > 0) {
|
||||
if (line >= nrows)
|
||||
line = nrows - 1;
|
||||
} else {
|
||||
line = 0;
|
||||
}
|
||||
b->SetCursor(0, line);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
Editor::Editor()
|
||||
{
|
||||
swap_ = std::make_unique<kte::SwapManager>();
|
||||
@@ -178,9 +214,9 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
// Setup highlighting using registry (extension + shebang)
|
||||
cur.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
const auto &rows = cur.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
const auto &cur_rows = cur.Rows();
|
||||
if (!cur_rows.empty())
|
||||
first = static_cast<std::string>(cur_rows[0]);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
cur.SetFiletype(ft);
|
||||
@@ -245,6 +281,172 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||
{
|
||||
PendingOpen p;
|
||||
p.path = path;
|
||||
p.line1 = line1;
|
||||
pending_open_.push_back(std::move(p));
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::HasPendingOpens() const
|
||||
{
|
||||
return !pending_open_.empty();
|
||||
}
|
||||
|
||||
|
||||
Editor::RecoveryPromptKind
|
||||
Editor::PendingRecoveryPrompt() const
|
||||
{
|
||||
return pending_recovery_prompt_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::CancelRecoveryPrompt()
|
||||
{
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
pending_recovery_open_ = PendingOpen{};
|
||||
pending_recovery_swap_path_.clear();
|
||||
pending_recovery_replay_err_.clear();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||
{
|
||||
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||
if (kind == RecoveryPromptKind::None)
|
||||
return false;
|
||||
const PendingOpen req = pending_recovery_open_;
|
||||
const std::string swp = pending_recovery_swap_path_;
|
||||
const std::string rerr_s = pending_recovery_replay_err_;
|
||||
CancelRecoveryPrompt();
|
||||
|
||||
std::string err;
|
||||
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||
if (yes) {
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
Buffer *b = CurrentBuffer();
|
||||
if (!b) {
|
||||
SetStatus("Recovery failed: no buffer");
|
||||
return false;
|
||||
}
|
||||
std::string rerr;
|
||||
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
||||
SetStatus("Swap recovery failed: " + rerr);
|
||||
return false;
|
||||
}
|
||||
b->SetDirty(true);
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Recovered " + req.path);
|
||||
return true;
|
||||
}
|
||||
// Discard: best-effort delete swap, then open clean.
|
||||
(void) std::remove(swp.c_str());
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
return true;
|
||||
}
|
||||
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||
if (yes) {
|
||||
(void) std::remove(swp.c_str());
|
||||
}
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
return false;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
// Include a short hint that the swap was corrupt.
|
||||
if (!rerr_s.empty()) {
|
||||
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||
} else {
|
||||
SetStatus("Opened " + req.path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::ProcessPendingOpens()
|
||||
{
|
||||
if (PromptActive())
|
||||
return false;
|
||||
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||
return false;
|
||||
|
||||
bool opened_any = false;
|
||||
while (!pending_open_.empty()) {
|
||||
PendingOpen req = std::move(pending_open_.front());
|
||||
pending_open_.pop_front();
|
||||
if (req.path.empty())
|
||||
continue;
|
||||
|
||||
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||
bool swp_exists = false;
|
||||
try {
|
||||
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||
} catch (...) {
|
||||
swp_exists = false;
|
||||
}
|
||||
if (swp_exists) {
|
||||
Buffer tmp;
|
||||
std::string oerr;
|
||||
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||
const std::string orig = buffer_bytes_via_views(tmp);
|
||||
std::string rerr;
|
||||
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
||||
const std::string rec = buffer_bytes_via_views(tmp);
|
||||
if (rec != orig) {
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||
pending_recovery_open_ = req;
|
||||
pending_recovery_swap_path_ = swp;
|
||||
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
} else {
|
||||
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||
pending_recovery_open_ = req;
|
||||
pending_recovery_swap_path_ = swp;
|
||||
pending_recovery_replay_err_ = rerr;
|
||||
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||
SetStatus(
|
||||
"Swap file unreadable for " + req.path +
|
||||
". Delete it? (y/N, C-g cancel)");
|
||||
return opened_any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string err;
|
||||
if (!OpenFile(req.path, err)) {
|
||||
SetStatus(err);
|
||||
opened_any = false;
|
||||
continue;
|
||||
}
|
||||
apply_pending_line(*this, req.line1);
|
||||
SetStatus("Opened " + req.path);
|
||||
opened_any = true;
|
||||
// Open at most one per call; frontends can call us again next frame.
|
||||
break;
|
||||
}
|
||||
return opened_any;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::SwitchTo(std::size_t index)
|
||||
{
|
||||
@@ -284,7 +486,9 @@ Editor::CloseBuffer(std::size_t index)
|
||||
return false;
|
||||
}
|
||||
if (swap_) {
|
||||
swap_->Detach(&buffers_[index]);
|
||||
// If the buffer is clean, remove its swap file when closing.
|
||||
// (Crash recovery is unaffected: on crash, close paths are not executed.)
|
||||
swap_->Detach(&buffers_[index], !buffers_[index].Dirty());
|
||||
buffers_[index].SetSwapRecorder(nullptr);
|
||||
}
|
||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
|
||||
37
Editor.h
37
Editor.h
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -497,6 +498,30 @@ public:
|
||||
|
||||
bool OpenFile(const std::string &path, std::string &err);
|
||||
|
||||
// Request that a file be opened. The request is processed by calling
|
||||
// ProcessPendingOpens() (typically once per frontend frame).
|
||||
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||
|
||||
// If no modal prompt is active, process queued open requests.
|
||||
// Returns true if a file was opened during this call.
|
||||
bool ProcessPendingOpens();
|
||||
|
||||
[[nodiscard]] bool HasPendingOpens() const;
|
||||
|
||||
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||
enum class RecoveryPromptKind {
|
||||
None = 0,
|
||||
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||
};
|
||||
|
||||
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||
|
||||
bool ResolveRecoveryPrompt(bool yes);
|
||||
|
||||
void CancelRecoveryPrompt();
|
||||
|
||||
// Buffer switching/closing
|
||||
bool SwitchTo(std::size_t index);
|
||||
|
||||
@@ -550,6 +575,11 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingOpen {
|
||||
std::string path;
|
||||
std::size_t line1{0}; // 1-based; 0 = none
|
||||
};
|
||||
|
||||
std::size_t rows_ = 0, cols_ = 0;
|
||||
int mode_ = 0;
|
||||
int kill_ = 0; // KILL CHAIN
|
||||
@@ -593,6 +623,13 @@ private:
|
||||
std::string prompt_text_;
|
||||
std::string pending_overwrite_path_;
|
||||
|
||||
// Deferred open + swap recovery prompt state
|
||||
std::deque<PendingOpen> pending_open_;
|
||||
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||
PendingOpen pending_recovery_open_{};
|
||||
std::string pending_recovery_swap_path_;
|
||||
std::string pending_recovery_replay_err_;
|
||||
|
||||
// GUI-only state (safe no-op in terminal builds)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
|
||||
@@ -298,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
|
||||
@@ -912,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
ed.SetFilePickerDir(e.path.string());
|
||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Open file on single click
|
||||
std::string err;
|
||||
if (!ed.OpenFile(e.path.string(), err)) {
|
||||
ed.SetStatus(std::string("open: ") + err);
|
||||
} else {
|
||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
||||
}
|
||||
ed.RequestOpenFile(e.path.string());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,6 +775,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
if (app_)
|
||||
app_->processEvents();
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
@@ -801,14 +804,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
||||
const QStringList files = dlg.selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
const QString fp = files.front();
|
||||
std::string err;
|
||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
||||
} else if (!err.empty()) {
|
||||
ed.SetStatus(std::string("Open failed: ") + err);
|
||||
} else {
|
||||
ed.SetStatus("Open failed");
|
||||
}
|
||||
ed.RequestOpenFile(fp.toStdString());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
// Update picker dir for next time
|
||||
QFileInfo info(fp);
|
||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||
|
||||
572
Swap.cc
572
Swap.cc
@@ -22,6 +22,24 @@ constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
|
||||
constexpr std::uint32_t VERSION = 1;
|
||||
|
||||
|
||||
static std::string
|
||||
snapshot_buffer_bytes(const Buffer &b)
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
std::string out;
|
||||
// Cheap lower bound: sum of row sizes.
|
||||
std::size_t approx = 0;
|
||||
for (const auto &r: rows)
|
||||
approx += r.size();
|
||||
out.reserve(approx);
|
||||
for (std::size_t i = 0; i < rows.size(); i++) {
|
||||
auto v = b.GetLineView(i);
|
||||
out.append(v.data(), v.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
static fs::path
|
||||
xdg_state_home()
|
||||
{
|
||||
@@ -38,6 +56,13 @@ xdg_state_home()
|
||||
}
|
||||
|
||||
|
||||
static fs::path
|
||||
swap_root_dir()
|
||||
{
|
||||
return xdg_state_home() / "kte" / "swap";
|
||||
}
|
||||
|
||||
|
||||
static std::uint64_t
|
||||
fnv1a64(std::string_view s)
|
||||
{
|
||||
@@ -82,6 +107,64 @@ write_full(int fd, const void *buf, size_t len)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
encode_path_key(std::string s)
|
||||
{
|
||||
// Turn an absolute path like "/home/kyle/tmp/test.txt" into
|
||||
// "home!kyle!tmp!test.txt" so swap files are human-identifiable.
|
||||
//
|
||||
// Notes:
|
||||
// - We strip a single leading path separator so absolute paths don't start with '!'.
|
||||
// - We replace both '/' and '\\' with '!'.
|
||||
// - We leave other characters as-is (spaces are OK on POSIX).
|
||||
if (!s.empty() && (s[0] == '/' || s[0] == '\\'))
|
||||
s.erase(0, 1);
|
||||
for (char &ch: s) {
|
||||
if (ch == '/' || ch == '\\')
|
||||
ch = '!';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
static std::string
|
||||
compute_swap_path_for_filename(const std::string &filename)
|
||||
{
|
||||
if (filename.empty())
|
||||
return std::string();
|
||||
// Always place swap under an XDG home-appropriate state directory.
|
||||
// This avoids cluttering working directories and prevents stomping on
|
||||
// swap files when multiple different paths share the same basename.
|
||||
fs::path root = swap_root_dir();
|
||||
|
||||
fs::path p(filename);
|
||||
std::string key;
|
||||
try {
|
||||
key = fs::weakly_canonical(p).string();
|
||||
} catch (...) {
|
||||
try {
|
||||
key = fs::absolute(p).string();
|
||||
} catch (...) {
|
||||
key = filename;
|
||||
}
|
||||
}
|
||||
std::string encoded = encode_path_key(key);
|
||||
if (!encoded.empty()) {
|
||||
std::string name = encoded + ".swp";
|
||||
// Avoid filesystem/path length issues; fall back to hashed naming.
|
||||
// NAME_MAX is often 255 on POSIX, but keep extra headroom.
|
||||
if (name.size() <= 200) {
|
||||
return (root / name).string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stable, shorter name based on basename + hash.
|
||||
std::string base = p.filename().string();
|
||||
const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp";
|
||||
return (root / name).string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +174,11 @@ SwapManager::SwapManager()
|
||||
worker_ = std::thread([this] {
|
||||
this->writer_loop();
|
||||
});
|
||||
// Best-effort prune of old swap files.
|
||||
// Safe early in startup: journals_ is still empty and no fds are open yet.
|
||||
if (cfg_.prune_on_startup) {
|
||||
PruneSwapDir();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +197,29 @@ SwapManager::~SwapManager()
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Checkpoint(Buffer *buf)
|
||||
{
|
||||
if (buf) {
|
||||
RecordCheckpoint(*buf, false);
|
||||
return;
|
||||
}
|
||||
// All buffers
|
||||
std::vector<Buffer *> bufs;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
bufs.reserve(journals_.size());
|
||||
for (auto &kv: journals_) {
|
||||
bufs.push_back(kv.first);
|
||||
}
|
||||
}
|
||||
for (Buffer *b: bufs) {
|
||||
if (b)
|
||||
RecordCheckpoint(*b, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Flush(Buffer *buf)
|
||||
{
|
||||
@@ -171,10 +282,14 @@ SwapManager::Attach(Buffer *buf)
|
||||
|
||||
|
||||
void
|
||||
SwapManager::Detach(Buffer *buf)
|
||||
SwapManager::Detach(Buffer *buf, const bool remove_file)
|
||||
{
|
||||
if (!buf)
|
||||
return;
|
||||
// Write a best-effort final checkpoint before suspending and closing.
|
||||
// If the caller requested removal, skip the final checkpoint so the file can be deleted.
|
||||
if (!remove_file)
|
||||
RecordCheckpoint(*buf, true);
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
@@ -183,24 +298,162 @@ SwapManager::Detach(Buffer *buf)
|
||||
}
|
||||
}
|
||||
Flush(buf);
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(buf);
|
||||
if (it != journals_.end()) {
|
||||
path = it->second.path;
|
||||
close_ctx(it->second);
|
||||
journals_.erase(it);
|
||||
}
|
||||
recorders_.erase(buf);
|
||||
}
|
||||
if (remove_file && !path.empty()) {
|
||||
(void) std::remove(path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::ResetJournal(Buffer &buf)
|
||||
{
|
||||
std::string path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
if (ctx.path.empty())
|
||||
ctx.path = ComputeSidecarPath(buf);
|
||||
path = ctx.path;
|
||||
ctx.suspended = true;
|
||||
}
|
||||
|
||||
Flush(&buf);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
close_ctx(ctx);
|
||||
ctx.header_ok = false;
|
||||
ctx.last_flush_ns = 0;
|
||||
ctx.last_fsync_ns = 0;
|
||||
ctx.last_chkpt_ns = 0;
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.approx_size_bytes = 0;
|
||||
ctx.suspended = false;
|
||||
}
|
||||
|
||||
if (!path.empty()) {
|
||||
(void) std::remove(path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::SwapDirRoot()
|
||||
{
|
||||
return swap_root_dir().string();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::PruneSwapDir()
|
||||
{
|
||||
SwapConfig cfg;
|
||||
std::vector<std::string> active;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg = cfg_;
|
||||
active.reserve(journals_.size());
|
||||
for (const auto &kv: journals_) {
|
||||
if (!kv.second.path.empty())
|
||||
active.push_back(kv.second.path);
|
||||
}
|
||||
}
|
||||
|
||||
const fs::path root = swap_root_dir();
|
||||
std::error_code ec;
|
||||
if (!fs::exists(root, ec) || ec)
|
||||
return;
|
||||
|
||||
struct Entry {
|
||||
fs::path path;
|
||||
std::filesystem::file_time_type mtime;
|
||||
};
|
||||
std::vector<Entry> swps;
|
||||
for (auto it = fs::directory_iterator(root, ec); !ec && it != fs::directory_iterator(); it.increment(ec)) {
|
||||
const fs::path p = it->path();
|
||||
if (p.extension() != ".swp")
|
||||
continue;
|
||||
// Never delete active journals.
|
||||
const std::string ps = p.string();
|
||||
bool is_active = false;
|
||||
for (const auto &a: active) {
|
||||
if (a == ps) {
|
||||
is_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_active)
|
||||
continue;
|
||||
std::error_code ec2;
|
||||
if (!it->is_regular_file(ec2) || ec2)
|
||||
continue;
|
||||
auto tm = fs::last_write_time(p, ec2);
|
||||
if (ec2)
|
||||
continue;
|
||||
swps.push_back({p, tm});
|
||||
}
|
||||
|
||||
if (swps.empty())
|
||||
return;
|
||||
|
||||
// Sort newest first.
|
||||
std::sort(swps.begin(), swps.end(), [](const Entry &a, const Entry &b) {
|
||||
return a.mtime > b.mtime;
|
||||
});
|
||||
|
||||
// Convert age threshold.
|
||||
auto now = std::filesystem::file_time_type::clock::now();
|
||||
auto max_age = std::chrono::hours(24) * static_cast<long long>(cfg.prune_max_age_days);
|
||||
|
||||
std::size_t kept = 0;
|
||||
for (const auto &e: swps) {
|
||||
bool too_old = false;
|
||||
if (cfg.prune_max_age_days > 0) {
|
||||
// If file_time_type isn't system_clock, duration arithmetic still works.
|
||||
if (now - e.mtime > max_age)
|
||||
too_old = true;
|
||||
}
|
||||
bool over_limit = (cfg.prune_max_files > 0) && (kept >= cfg.prune_max_files);
|
||||
if (too_old || over_limit) {
|
||||
std::error_code ec3;
|
||||
fs::remove(e.path, ec3);
|
||||
} else {
|
||||
++kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
{
|
||||
// Best-effort: checkpoint the old journal before switching paths.
|
||||
RecordCheckpoint(buf, true);
|
||||
std::string old_path;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
old_path = it->second.path;
|
||||
it->second.suspended = true;
|
||||
}
|
||||
Flush(&buf);
|
||||
@@ -210,8 +463,16 @@ SwapManager::NotifyFilenameChanged(Buffer &buf)
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
close_ctx(ctx);
|
||||
if (!old_path.empty())
|
||||
(void) std::remove(old_path.c_str());
|
||||
ctx.path = ComputeSidecarPath(buf);
|
||||
ctx.suspended = false;
|
||||
ctx.header_ok = false;
|
||||
ctx.last_flush_ns = 0;
|
||||
ctx.last_fsync_ns = 0;
|
||||
ctx.last_chkpt_ns = 0;
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.approx_size_bytes = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -257,54 +518,9 @@ SwapManager::SuspendGuard::~SuspendGuard()
|
||||
std::string
|
||||
SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
{
|
||||
// Always place swap under an XDG home-appropriate state directory.
|
||||
// This avoids cluttering working directories and prevents stomping on
|
||||
// swap files when multiple different paths share the same basename.
|
||||
fs::path root = xdg_state_home() / "kte" / "swap";
|
||||
|
||||
auto encode_path = [](std::string s) -> std::string {
|
||||
// Turn an absolute path like "/home/kyle/tmp/test.txt" into
|
||||
// "home!kyle!tmp!test.txt" so swap files are human-identifiable.
|
||||
//
|
||||
// Notes:
|
||||
// - We strip a single leading path separator so absolute paths don't start with '!'.
|
||||
// - We replace both '/' and '\\' with '!'.
|
||||
// - We leave other characters as-is (spaces are OK on POSIX).
|
||||
if (!s.empty() && (s[0] == '/' || s[0] == '\\'))
|
||||
s.erase(0, 1);
|
||||
for (char &ch: s) {
|
||||
if (ch == '/' || ch == '\\')
|
||||
ch = '!';
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
fs::path root = swap_root_dir();
|
||||
if (!buf.Filename().empty()) {
|
||||
fs::path p(buf.Filename());
|
||||
std::string key;
|
||||
try {
|
||||
key = fs::weakly_canonical(p).string();
|
||||
} catch (...) {
|
||||
try {
|
||||
key = fs::absolute(p).string();
|
||||
} catch (...) {
|
||||
key = buf.Filename();
|
||||
}
|
||||
}
|
||||
std::string encoded = encode_path(key);
|
||||
if (!encoded.empty()) {
|
||||
std::string name = encoded + ".swp";
|
||||
// Avoid filesystem/path length issues; fall back to hashed naming.
|
||||
// NAME_MAX is often 255 on POSIX, but keep extra headroom.
|
||||
if (name.size() <= 200) {
|
||||
return (root / name).string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stable, shorter name based on basename + hash.
|
||||
std::string base = p.filename().string();
|
||||
const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp";
|
||||
return (root / name).string();
|
||||
return compute_swap_path_for_filename(buf.Filename());
|
||||
}
|
||||
|
||||
// Unnamed buffers: unique within the process.
|
||||
@@ -316,6 +532,20 @@ SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::ComputeSwapPathForFilename(const std::string &filename)
|
||||
{
|
||||
return ComputeSidecarPathForFilename(filename);
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
SwapManager::ComputeSidecarPathForFilename(const std::string &filename)
|
||||
{
|
||||
return compute_swap_path_for_filename(filename);
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
SwapManager::now_ns()
|
||||
{
|
||||
@@ -403,8 +633,10 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
||||
ctx.path = path;
|
||||
if (st.st_size == 0) {
|
||||
ctx.header_ok = write_header(fd);
|
||||
ctx.approx_size_bytes = ctx.header_ok ? 64 : 0;
|
||||
} else {
|
||||
ctx.header_ok = true; // stage 1: trust existing header
|
||||
ctx.approx_size_bytes = static_cast<std::uint64_t>(st.st_size);
|
||||
}
|
||||
return ctx.header_ok;
|
||||
}
|
||||
@@ -422,6 +654,79 @@ SwapManager::close_ctx(JournalCtx &ctx)
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record)
|
||||
{
|
||||
if (ctx.path.empty())
|
||||
return false;
|
||||
if (chkpt_record.empty())
|
||||
return false;
|
||||
|
||||
// Close existing file before rename.
|
||||
if (ctx.fd >= 0) {
|
||||
(void) ::fsync(ctx.fd);
|
||||
::close(ctx.fd);
|
||||
ctx.fd = -1;
|
||||
}
|
||||
ctx.header_ok = false;
|
||||
|
||||
const std::string tmp_path = ctx.path + ".tmp";
|
||||
// Create the compacted file: header + checkpoint record.
|
||||
if (!ensure_parent_dir(tmp_path))
|
||||
return false;
|
||||
|
||||
int flags = O_CREAT | O_WRONLY | O_TRUNC;
|
||||
#ifdef O_CLOEXEC
|
||||
flags |= O_CLOEXEC;
|
||||
#endif
|
||||
int tfd = ::open(tmp_path.c_str(), flags, 0600);
|
||||
if (tfd < 0)
|
||||
return false;
|
||||
(void) ::fchmod(tfd, 0600);
|
||||
bool ok = write_header(tfd);
|
||||
if (ok)
|
||||
ok = write_full(tfd, chkpt_record.data(), chkpt_record.size());
|
||||
if (ok)
|
||||
ok = (::fsync(tfd) == 0);
|
||||
::close(tfd);
|
||||
if (!ok) {
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic replace.
|
||||
if (::rename(tmp_path.c_str(), ctx.path.c_str()) != 0) {
|
||||
std::remove(tmp_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort: fsync parent dir to persist the rename.
|
||||
try {
|
||||
fs::path p(ctx.path);
|
||||
fs::path dir = p.parent_path();
|
||||
if (!dir.empty()) {
|
||||
int dflags = O_RDONLY;
|
||||
#ifdef O_DIRECTORY
|
||||
dflags |= O_DIRECTORY;
|
||||
#endif
|
||||
int dfd = ::open(dir.string().c_str(), dflags);
|
||||
if (dfd >= 0) {
|
||||
(void) ::fsync(dfd);
|
||||
::close(dfd);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Re-open for further appends.
|
||||
if (!open_ctx(ctx, ctx.path))
|
||||
return false;
|
||||
ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
std::uint32_t
|
||||
SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed)
|
||||
{
|
||||
@@ -510,6 +815,7 @@ SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text)
|
||||
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
|
||||
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, text.size());
|
||||
}
|
||||
|
||||
|
||||
@@ -533,6 +839,7 @@ SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len)
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(len));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, len);
|
||||
}
|
||||
|
||||
|
||||
@@ -553,6 +860,7 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col)
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +880,68 @@ SwapManager::RecordJoin(Buffer &buf, int row)
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, row)));
|
||||
enqueue(std::move(p));
|
||||
maybe_request_checkpoint(buf, 1);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::maybe_request_checkpoint(Buffer &buf, const std::size_t approx_edit_bytes)
|
||||
{
|
||||
SwapConfig cfg;
|
||||
bool do_chkpt = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg = cfg_;
|
||||
if (cfg.checkpoint_bytes == 0 && cfg.checkpoint_interval_ms == 0)
|
||||
return;
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
JournalCtx &ctx = it->second;
|
||||
ctx.edit_bytes_since_chkpt += approx_edit_bytes;
|
||||
const std::uint64_t now = now_ns();
|
||||
if (ctx.last_chkpt_ns == 0)
|
||||
ctx.last_chkpt_ns = now;
|
||||
const bool bytes_hit = (cfg.checkpoint_bytes > 0) && (
|
||||
ctx.edit_bytes_since_chkpt >= cfg.checkpoint_bytes);
|
||||
const bool time_hit = (cfg.checkpoint_interval_ms > 0) &&
|
||||
(((now - ctx.last_chkpt_ns) / 1000000ULL) >= cfg.checkpoint_interval_ms);
|
||||
if (bytes_hit || time_hit) {
|
||||
ctx.edit_bytes_since_chkpt = 0;
|
||||
ctx.last_chkpt_ns = now;
|
||||
do_chkpt = true;
|
||||
}
|
||||
}
|
||||
if (do_chkpt) {
|
||||
RecordCheckpoint(buf, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
SwapManager::RecordCheckpoint(Buffer &buf, const bool urgent_flush)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(&buf);
|
||||
if (it == journals_.end() || it->second.suspended)
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string bytes = snapshot_buffer_bytes(buf);
|
||||
if (bytes.size() > 0xFFFFFFFFu)
|
||||
return;
|
||||
|
||||
Pending p;
|
||||
p.buf = &buf;
|
||||
p.type = SwapRecType::CHKPT;
|
||||
p.urgent_flush = urgent_flush;
|
||||
// payload v1: [encver u8=1][nbytes u32][bytes]
|
||||
p.payload.push_back(1);
|
||||
put_le32(p.payload, static_cast<std::uint32_t>(bytes.size()));
|
||||
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(bytes.data()),
|
||||
reinterpret_cast<const std::uint8_t *>(bytes.data()) + bytes.size());
|
||||
enqueue(std::move(p));
|
||||
}
|
||||
|
||||
|
||||
@@ -641,17 +1011,17 @@ SwapManager::process_one(const Pending &p)
|
||||
|
||||
JournalCtx *ctxp = nullptr;
|
||||
std::string path;
|
||||
std::size_t compact_bytes = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
auto it = journals_.find(p.buf);
|
||||
if (it == journals_.end())
|
||||
return;
|
||||
if (it->second.suspended)
|
||||
return;
|
||||
if (it->second.path.empty())
|
||||
it->second.path = ComputeSidecarPath(buf);
|
||||
path = it->second.path;
|
||||
ctxp = &it->second;
|
||||
compact_bytes = cfg_.compact_bytes;
|
||||
}
|
||||
if (!ctxp)
|
||||
return;
|
||||
@@ -680,13 +1050,27 @@ SwapManager::process_one(const Pending &p)
|
||||
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
|
||||
crcbytes[3] = static_cast<std::uint8_t>((c >> 24) & 0xFFu);
|
||||
|
||||
std::vector<std::uint8_t> rec;
|
||||
rec.reserve(sizeof(head) + p.payload.size() + sizeof(crcbytes));
|
||||
rec.insert(rec.end(), head, head + sizeof(head));
|
||||
if (!p.payload.empty())
|
||||
rec.insert(rec.end(), p.payload.begin(), p.payload.end());
|
||||
rec.insert(rec.end(), crcbytes, crcbytes + sizeof(crcbytes));
|
||||
|
||||
// Write (handle partial writes and check results)
|
||||
bool ok = write_full(ctxp->fd, head, sizeof(head));
|
||||
if (ok && !p.payload.empty())
|
||||
ok = write_full(ctxp->fd, p.payload.data(), p.payload.size());
|
||||
if (ok)
|
||||
ok = write_full(ctxp->fd, crcbytes, sizeof(crcbytes));
|
||||
(void) ok; // stage 1: best-effort; future work could mark ctx error state
|
||||
bool ok = write_full(ctxp->fd, rec.data(), rec.size());
|
||||
if (ok) {
|
||||
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size());
|
||||
if (p.urgent_flush) {
|
||||
(void) ::fsync(ctxp->fd);
|
||||
ctxp->last_fsync_ns = now_ns();
|
||||
}
|
||||
if (p.type == SwapRecType::CHKPT && compact_bytes > 0 &&
|
||||
ctxp->approx_size_bytes >= static_cast<std::uint64_t>(compact_bytes)) {
|
||||
(void) compact_to_checkpoint(*ctxp, rec);
|
||||
}
|
||||
}
|
||||
(void) ok; // best-effort; future work could mark ctx error state
|
||||
}
|
||||
|
||||
|
||||
@@ -743,6 +1127,20 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure replayed edits don't get re-journaled if the caller forgot to detach/suspend.
|
||||
kte::SwapRecorder *prev_rec = buf.SwapRecorder();
|
||||
buf.SetSwapRecorder(nullptr);
|
||||
struct RestoreSwapRecorder {
|
||||
Buffer &b;
|
||||
kte::SwapRecorder *prev;
|
||||
|
||||
|
||||
~RestoreSwapRecorder()
|
||||
{
|
||||
b.SetSwapRecorder(prev);
|
||||
}
|
||||
} restore{buf, prev_rec};
|
||||
|
||||
for (;;) {
|
||||
std::uint8_t head[4];
|
||||
in.read(reinterpret_cast<char *>(head), sizeof(head));
|
||||
@@ -780,9 +1178,11 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
}
|
||||
|
||||
// Apply record
|
||||
switch (type) {
|
||||
case SwapRecType::INS: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing payload";
|
||||
err = "Swap record missing INS payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
@@ -790,8 +1190,6 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case SwapRecType::INS: {
|
||||
std::uint32_t row = 0, col = 0, nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, nbytes)) {
|
||||
@@ -807,6 +1205,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::DEL: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing DEL payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t row = 0, col = 0, dlen = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||
payload, off, dlen)) {
|
||||
@@ -817,6 +1225,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::SPLIT: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing SPLIT payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t row = 0, col = 0;
|
||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
||||
err = "Malformed SPLIT payload";
|
||||
@@ -826,6 +1244,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
break;
|
||||
}
|
||||
case SwapRecType::JOIN: {
|
||||
std::size_t off = 0;
|
||||
if (payload.empty()) {
|
||||
err = "Swap record missing JOIN payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap payload encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t row = 0;
|
||||
if (!parse_u32_le(payload, off, row)) {
|
||||
err = "Malformed JOIN payload";
|
||||
@@ -834,8 +1262,32 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
||||
buf.join_lines((int) row);
|
||||
break;
|
||||
}
|
||||
case SwapRecType::CHKPT: {
|
||||
std::size_t off = 0;
|
||||
if (payload.size() < 5) {
|
||||
err = "Malformed CHKPT payload";
|
||||
return false;
|
||||
}
|
||||
const std::uint8_t encver = payload[off++];
|
||||
if (encver != 1) {
|
||||
err = "Unsupported swap checkpoint encoding";
|
||||
return false;
|
||||
}
|
||||
std::uint32_t nbytes = 0;
|
||||
if (!parse_u32_le(payload, off, nbytes)) {
|
||||
err = "Malformed CHKPT payload";
|
||||
return false;
|
||||
}
|
||||
if (off + nbytes > payload.size()) {
|
||||
err = "Truncated CHKPT payload bytes";
|
||||
return false;
|
||||
}
|
||||
buf.replace_all_bytes(std::string_view(reinterpret_cast<const char *>(payload.data() + off),
|
||||
(std::size_t) nbytes));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Ignore unknown types for forward-compat in stage 1
|
||||
// Ignore unknown types for forward-compat
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
54
Swap.h
54
Swap.h
@@ -32,6 +32,18 @@ struct SwapConfig {
|
||||
// Grouping and durability knobs (stage 1 defaults)
|
||||
unsigned flush_interval_ms{200}; // group small writes
|
||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||
|
||||
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||
|
||||
// Cleanup / retention (best-effort)
|
||||
bool prune_on_startup{true};
|
||||
unsigned prune_max_age_days{30};
|
||||
std::size_t prune_max_files{2048};
|
||||
};
|
||||
|
||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||
@@ -45,13 +57,36 @@ public:
|
||||
void Attach(Buffer *buf);
|
||||
|
||||
// Detach and close journal.
|
||||
void Detach(Buffer *buf);
|
||||
// If remove_file is true, the swap file is deleted after closing.
|
||||
// Intended for clean shutdown/close flows.
|
||||
void Detach(Buffer *buf, bool remove_file = false);
|
||||
|
||||
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||
void ResetJournal(Buffer &buf);
|
||||
|
||||
// Best-effort pruning of old swap files under the swap directory.
|
||||
// Never touches non-`.swp` files.
|
||||
void PruneSwapDir();
|
||||
|
||||
// Block until all currently queued records have been written.
|
||||
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
||||
// for tests and shutdown.
|
||||
void Flush(Buffer *buf = nullptr);
|
||||
|
||||
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||
void Checkpoint(Buffer *buf = nullptr);
|
||||
|
||||
|
||||
void SetConfig(const SwapConfig &cfg)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
cfg_ = cfg;
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
|
||||
// Obtain a per-buffer recorder adapter that emits records for that buffer.
|
||||
// The returned pointer is owned by the SwapManager and remains valid until
|
||||
// Detach(buf) or SwapManager destruction.
|
||||
@@ -67,6 +102,10 @@ public:
|
||||
// treat this as a recovery failure and surface `err`.
|
||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
||||
|
||||
// Compute the swap path for a file-backed buffer by filename.
|
||||
// Returns empty string if filename is empty.
|
||||
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||
|
||||
// Test-only hook to keep swap path logic centralized.
|
||||
// (Avoid duplicating naming rules in unit tests.)
|
||||
#ifdef KTE_TESTS
|
||||
@@ -114,6 +153,10 @@ private:
|
||||
|
||||
void RecordJoin(Buffer &buf, int row);
|
||||
|
||||
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||
|
||||
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||
|
||||
struct JournalCtx {
|
||||
std::string path;
|
||||
int fd{-1};
|
||||
@@ -121,6 +164,9 @@ private:
|
||||
bool suspended{false};
|
||||
std::uint64_t last_flush_ns{0};
|
||||
std::uint64_t last_fsync_ns{0};
|
||||
std::uint64_t last_chkpt_ns{0};
|
||||
std::uint64_t edit_bytes_since_chkpt{0};
|
||||
std::uint64_t approx_size_bytes{0};
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
@@ -134,16 +180,22 @@ private:
|
||||
// Helpers
|
||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||
|
||||
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
static bool ensure_parent_dir(const std::string &path);
|
||||
|
||||
static std::string SwapDirRoot();
|
||||
|
||||
static bool write_header(int fd);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||
|
||||
@@ -94,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
||||
}
|
||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
@@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
void
|
||||
TestFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
237
docs/swap.md
Normal file
237
docs/swap.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Swap journaling (crash recovery)
|
||||
|
||||
kte has a small “swap” system: an append-only per-buffer journal that
|
||||
records edits so they can be replayed after a crash.
|
||||
|
||||
This document describes the **currently implemented** swap system (stage
|
||||
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||
|
||||
## What it is (and what it is not)
|
||||
|
||||
- The swap file is a **journal of editing operations** (currently
|
||||
inserts, deletes, and periodic full-buffer checkpoints).
|
||||
- It is written by a **single background writer thread** owned by
|
||||
`kte::SwapManager`.
|
||||
- It is intended for **best-effort crash recovery**.
|
||||
|
||||
kte automatically deletes/resets swap journals after a **clean save**
|
||||
and when
|
||||
closing a clean buffer, so old swap files do not accumulate under normal
|
||||
workflows. A best-effort prune also runs at startup to remove very old
|
||||
swap
|
||||
files.
|
||||
|
||||
## Automatic recovery prompt
|
||||
|
||||
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||
swap journal exists.
|
||||
|
||||
- If a swap file exists and replay succeeds *and* produces different
|
||||
content than what is currently on disk, kte prompts:
|
||||
|
||||
```text
|
||||
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||
best-effort)
|
||||
and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||
|
||||
```text
|
||||
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||
```
|
||||
|
||||
- `y`: delete the swap file (best-effort) and open the file normally
|
||||
- `Enter` (default): keep the swap file and open the file normally
|
||||
- `C-g`: cancel opening the file
|
||||
|
||||
## Where swap files live
|
||||
|
||||
Swap files are stored under an XDG-style per-user *state* directory:
|
||||
|
||||
- If `XDG_STATE_HOME` is set and non-empty:
|
||||
- `$XDG_STATE_HOME/kte/swap/…`
|
||||
- Otherwise, if `HOME` is set:
|
||||
- `~/.local/state/kte/swap/…`
|
||||
- Last resort fallback:
|
||||
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||
`std::filesystem::temp_directory_path()`)
|
||||
|
||||
Swap files are always created with permissions `0600`.
|
||||
|
||||
### Swap file naming
|
||||
|
||||
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||
path:
|
||||
|
||||
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||
else `absolute`, else the raw `Buffer::Filename()`).
|
||||
2. Encode it so it’s human-identifiable:
|
||||
- Strip one leading path separator (`/` or `\\`)
|
||||
- Replace path separators (`/` and `\\`) with `!`
|
||||
- Append `.swp`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||
```
|
||||
|
||||
If the resulting name would be long (over ~200 characters), kte falls
|
||||
back to a shorter stable name:
|
||||
|
||||
```text
|
||||
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||
```
|
||||
|
||||
For unnamed/unsaved buffers, kte uses:
|
||||
|
||||
```text
|
||||
unnamed-<pid>-<counter>.swp
|
||||
```
|
||||
|
||||
## Lifecycle (when swap is written)
|
||||
|
||||
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||
attached for journaling when they are added/opened.
|
||||
|
||||
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||
establishes its swap path.
|
||||
- `Buffer` emits swap events from its low-level edit APIs:
|
||||
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||
in stage 1).
|
||||
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||
closes the journal.
|
||||
- On `Save As` / filename changes,
|
||||
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||
journal and switches to a new path.
|
||||
- Note: the old swap file is currently left on disk (no
|
||||
cleanup/rotation yet).
|
||||
|
||||
## Durability and performance
|
||||
|
||||
Swap writing is best-effort and asynchronous:
|
||||
|
||||
- Records are queued from the UI/editing thread(s).
|
||||
- A background writer thread wakes at least every
|
||||
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||
records.
|
||||
- `fsync()` is throttled to at most once per
|
||||
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||
primarily used by tests and shutdown paths.
|
||||
|
||||
If a crash happens while writing, the swap file may end with a partial
|
||||
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||
|
||||
## On-disk format (v1)
|
||||
|
||||
The file is:
|
||||
|
||||
1. A fixed-size 64-byte header
|
||||
2. Followed by a stream of records
|
||||
|
||||
All multi-byte integers in the swap file are **little-endian**.
|
||||
|
||||
### Header (64 bytes)
|
||||
|
||||
Layout (stage 1):
|
||||
|
||||
- `magic` (8 bytes): `KTE_SWP\0`
|
||||
- `version` (`u32`): currently `1`
|
||||
- `flags` (`u32`): currently `0`
|
||||
- `created_time` (`u64`): Unix seconds
|
||||
- remaining bytes are reserved/padding (currently zeroed)
|
||||
|
||||
### Record framing
|
||||
|
||||
Each record is:
|
||||
|
||||
```text
|
||||
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||
```
|
||||
|
||||
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||
followed by the payload bytes.
|
||||
|
||||
### Record types
|
||||
|
||||
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||
emits:
|
||||
|
||||
- `INS` (`1`): insert bytes at `(row, col)`
|
||||
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||
|
||||
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||
interface.
|
||||
|
||||
### Payload encoding (v1)
|
||||
|
||||
Every payload starts with:
|
||||
|
||||
```text
|
||||
[encver: u8]
|
||||
```
|
||||
|
||||
Currently `encver` must be `1`.
|
||||
|
||||
#### `INS` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[nbytes:u32]
|
||||
[bytes: nbytes]
|
||||
```
|
||||
|
||||
#### `DEL` payload (encver = 1)
|
||||
|
||||
```text
|
||||
[encver: u8 = 1]
|
||||
[row: u32]
|
||||
[col: u32]
|
||||
[len: u32]
|
||||
```
|
||||
|
||||
`row`/`col` are 0-based and are interpreted the same way as
|
||||
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||
|
||||
## Replay / recovery
|
||||
|
||||
Swap replay is implemented as a low-level API:
|
||||
|
||||
-
|
||||
|
||||
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||
|
||||
Behavior:
|
||||
|
||||
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||
from the on-disk file) and a swap path.
|
||||
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||
`err`.
|
||||
- On unknown record types, it ignores them (forward compatibility).
|
||||
- On failure, the buffer may have had a prefix of records applied;
|
||||
callers should treat this as “recovery failed”.
|
||||
|
||||
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||
should suspend/disable recording during replay (or detach first),
|
||||
otherwise replayed edits would be re-journaled.
|
||||
|
||||
## Tests
|
||||
|
||||
Swap behavior and format are validated by unit tests:
|
||||
|
||||
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||
28
main.cc
28
main.cc
@@ -195,6 +195,7 @@ main(int argc, char *argv[])
|
||||
} else if (req_term) {
|
||||
use_gui = false;
|
||||
} else {
|
||||
|
||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||
#if defined(KTE_DEFAULT_GUI)
|
||||
use_gui = true;
|
||||
@@ -207,6 +208,9 @@ main(int argc, char *argv[])
|
||||
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
||||
// If no files are provided, create an empty buffer.
|
||||
if (optind < argc) {
|
||||
// Seed a scratch buffer so the UI has something to show while deferred opens
|
||||
// (and potential swap recovery prompts) are processed.
|
||||
editor.AddBuffer(Buffer());
|
||||
std::size_t pending_line = 0; // 0 = no pending line
|
||||
for (int i = optind; i < argc; ++i) {
|
||||
const char *arg = argv[i];
|
||||
@@ -242,29 +246,9 @@ main(int argc, char *argv[])
|
||||
// Fall through: not a +number, treat as filename starting with '+'
|
||||
}
|
||||
|
||||
std::string err;
|
||||
const std::string path = arg;
|
||||
if (!editor.OpenFile(path, err)) {
|
||||
editor.SetStatus("open: " + err);
|
||||
std::cerr << "kte: " << err << "\n";
|
||||
} else if (pending_line > 0) {
|
||||
// Apply pending +N to the just-opened (current) buffer
|
||||
if (Buffer *b = editor.CurrentBuffer()) {
|
||||
std::size_t nrows = b->Nrows();
|
||||
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
|
||||
// 1-based to 0-based
|
||||
if (nrows > 0) {
|
||||
if (line >= nrows)
|
||||
line = nrows - 1;
|
||||
} else {
|
||||
line = 0;
|
||||
}
|
||||
b->SetCursor(0, line);
|
||||
// Do not force viewport offsets here; the frontend/renderer
|
||||
// will establish dimensions and normalize visibility on first draw.
|
||||
}
|
||||
pending_line = 0; // consumed
|
||||
}
|
||||
editor.RequestOpenFile(path, pending_line);
|
||||
pending_line = 0; // consumed (if set)
|
||||
}
|
||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||
} else {
|
||||
|
||||
131
tests/test_swap_cleanup.cc
Normal file
131
tests/test_swap_cleanup.cc
Normal 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);
|
||||
}
|
||||
280
tests/test_swap_recovery_prompt.cc
Normal file
280
tests/test_swap_recovery_prompt.cc
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -112,3 +138,90 @@ TEST (SwapReplay_TruncatedLog_FailsSafely)
|
||||
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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user