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
|
// Undo system accessors
|
||||||
UndoSystem *
|
UndoSystem *
|
||||||
Buffer::Undo()
|
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().
|
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||||
void insert_text(int row, int col, std::string_view text);
|
void insert_text(int row, int col, std::string_view text);
|
||||||
@@ -508,6 +514,11 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
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)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
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.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# 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_recorder.cc
|
||||||
tests/test_swap_writer.cc
|
tests/test_swap_writer.cc
|
||||||
tests/test_swap_replay.cc
|
tests/test_swap_replay.cc
|
||||||
|
tests/test_swap_recovery_prompt.cc
|
||||||
|
tests/test_swap_cleanup.cc
|
||||||
tests/test_piece_table.cc
|
tests/test_piece_table.cc
|
||||||
tests/test_search.cc
|
tests/test_search.cc
|
||||||
tests/test_search_replace_flow.cc
|
tests/test_search_replace_flow.cc
|
||||||
|
|||||||
49
Command.cc
49
Command.cc
@@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -632,6 +634,8 @@ cmd_save(CommandContext &ctx)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -686,6 +690,10 @@ cmd_save_as(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -789,6 +797,7 @@ cmd_refresh(CommandContext &ctx)
|
|||||||
ctx.editor.SetCloseConfirmPending(false);
|
ctx.editor.SetCloseConfirmPending(false);
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
ctx.editor.SetCloseAfterSave(false);
|
||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
|
ctx.editor.CancelRecoveryPrompt();
|
||||||
ctx.editor.CancelPrompt();
|
ctx.editor.CancelPrompt();
|
||||||
ctx.editor.SetStatus("Canceled");
|
ctx.editor.SetStatus("Canceled");
|
||||||
return true;
|
return true;
|
||||||
@@ -2441,7 +2450,6 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetSearchIndex(-1);
|
ctx.editor.SetSearchIndex(-1);
|
||||||
return true;
|
return true;
|
||||||
} else if (kind == Editor::PromptKind::OpenFile) {
|
} else if (kind == Editor::PromptKind::OpenFile) {
|
||||||
std::string err;
|
|
||||||
// Expand "~" to the user's home directory
|
// Expand "~" to the user's home directory
|
||||||
auto expand_user_path = [](const std::string &in) -> std::string {
|
auto expand_user_path = [](const std::string &in) -> std::string {
|
||||||
if (!in.empty() && in[0] == '~') {
|
if (!in.empty() && in[0] == '~') {
|
||||||
@@ -2458,14 +2466,19 @@ cmd_newline(CommandContext &ctx)
|
|||||||
value = expand_user_path(value);
|
value = expand_user_path(value);
|
||||||
if (value.empty()) {
|
if (value.empty()) {
|
||||||
ctx.editor.SetStatus("Open canceled (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 {
|
} else {
|
||||||
ctx.editor.SetStatus(std::string("Opened ") + value);
|
ctx.editor.RequestOpenFile(value);
|
||||||
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
const bool opened = ctx.editor.ProcessPendingOpens();
|
||||||
cmd_center_on_cursor(ctx);
|
if (ctx.editor.PromptActive()) {
|
||||||
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
// A recovery confirmation prompt was started.
|
||||||
ctx.editor.CancelPrompt();
|
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) {
|
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
||||||
// Resolve to a buffer index by exact match against path or basename;
|
// Resolve to a buffer index by exact match against path or basename;
|
||||||
@@ -2579,6 +2592,10 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
ctx.editor.SetStatus("Saved as " + target);
|
ctx.editor.SetStatus("Saved as " + target);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -2612,6 +2629,16 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
// Regardless of answer, end any close-after-save pending state for safety.
|
// Regardless of answer, end any close-after-save pending state for safety.
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
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) {
|
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
||||||
bool yes = false;
|
bool yes = false;
|
||||||
if (!value.empty()) {
|
if (!value.empty()) {
|
||||||
@@ -2630,6 +2657,8 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
@@ -2639,6 +2668,10 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
|
|||||||
216
Editor.cc
216
Editor.cc
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
@@ -8,6 +9,41 @@
|
|||||||
#include "syntax/NullHighlighter.h"
|
#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()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -177,10 +213,10 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// Setup highlighting using registry (extension + shebang)
|
// Setup highlighting using registry (extension + shebang)
|
||||||
cur.EnsureHighlighter();
|
cur.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
const auto &rows = cur.Rows();
|
const auto &cur_rows = cur.Rows();
|
||||||
if (!rows.empty())
|
if (!cur_rows.empty())
|
||||||
first = static_cast<std::string>(rows[0]);
|
first = static_cast<std::string>(cur_rows[0]);
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
cur.SetFiletype(ft);
|
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
|
bool
|
||||||
Editor::SwitchTo(std::size_t index)
|
Editor::SwitchTo(std::size_t index)
|
||||||
{
|
{
|
||||||
@@ -284,7 +486,9 @@ Editor::CloseBuffer(std::size_t index)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (swap_) {
|
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_[index].SetSwapRecorder(nullptr);
|
||||||
}
|
}
|
||||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||||
|
|||||||
37
Editor.h
37
Editor.h
@@ -4,6 +4,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -497,6 +498,30 @@ public:
|
|||||||
|
|
||||||
bool OpenFile(const std::string &path, std::string &err);
|
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
|
// Buffer switching/closing
|
||||||
bool SwitchTo(std::size_t index);
|
bool SwitchTo(std::size_t index);
|
||||||
|
|
||||||
@@ -550,6 +575,11 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct PendingOpen {
|
||||||
|
std::string path;
|
||||||
|
std::size_t line1{0}; // 1-based; 0 = none
|
||||||
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
int mode_ = 0;
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
@@ -593,6 +623,13 @@ private:
|
|||||||
std::string prompt_text_;
|
std::string prompt_text_;
|
||||||
std::string pending_overwrite_path_;
|
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)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
std::string file_picker_dir_;
|
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
|
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
|
|||||||
@@ -912,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ed.SetFilePickerDir(e.path.string());
|
ed.SetFilePickerDir(e.path.string());
|
||||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
// Open file on single click
|
// Open file on single click
|
||||||
std::string err;
|
ed.RequestOpenFile(e.path.string());
|
||||||
if (!ed.OpenFile(e.path.string(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("open: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
|
||||||
}
|
|
||||||
ed.SetFilePickerVisible(false);
|
ed.SetFilePickerVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,6 +775,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
if (app_)
|
if (app_)
|
||||||
app_->processEvents();
|
app_->processEvents();
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Drain input queue
|
// Drain input queue
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -801,14 +804,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
const QStringList files = dlg.selectedFiles();
|
const QStringList files = dlg.selectedFiles();
|
||||||
if (!files.isEmpty()) {
|
if (!files.isEmpty()) {
|
||||||
const QString fp = files.front();
|
const QString fp = files.front();
|
||||||
std::string err;
|
ed.RequestOpenFile(fp.toStdString());
|
||||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
|
||||||
} else if (!err.empty()) {
|
|
||||||
ed.SetStatus(std::string("Open failed: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus("Open failed");
|
|
||||||
}
|
|
||||||
// Update picker dir for next time
|
// Update picker dir for next time
|
||||||
QFileInfo info(fp);
|
QFileInfo info(fp);
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||||
|
|||||||
610
Swap.cc
610
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;
|
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
|
static fs::path
|
||||||
xdg_state_home()
|
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
|
static std::uint64_t
|
||||||
fnv1a64(std::string_view s)
|
fnv1a64(std::string_view s)
|
||||||
{
|
{
|
||||||
@@ -82,6 +107,64 @@ write_full(int fd, const void *buf, size_t len)
|
|||||||
}
|
}
|
||||||
return true;
|
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] {
|
worker_ = std::thread([this] {
|
||||||
this->writer_loop();
|
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
|
void
|
||||||
SwapManager::Flush(Buffer *buf)
|
SwapManager::Flush(Buffer *buf)
|
||||||
{
|
{
|
||||||
@@ -171,10 +282,14 @@ SwapManager::Attach(Buffer *buf)
|
|||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
SwapManager::Detach(Buffer *buf)
|
SwapManager::Detach(Buffer *buf, const bool remove_file)
|
||||||
{
|
{
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return;
|
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_);
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
auto it = journals_.find(buf);
|
auto it = journals_.find(buf);
|
||||||
@@ -183,24 +298,162 @@ SwapManager::Detach(Buffer *buf)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Flush(buf);
|
Flush(buf);
|
||||||
std::lock_guard<std::mutex> lg(mtx_);
|
std::string path;
|
||||||
auto it = journals_.find(buf);
|
{
|
||||||
if (it != journals_.end()) {
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
close_ctx(it->second);
|
auto it = journals_.find(buf);
|
||||||
journals_.erase(it);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recorders_.erase(buf);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
SwapManager::NotifyFilenameChanged(Buffer &buf)
|
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_);
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
auto it = journals_.find(&buf);
|
auto it = journals_.find(&buf);
|
||||||
if (it == journals_.end())
|
if (it == journals_.end())
|
||||||
return;
|
return;
|
||||||
|
old_path = it->second.path;
|
||||||
it->second.suspended = true;
|
it->second.suspended = true;
|
||||||
}
|
}
|
||||||
Flush(&buf);
|
Flush(&buf);
|
||||||
@@ -210,8 +463,16 @@ SwapManager::NotifyFilenameChanged(Buffer &buf)
|
|||||||
return;
|
return;
|
||||||
JournalCtx &ctx = it->second;
|
JournalCtx &ctx = it->second;
|
||||||
close_ctx(ctx);
|
close_ctx(ctx);
|
||||||
ctx.path = ComputeSidecarPath(buf);
|
if (!old_path.empty())
|
||||||
ctx.suspended = false;
|
(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
|
std::string
|
||||||
SwapManager::ComputeSidecarPath(const Buffer &buf)
|
SwapManager::ComputeSidecarPath(const Buffer &buf)
|
||||||
{
|
{
|
||||||
// Always place swap under an XDG home-appropriate state directory.
|
fs::path root = swap_root_dir();
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!buf.Filename().empty()) {
|
if (!buf.Filename().empty()) {
|
||||||
fs::path p(buf.Filename());
|
return compute_swap_path_for_filename(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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unnamed buffers: unique within the process.
|
// 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
|
std::uint64_t
|
||||||
SwapManager::now_ns()
|
SwapManager::now_ns()
|
||||||
{
|
{
|
||||||
@@ -402,9 +632,11 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
|
|||||||
ctx.fd = fd;
|
ctx.fd = fd;
|
||||||
ctx.path = path;
|
ctx.path = path;
|
||||||
if (st.st_size == 0) {
|
if (st.st_size == 0) {
|
||||||
ctx.header_ok = write_header(fd);
|
ctx.header_ok = write_header(fd);
|
||||||
|
ctx.approx_size_bytes = ctx.header_ok ? 64 : 0;
|
||||||
} else {
|
} else {
|
||||||
ctx.header_ok = true; // stage 1: trust existing header
|
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;
|
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
|
std::uint32_t
|
||||||
SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed)
|
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()),
|
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
|
||||||
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
|
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
|
||||||
enqueue(std::move(p));
|
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>(std::max(0, col)));
|
||||||
put_le32(p.payload, static_cast<std::uint32_t>(len));
|
put_le32(p.payload, static_cast<std::uint32_t>(len));
|
||||||
enqueue(std::move(p));
|
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, row)));
|
||||||
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
put_le32(p.payload, static_cast<std::uint32_t>(std::max(0, col)));
|
||||||
enqueue(std::move(p));
|
enqueue(std::move(p));
|
||||||
|
maybe_request_checkpoint(buf, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -572,6 +880,68 @@ SwapManager::RecordJoin(Buffer &buf, int row)
|
|||||||
p.payload.push_back(1);
|
p.payload.push_back(1);
|
||||||
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, row)));
|
||||||
enqueue(std::move(p));
|
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;
|
JournalCtx *ctxp = nullptr;
|
||||||
std::string path;
|
std::string path;
|
||||||
|
std::size_t compact_bytes = 0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lg(mtx_);
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
auto it = journals_.find(p.buf);
|
auto it = journals_.find(p.buf);
|
||||||
if (it == journals_.end())
|
if (it == journals_.end())
|
||||||
return;
|
return;
|
||||||
if (it->second.suspended)
|
|
||||||
return;
|
|
||||||
if (it->second.path.empty())
|
if (it->second.path.empty())
|
||||||
it->second.path = ComputeSidecarPath(buf);
|
it->second.path = ComputeSidecarPath(buf);
|
||||||
path = it->second.path;
|
path = it->second.path;
|
||||||
ctxp = &it->second;
|
ctxp = &it->second;
|
||||||
|
compact_bytes = cfg_.compact_bytes;
|
||||||
}
|
}
|
||||||
if (!ctxp)
|
if (!ctxp)
|
||||||
return;
|
return;
|
||||||
@@ -680,13 +1050,27 @@ SwapManager::process_one(const Pending &p)
|
|||||||
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
|
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
|
||||||
crcbytes[3] = static_cast<std::uint8_t>((c >> 24) & 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)
|
// Write (handle partial writes and check results)
|
||||||
bool ok = write_full(ctxp->fd, head, sizeof(head));
|
bool ok = write_full(ctxp->fd, rec.data(), rec.size());
|
||||||
if (ok && !p.payload.empty())
|
if (ok) {
|
||||||
ok = write_full(ctxp->fd, p.payload.data(), p.payload.size());
|
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size());
|
||||||
if (ok)
|
if (p.urgent_flush) {
|
||||||
ok = write_full(ctxp->fd, crcbytes, sizeof(crcbytes));
|
(void) ::fsync(ctxp->fd);
|
||||||
(void) ok; // stage 1: best-effort; future work could mark ctx error state
|
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;
|
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 (;;) {
|
for (;;) {
|
||||||
std::uint8_t head[4];
|
std::uint8_t head[4];
|
||||||
in.read(reinterpret_cast<char *>(head), sizeof(head));
|
in.read(reinterpret_cast<char *>(head), sizeof(head));
|
||||||
@@ -780,18 +1178,18 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply record
|
// Apply record
|
||||||
std::size_t off = 0;
|
|
||||||
if (payload.empty()) {
|
|
||||||
err = "Swap record missing payload";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const std::uint8_t encver = payload[off++];
|
|
||||||
if (encver != 1) {
|
|
||||||
err = "Unsupported swap payload encoding";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SwapRecType::INS: {
|
case SwapRecType::INS: {
|
||||||
|
std::size_t off = 0;
|
||||||
|
if (payload.empty()) {
|
||||||
|
err = "Swap record missing INS 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, nbytes = 0;
|
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(
|
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||||
payload, off, nbytes)) {
|
payload, off, nbytes)) {
|
||||||
@@ -807,6 +1205,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SwapRecType::DEL: {
|
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;
|
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(
|
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
|
||||||
payload, off, dlen)) {
|
payload, off, dlen)) {
|
||||||
@@ -817,6 +1225,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SwapRecType::SPLIT: {
|
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;
|
std::uint32_t row = 0, col = 0;
|
||||||
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
|
||||||
err = "Malformed SPLIT payload";
|
err = "Malformed SPLIT payload";
|
||||||
@@ -826,6 +1244,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SwapRecType::JOIN: {
|
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;
|
std::uint32_t row = 0;
|
||||||
if (!parse_u32_le(payload, off, row)) {
|
if (!parse_u32_le(payload, off, row)) {
|
||||||
err = "Malformed JOIN payload";
|
err = "Malformed JOIN payload";
|
||||||
@@ -834,8 +1262,32 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
|
|||||||
buf.join_lines((int) row);
|
buf.join_lines((int) row);
|
||||||
break;
|
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:
|
default:
|
||||||
// Ignore unknown types for forward-compat in stage 1
|
// Ignore unknown types for forward-compat
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
Swap.h
54
Swap.h
@@ -32,6 +32,18 @@ struct SwapConfig {
|
|||||||
// Grouping and durability knobs (stage 1 defaults)
|
// Grouping and durability knobs (stage 1 defaults)
|
||||||
unsigned flush_interval_ms{200}; // group small writes
|
unsigned flush_interval_ms{200}; // group small writes
|
||||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
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.
|
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||||
@@ -45,13 +57,36 @@ public:
|
|||||||
void Attach(Buffer *buf);
|
void Attach(Buffer *buf);
|
||||||
|
|
||||||
// Detach and close journal.
|
// 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.
|
// Block until all currently queued records have been written.
|
||||||
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
||||||
// for tests and shutdown.
|
// for tests and shutdown.
|
||||||
void Flush(Buffer *buf = nullptr);
|
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.
|
// Obtain a per-buffer recorder adapter that emits records for that buffer.
|
||||||
// The returned pointer is owned by the SwapManager and remains valid until
|
// The returned pointer is owned by the SwapManager and remains valid until
|
||||||
// Detach(buf) or SwapManager destruction.
|
// Detach(buf) or SwapManager destruction.
|
||||||
@@ -67,6 +102,10 @@ public:
|
|||||||
// treat this as a recovery failure and surface `err`.
|
// treat this as a recovery failure and surface `err`.
|
||||||
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &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.
|
// Test-only hook to keep swap path logic centralized.
|
||||||
// (Avoid duplicating naming rules in unit tests.)
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
#ifdef KTE_TESTS
|
#ifdef KTE_TESTS
|
||||||
@@ -114,6 +153,10 @@ private:
|
|||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row);
|
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 {
|
struct JournalCtx {
|
||||||
std::string path;
|
std::string path;
|
||||||
int fd{-1};
|
int fd{-1};
|
||||||
@@ -121,6 +164,9 @@ private:
|
|||||||
bool suspended{false};
|
bool suspended{false};
|
||||||
std::uint64_t last_flush_ns{0};
|
std::uint64_t last_flush_ns{0};
|
||||||
std::uint64_t last_fsync_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 {
|
struct Pending {
|
||||||
@@ -134,16 +180,22 @@ private:
|
|||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
|
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
static std::uint64_t now_ns();
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
static bool ensure_parent_dir(const std::string &path);
|
static bool ensure_parent_dir(const std::string &path);
|
||||||
|
|
||||||
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool write_header(int fd);
|
static bool write_header(int fd);
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
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 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);
|
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));
|
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;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
void
|
void
|
||||||
TestFrontend::Step(Editor &ed, bool &running)
|
TestFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
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)
|
||||||
36
main.cc
36
main.cc
@@ -195,11 +195,12 @@ main(int argc, char *argv[])
|
|||||||
} else if (req_term) {
|
} else if (req_term) {
|
||||||
use_gui = false;
|
use_gui = false;
|
||||||
} else {
|
} else {
|
||||||
// Default depends on build target: kge defaults to GUI, kte to terminal
|
|
||||||
|
// Default depends on build target: kge defaults to GUI, kte to terminal
|
||||||
#if defined(KTE_DEFAULT_GUI)
|
#if defined(KTE_DEFAULT_GUI)
|
||||||
use_gui = true;
|
use_gui = true;
|
||||||
#else
|
#else
|
||||||
use_gui = false;
|
use_gui = false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -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.
|
// 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 no files are provided, create an empty buffer.
|
||||||
if (optind < argc) {
|
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
|
std::size_t pending_line = 0; // 0 = no pending line
|
||||||
for (int i = optind; i < argc; ++i) {
|
for (int i = optind; i < argc; ++i) {
|
||||||
const char *arg = argv[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 '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string err;
|
|
||||||
const std::string path = arg;
|
const std::string path = arg;
|
||||||
if (!editor.OpenFile(path, err)) {
|
editor.RequestOpenFile(path, pending_line);
|
||||||
editor.SetStatus("open: " + err);
|
pending_line = 0; // consumed (if set)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||||
} else {
|
} else {
|
||||||
@@ -318,4 +302,4 @@ main(int argc, char *argv[])
|
|||||||
fe->Shutdown();
|
fe->Shutdown();
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
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 "Buffer.h"
|
||||||
#include "Swap.h"
|
#include "Swap.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
static void
|
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)
|
TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch)
|
||||||
{
|
{
|
||||||
const std::string path = "./.kte_ut_swap_replay_1.txt";
|
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(path.c_str());
|
||||||
std::remove(swap_path.c_str());
|
std::remove(swap_path.c_str());
|
||||||
std::remove(trunc_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::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid()));
|
||||||
std::filesystem::remove_all(xdg_root);
|
std::filesystem::remove_all(xdg_root);
|
||||||
|
|
||||||
const char *old_xdg = std::getenv("XDG_STATE_HOME");
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
|
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();
|
const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string();
|
||||||
std::filesystem::create_directories((xdg_root / "work"));
|
std::filesystem::create_directories((xdg_root / "work"));
|
||||||
@@ -148,14 +150,15 @@ TEST (SwapWriter_Header_Records_And_CRC)
|
|||||||
off = crc_off + 4;
|
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[0], (std::uint8_t) kte::SwapRecType::INS);
|
||||||
ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL);
|
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(path.c_str());
|
||||||
std::remove(swp.c_str());
|
std::remove(swp.c_str());
|
||||||
if (old_xdg) {
|
if (!old_xdg.empty()) {
|
||||||
setenv("XDG_STATE_HOME", old_xdg, 1);
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
} else {
|
} else {
|
||||||
unsetenv("XDG_STATE_HOME");
|
unsetenv("XDG_STATE_HOME");
|
||||||
}
|
}
|
||||||
@@ -171,8 +174,10 @@ TEST (SwapWriter_NoStomp_SameBasename)
|
|||||||
std::filesystem::remove_all(xdg_root);
|
std::filesystem::remove_all(xdg_root);
|
||||||
std::filesystem::create_directories(xdg_root);
|
std::filesystem::create_directories(xdg_root);
|
||||||
|
|
||||||
const char *old_xdg = std::getenv("XDG_STATE_HOME");
|
const char *old_xdg_p = std::getenv("XDG_STATE_HOME");
|
||||||
setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1);
|
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 d1 = xdg_root / "p1";
|
||||||
const std::filesystem::path d2 = xdg_root / "p2";
|
const std::filesystem::path d2 = xdg_root / "p2";
|
||||||
@@ -227,8 +232,8 @@ TEST (SwapWriter_NoStomp_SameBasename)
|
|||||||
std::remove(swp2.c_str());
|
std::remove(swp2.c_str());
|
||||||
std::remove(f1.string().c_str());
|
std::remove(f1.string().c_str());
|
||||||
std::remove(f2.string().c_str());
|
std::remove(f2.string().c_str());
|
||||||
if (old_xdg) {
|
if (!old_xdg.empty()) {
|
||||||
setenv("XDG_STATE_HOME", old_xdg, 1);
|
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
|
||||||
} else {
|
} else {
|
||||||
unsetenv("XDG_STATE_HOME");
|
unsetenv("XDG_STATE_HOME");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user