diff --git a/Buffer.cc b/Buffer.cc index 6190c00..1a43e26 100644 --- a/Buffer.cc +++ b/Buffer.cc @@ -575,6 +575,16 @@ Buffer::delete_row(int row) } +void +Buffer::replace_all_bytes(const std::string_view bytes) +{ + content_.Clear(); + if (!bytes.empty()) + content_.Append(bytes.data(), bytes.size()); + rows_cache_dirty_ = true; +} + + // Undo system accessors UndoSystem * Buffer::Undo() diff --git a/Buffer.h b/Buffer.h index d917b88..af3ad0e 100644 --- a/Buffer.h +++ b/Buffer.h @@ -494,6 +494,12 @@ public: } + [[nodiscard]] kte::SwapRecorder *SwapRecorder() const + { + return swap_rec_; + } + + // Raw, low-level editing APIs used by UndoSystem apply(). // These must NOT trigger undo recording. They also do not move the cursor. void insert_text(int row, int col, std::string_view text); @@ -508,6 +514,11 @@ public: void delete_row(int row); + // Replace the entire buffer content with raw bytes. + // Intended for crash recovery (swap replay) and test harnesses. + // This does not trigger swap or undo recording. + void replace_all_bytes(std::string_view bytes); + // Undo system accessors (created per-buffer) [[nodiscard]] UndoSystem *Undo(); diff --git a/CMakeLists.txt b/CMakeLists.txt index 44e8b7b..b1e2763 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ project(kte) include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 20) -set(KTE_VERSION "1.6.1") +set(KTE_VERSION "1.6.2") # Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. @@ -308,6 +308,8 @@ if (BUILD_TESTS) tests/test_swap_recorder.cc tests/test_swap_writer.cc tests/test_swap_replay.cc + tests/test_swap_recovery_prompt.cc + tests/test_swap_cleanup.cc tests/test_piece_table.cc tests/test_search.cc tests/test_search_replace_flow.cc diff --git a/Command.cc b/Command.cc index 1b7a6e7..eb63538 100644 --- a/Command.cc +++ b/Command.cc @@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx) return false; } buf->SetDirty(false); + if (auto *sm = ctx.editor.Swap()) + sm->ResetJournal(*buf); ctx.editor.SetStatus("Saved " + buf->Filename()); return true; } @@ -632,6 +634,8 @@ cmd_save(CommandContext &ctx) return false; } buf->SetDirty(false); + if (auto *sm = ctx.editor.Swap()) + sm->ResetJournal(*buf); ctx.editor.SetStatus("Saved " + buf->Filename()); if (auto *u = buf->Undo()) u->mark_saved(); @@ -686,6 +690,10 @@ cmd_save_as(CommandContext &ctx) ctx.editor.SetStatus(err); return false; } + if (auto *sm = ctx.editor.Swap()) { + sm->NotifyFilenameChanged(*buf); + sm->ResetJournal(*buf); + } ctx.editor.SetStatus("Saved as " + ctx.arg); if (auto *u = buf->Undo()) u->mark_saved(); @@ -789,6 +797,7 @@ cmd_refresh(CommandContext &ctx) ctx.editor.SetCloseConfirmPending(false); ctx.editor.SetCloseAfterSave(false); ctx.editor.ClearPendingOverwritePath(); + ctx.editor.CancelRecoveryPrompt(); ctx.editor.CancelPrompt(); ctx.editor.SetStatus("Canceled"); return true; @@ -2441,7 +2450,6 @@ cmd_newline(CommandContext &ctx) ctx.editor.SetSearchIndex(-1); return true; } else if (kind == Editor::PromptKind::OpenFile) { - std::string err; // Expand "~" to the user's home directory auto expand_user_path = [](const std::string &in) -> std::string { if (!in.empty() && in[0] == '~') { @@ -2458,14 +2466,19 @@ cmd_newline(CommandContext &ctx) value = expand_user_path(value); if (value.empty()) { ctx.editor.SetStatus("Open canceled (empty)"); - } else if (!ctx.editor.OpenFile(value, err)) { - ctx.editor.SetStatus(err.empty() ? std::string("Failed to open ") + value : err); } else { - ctx.editor.SetStatus(std::string("Opened ") + value); - // 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(); + ctx.editor.RequestOpenFile(value); + const bool opened = ctx.editor.ProcessPendingOpens(); + if (ctx.editor.PromptActive()) { + // A recovery confirmation prompt was started. + return true; + } + if (opened) { + // Center the view on the cursor (e.g. if the buffer restored a cursor position) + cmd_center_on_cursor(ctx); + // Close the prompt so subsequent typing edits the buffer, not the prompt + ctx.editor.CancelPrompt(); + } } } else if (kind == Editor::PromptKind::BufferSwitch) { // Resolve to a buffer index by exact match against path or basename; @@ -2579,6 +2592,10 @@ cmd_newline(CommandContext &ctx) ctx.editor.SetStatus(err); } else { buf->SetDirty(false); + if (auto *sm = ctx.editor.Swap()) { + sm->NotifyFilenameChanged(*buf); + sm->ResetJournal(*buf); + } ctx.editor.SetStatus("Saved as " + target); if (auto *u = buf->Undo()) u->mark_saved(); @@ -2612,6 +2629,16 @@ cmd_newline(CommandContext &ctx) ctx.editor.ClearPendingOverwritePath(); // Regardless of answer, end any close-after-save pending state for safety. ctx.editor.SetCloseAfterSave(false); + } else if (ctx.editor.PendingRecoveryPrompt() != Editor::RecoveryPromptKind::None) { + bool yes = false; + if (!value.empty()) { + char c = value[0]; + yes = (c == 'y' || c == 'Y'); + } + (void) ctx.editor.ResolveRecoveryPrompt(yes); + ctx.editor.CancelPrompt(); + // Continue any queued opens (e.g., startup argv files). + ctx.editor.ProcessPendingOpens(); } else if (ctx.editor.CloseConfirmPending() && buf) { bool yes = false; if (!value.empty()) { @@ -2630,6 +2657,8 @@ cmd_newline(CommandContext &ctx) proceed_to_close = false; } else { buf->SetDirty(false); + if (auto *sm = ctx.editor.Swap()) + sm->ResetJournal(*buf); if (auto *u = buf->Undo()) u->mark_saved(); } @@ -2639,6 +2668,10 @@ cmd_newline(CommandContext &ctx) proceed_to_close = false; } else { buf->SetDirty(false); + if (auto *sm = ctx.editor.Swap()) { + sm->NotifyFilenameChanged(*buf); + sm->ResetJournal(*buf); + } if (auto *u = buf->Undo()) u->mark_saved(); } diff --git a/Editor.cc b/Editor.cc index 6a41b55..f05dbbc 100644 --- a/Editor.cc +++ b/Editor.cc @@ -1,6 +1,7 @@ #include -#include +#include #include +#include #include "Editor.h" #include "syntax/HighlighterRegistry.h" @@ -8,6 +9,41 @@ #include "syntax/NullHighlighter.h" +namespace { +static std::string +buffer_bytes_via_views(const Buffer &b) +{ + const auto &rows = b.Rows(); + std::string out; + for (std::size_t i = 0; i < rows.size(); i++) { + auto v = b.GetLineView(i); + out.append(v.data(), v.size()); + } + return out; +} + + +static void +apply_pending_line(Editor &ed, const std::size_t line1) +{ + if (line1 == 0) + return; + Buffer *b = ed.CurrentBuffer(); + if (!b) + return; + const std::size_t nrows = b->Nrows(); + std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based + if (nrows > 0) { + if (line >= nrows) + line = nrows - 1; + } else { + line = 0; + } + b->SetCursor(0, line); +} +} // namespace + + Editor::Editor() { swap_ = std::make_unique(); @@ -177,10 +213,10 @@ Editor::OpenFile(const std::string &path, std::string &err) } // Setup highlighting using registry (extension + shebang) cur.EnsureHighlighter(); - std::string first = ""; - const auto &rows = cur.Rows(); - if (!rows.empty()) - first = static_cast(rows[0]); + std::string first = ""; + const auto &cur_rows = cur.Rows(); + if (!cur_rows.empty()) + first = static_cast(cur_rows[0]); std::string ft = kte::HighlighterRegistry::DetectForPath(path, first); if (!ft.empty()) { cur.SetFiletype(ft); @@ -245,6 +281,172 @@ Editor::OpenFile(const std::string &path, std::string &err) } +void +Editor::RequestOpenFile(const std::string &path, const std::size_t line1) +{ + PendingOpen p; + p.path = path; + p.line1 = line1; + pending_open_.push_back(std::move(p)); +} + + +bool +Editor::HasPendingOpens() const +{ + return !pending_open_.empty(); +} + + +Editor::RecoveryPromptKind +Editor::PendingRecoveryPrompt() const +{ + return pending_recovery_prompt_; +} + + +void +Editor::CancelRecoveryPrompt() +{ + pending_recovery_prompt_ = RecoveryPromptKind::None; + pending_recovery_open_ = PendingOpen{}; + pending_recovery_swap_path_.clear(); + pending_recovery_replay_err_.clear(); +} + + +bool +Editor::ResolveRecoveryPrompt(const bool yes) +{ + const RecoveryPromptKind kind = pending_recovery_prompt_; + if (kind == RecoveryPromptKind::None) + return false; + const PendingOpen req = pending_recovery_open_; + const std::string swp = pending_recovery_swap_path_; + const std::string rerr_s = pending_recovery_replay_err_; + CancelRecoveryPrompt(); + + std::string err; + if (kind == RecoveryPromptKind::RecoverOrDiscard) { + if (yes) { + if (!OpenFile(req.path, err)) { + SetStatus(err); + return false; + } + Buffer *b = CurrentBuffer(); + if (!b) { + SetStatus("Recovery failed: no buffer"); + return false; + } + std::string rerr; + if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) { + SetStatus("Swap recovery failed: " + rerr); + return false; + } + b->SetDirty(true); + apply_pending_line(*this, req.line1); + SetStatus("Recovered " + req.path); + return true; + } + // Discard: best-effort delete swap, then open clean. + (void) std::remove(swp.c_str()); + if (!OpenFile(req.path, err)) { + SetStatus(err); + return false; + } + apply_pending_line(*this, req.line1); + SetStatus("Opened " + req.path); + return true; + } + if (kind == RecoveryPromptKind::DeleteCorruptSwap) { + if (yes) { + (void) std::remove(swp.c_str()); + } + if (!OpenFile(req.path, err)) { + SetStatus(err); + return false; + } + apply_pending_line(*this, req.line1); + // Include a short hint that the swap was corrupt. + if (!rerr_s.empty()) { + SetStatus("Opened " + req.path + " (swap unreadable)"); + } else { + SetStatus("Opened " + req.path); + } + return true; + } + return false; +} + + +bool +Editor::ProcessPendingOpens() +{ + if (PromptActive()) + return false; + if (pending_recovery_prompt_ != RecoveryPromptKind::None) + return false; + + bool opened_any = false; + while (!pending_open_.empty()) { + PendingOpen req = std::move(pending_open_.front()); + pending_open_.pop_front(); + if (req.path.empty()) + continue; + + std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path); + bool swp_exists = false; + try { + swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp)); + } catch (...) { + swp_exists = false; + } + if (swp_exists) { + Buffer tmp; + std::string oerr; + if (tmp.OpenFromFile(req.path, oerr)) { + const std::string orig = buffer_bytes_via_views(tmp); + std::string rerr; + if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) { + const std::string rec = buffer_bytes_via_views(tmp); + if (rec != orig) { + pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard; + pending_recovery_open_ = req; + pending_recovery_swap_path_ = swp; + StartPrompt(PromptKind::Confirm, "Recover", ""); + SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)"); + return opened_any; + } + } else { + pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap; + pending_recovery_open_ = req; + pending_recovery_swap_path_ = swp; + pending_recovery_replay_err_ = rerr; + StartPrompt(PromptKind::Confirm, "Swap", ""); + SetStatus( + "Swap file unreadable for " + req.path + + ". Delete it? (y/N, C-g cancel)"); + return opened_any; + } + } + } + + std::string err; + if (!OpenFile(req.path, err)) { + SetStatus(err); + opened_any = false; + continue; + } + apply_pending_line(*this, req.line1); + SetStatus("Opened " + req.path); + opened_any = true; + // Open at most one per call; frontends can call us again next frame. + break; + } + return opened_any; +} + + bool Editor::SwitchTo(std::size_t index) { @@ -284,7 +486,9 @@ Editor::CloseBuffer(std::size_t index) return false; } if (swap_) { - swap_->Detach(&buffers_[index]); + // If the buffer is clean, remove its swap file when closing. + // (Crash recovery is unaffected: on crash, close paths are not executed.) + swap_->Detach(&buffers_[index], !buffers_[index].Dirty()); buffers_[index].SetSwapRecorder(nullptr); } buffers_.erase(buffers_.begin() + static_cast(index)); diff --git a/Editor.h b/Editor.h index 57d1215..12e141a 100644 --- a/Editor.h +++ b/Editor.h @@ -4,6 +4,7 @@ #pragma once #include #include +#include #include #include @@ -497,6 +498,30 @@ public: bool OpenFile(const std::string &path, std::string &err); + // Request that a file be opened. The request is processed by calling + // ProcessPendingOpens() (typically once per frontend frame). + void RequestOpenFile(const std::string &path, std::size_t line1 = 0); + + // If no modal prompt is active, process queued open requests. + // Returns true if a file was opened during this call. + bool ProcessPendingOpens(); + + [[nodiscard]] bool HasPendingOpens() const; + + // Swap recovery confirmation state. When non-None, a `PromptKind::Confirm` + // prompt is active and the user's answer should be routed to ResolveRecoveryPrompt(). + enum class RecoveryPromptKind { + None = 0, + RecoverOrDiscard, // y = recover swap, else discard swap and open clean + DeleteCorruptSwap // y = delete corrupt swap, else keep it + }; + + [[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const; + + bool ResolveRecoveryPrompt(bool yes); + + void CancelRecoveryPrompt(); + // Buffer switching/closing bool SwitchTo(std::size_t index); @@ -550,6 +575,11 @@ public: } private: + struct PendingOpen { + std::string path; + std::size_t line1{0}; // 1-based; 0 = none + }; + std::size_t rows_ = 0, cols_ = 0; int mode_ = 0; int kill_ = 0; // KILL CHAIN @@ -593,6 +623,13 @@ private: std::string prompt_text_; std::string pending_overwrite_path_; + // Deferred open + swap recovery prompt state + std::deque pending_open_; + RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None; + PendingOpen pending_recovery_open_{}; + std::string pending_recovery_swap_path_; + std::string pending_recovery_replay_err_; + // GUI-only state (safe no-op in terminal builds) bool file_picker_visible_ = false; std::string file_picker_dir_; diff --git a/ImGuiFrontend.cc b/ImGuiFrontend.cc index c73964e..7b9b0b0 100644 --- a/ImGuiFrontend.cc +++ b/ImGuiFrontend.cc @@ -298,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running) } } + // Allow deferred opens (including swap recovery prompts) to run. + ed.ProcessPendingOpens(); + // Execute pending mapped inputs (drain queue) AFTER dimensions are updated for (;;) { MappedInput mi; diff --git a/ImGuiRenderer.cc b/ImGuiRenderer.cc index 06bb7ee..a52acd6 100644 --- a/ImGuiRenderer.cc +++ b/ImGuiRenderer.cc @@ -912,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed) ed.SetFilePickerDir(e.path.string()); } else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { // Open file on single click - std::string err; - if (!ed.OpenFile(e.path.string(), err)) { - ed.SetStatus(std::string("open: ") + err); - } else { - ed.SetStatus(std::string("Opened: ") + e.name); - } + ed.RequestOpenFile(e.path.string()); + (void) ed.ProcessPendingOpens(); ed.SetFilePickerVisible(false); } } diff --git a/QtFrontend.cc b/QtFrontend.cc index 278c09f..f156538 100644 --- a/QtFrontend.cc +++ b/QtFrontend.cc @@ -775,6 +775,9 @@ GUIFrontend::Step(Editor &ed, bool &running) if (app_) app_->processEvents(); + // Allow deferred opens (including swap recovery prompts) to run. + ed.ProcessPendingOpens(); + // Drain input queue for (;;) { MappedInput mi; @@ -801,14 +804,8 @@ GUIFrontend::Step(Editor &ed, bool &running) const QStringList files = dlg.selectedFiles(); if (!files.isEmpty()) { const QString fp = files.front(); - std::string err; - if (ed.OpenFile(fp.toStdString(), err)) { - ed.SetStatus(std::string("Opened: ") + fp.toStdString()); - } else if (!err.empty()) { - ed.SetStatus(std::string("Open failed: ") + err); - } else { - ed.SetStatus("Open failed"); - } + ed.RequestOpenFile(fp.toStdString()); + (void) ed.ProcessPendingOpens(); // Update picker dir for next time QFileInfo info(fp); ed.SetFilePickerDir(info.dir().absolutePath().toStdString()); diff --git a/Swap.cc b/Swap.cc index 495c5dd..a3b4988 100644 --- a/Swap.cc +++ b/Swap.cc @@ -22,6 +22,24 @@ constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; constexpr std::uint32_t VERSION = 1; +static std::string +snapshot_buffer_bytes(const Buffer &b) +{ + const auto &rows = b.Rows(); + std::string out; + // Cheap lower bound: sum of row sizes. + std::size_t approx = 0; + for (const auto &r: rows) + approx += r.size(); + out.reserve(approx); + for (std::size_t i = 0; i < rows.size(); i++) { + auto v = b.GetLineView(i); + out.append(v.data(), v.size()); + } + return out; +} + + static fs::path xdg_state_home() { @@ -38,6 +56,13 @@ xdg_state_home() } +static fs::path +swap_root_dir() +{ + return xdg_state_home() / "kte" / "swap"; +} + + static std::uint64_t fnv1a64(std::string_view s) { @@ -82,6 +107,64 @@ write_full(int fd, const void *buf, size_t len) } return true; } + + +static std::string +encode_path_key(std::string s) +{ + // Turn an absolute path like "/home/kyle/tmp/test.txt" into + // "home!kyle!tmp!test.txt" so swap files are human-identifiable. + // + // Notes: + // - We strip a single leading path separator so absolute paths don't start with '!'. + // - We replace both '/' and '\\' with '!'. + // - We leave other characters as-is (spaces are OK on POSIX). + if (!s.empty() && (s[0] == '/' || s[0] == '\\')) + s.erase(0, 1); + for (char &ch: s) { + if (ch == '/' || ch == '\\') + ch = '!'; + } + return s; +} + + +static std::string +compute_swap_path_for_filename(const std::string &filename) +{ + if (filename.empty()) + return std::string(); + // Always place swap under an XDG home-appropriate state directory. + // This avoids cluttering working directories and prevents stomping on + // swap files when multiple different paths share the same basename. + fs::path root = swap_root_dir(); + + fs::path p(filename); + std::string key; + try { + key = fs::weakly_canonical(p).string(); + } catch (...) { + try { + key = fs::absolute(p).string(); + } catch (...) { + key = filename; + } + } + std::string encoded = encode_path_key(key); + if (!encoded.empty()) { + std::string name = encoded + ".swp"; + // Avoid filesystem/path length issues; fall back to hashed naming. + // NAME_MAX is often 255 on POSIX, but keep extra headroom. + if (name.size() <= 200) { + return (root / name).string(); + } + } + + // Fallback: stable, shorter name based on basename + hash. + std::string base = p.filename().string(); + const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp"; + return (root / name).string(); +} } @@ -91,6 +174,11 @@ SwapManager::SwapManager() worker_ = std::thread([this] { this->writer_loop(); }); + // Best-effort prune of old swap files. + // Safe early in startup: journals_ is still empty and no fds are open yet. + if (cfg_.prune_on_startup) { + PruneSwapDir(); + } } @@ -109,6 +197,29 @@ SwapManager::~SwapManager() } +void +SwapManager::Checkpoint(Buffer *buf) +{ + if (buf) { + RecordCheckpoint(*buf, false); + return; + } + // All buffers + std::vector bufs; + { + std::lock_guard lg(mtx_); + bufs.reserve(journals_.size()); + for (auto &kv: journals_) { + bufs.push_back(kv.first); + } + } + for (Buffer *b: bufs) { + if (b) + RecordCheckpoint(*b, false); + } +} + + void SwapManager::Flush(Buffer *buf) { @@ -171,10 +282,14 @@ SwapManager::Attach(Buffer *buf) void -SwapManager::Detach(Buffer *buf) +SwapManager::Detach(Buffer *buf, const bool remove_file) { if (!buf) return; + // Write a best-effort final checkpoint before suspending and closing. + // If the caller requested removal, skip the final checkpoint so the file can be deleted. + if (!remove_file) + RecordCheckpoint(*buf, true); { std::lock_guard lg(mtx_); auto it = journals_.find(buf); @@ -183,24 +298,162 @@ SwapManager::Detach(Buffer *buf) } } Flush(buf); - std::lock_guard lg(mtx_); - auto it = journals_.find(buf); - if (it != journals_.end()) { - close_ctx(it->second); - journals_.erase(it); + std::string path; + { + std::lock_guard lg(mtx_); + auto it = journals_.find(buf); + if (it != journals_.end()) { + path = it->second.path; + close_ctx(it->second); + journals_.erase(it); + } + recorders_.erase(buf); + } + if (remove_file && !path.empty()) { + (void) std::remove(path.c_str()); + } +} + + +void +SwapManager::ResetJournal(Buffer &buf) +{ + std::string path; + { + std::lock_guard 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 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 active; + { + std::lock_guard 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 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(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 SwapManager::NotifyFilenameChanged(Buffer &buf) { + // Best-effort: checkpoint the old journal before switching paths. + RecordCheckpoint(buf, true); + std::string old_path; { std::lock_guard lg(mtx_); auto it = journals_.find(&buf); if (it == journals_.end()) return; + old_path = it->second.path; it->second.suspended = true; } Flush(&buf); @@ -210,8 +463,16 @@ SwapManager::NotifyFilenameChanged(Buffer &buf) return; JournalCtx &ctx = it->second; close_ctx(ctx); - ctx.path = ComputeSidecarPath(buf); - ctx.suspended = false; + if (!old_path.empty()) + (void) std::remove(old_path.c_str()); + ctx.path = ComputeSidecarPath(buf); + ctx.suspended = false; + ctx.header_ok = false; + ctx.last_flush_ns = 0; + ctx.last_fsync_ns = 0; + ctx.last_chkpt_ns = 0; + ctx.edit_bytes_since_chkpt = 0; + ctx.approx_size_bytes = 0; } @@ -257,54 +518,9 @@ SwapManager::SuspendGuard::~SuspendGuard() std::string SwapManager::ComputeSidecarPath(const Buffer &buf) { - // Always place swap under an XDG home-appropriate state directory. - // This avoids cluttering working directories and prevents stomping on - // swap files when multiple different paths share the same basename. - fs::path root = xdg_state_home() / "kte" / "swap"; - - auto encode_path = [](std::string s) -> std::string { - // Turn an absolute path like "/home/kyle/tmp/test.txt" into - // "home!kyle!tmp!test.txt" so swap files are human-identifiable. - // - // Notes: - // - We strip a single leading path separator so absolute paths don't start with '!'. - // - We replace both '/' and '\\' with '!'. - // - We leave other characters as-is (spaces are OK on POSIX). - if (!s.empty() && (s[0] == '/' || s[0] == '\\')) - s.erase(0, 1); - for (char &ch: s) { - if (ch == '/' || ch == '\\') - ch = '!'; - } - return s; - }; - + fs::path root = swap_root_dir(); if (!buf.Filename().empty()) { - fs::path p(buf.Filename()); - std::string key; - try { - key = fs::weakly_canonical(p).string(); - } catch (...) { - try { - key = fs::absolute(p).string(); - } catch (...) { - key = buf.Filename(); - } - } - std::string encoded = encode_path(key); - if (!encoded.empty()) { - std::string name = encoded + ".swp"; - // Avoid filesystem/path length issues; fall back to hashed naming. - // NAME_MAX is often 255 on POSIX, but keep extra headroom. - if (name.size() <= 200) { - return (root / name).string(); - } - } - - // Fallback: stable, shorter name based on basename + hash. - std::string base = p.filename().string(); - const std::string name = base + "." + hex_u64(fnv1a64(key)) + ".swp"; - return (root / name).string(); + return compute_swap_path_for_filename(buf.Filename()); } // Unnamed buffers: unique within the process. @@ -316,6 +532,20 @@ SwapManager::ComputeSidecarPath(const Buffer &buf) } +std::string +SwapManager::ComputeSwapPathForFilename(const std::string &filename) +{ + return ComputeSidecarPathForFilename(filename); +} + + +std::string +SwapManager::ComputeSidecarPathForFilename(const std::string &filename) +{ + return compute_swap_path_for_filename(filename); +} + + std::uint64_t SwapManager::now_ns() { @@ -402,9 +632,11 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path) ctx.fd = fd; ctx.path = path; 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 { - ctx.header_ok = true; // stage 1: trust existing header + ctx.header_ok = true; // stage 1: trust existing header + ctx.approx_size_bytes = static_cast(st.st_size); } return ctx.header_ok; } @@ -422,6 +654,79 @@ SwapManager::close_ctx(JournalCtx &ctx) } +bool +SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector &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(chkpt_record.size()); + return true; +} + + std::uint32_t SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed) { @@ -510,6 +815,7 @@ SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text) p.payload.insert(p.payload.end(), reinterpret_cast(text.data()), reinterpret_cast(text.data()) + text.size()); enqueue(std::move(p)); + maybe_request_checkpoint(buf, text.size()); } @@ -533,6 +839,7 @@ SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len) put_le32(p.payload, static_cast(std::max(0, col))); put_le32(p.payload, static_cast(len)); enqueue(std::move(p)); + maybe_request_checkpoint(buf, len); } @@ -553,6 +860,7 @@ SwapManager::RecordSplit(Buffer &buf, int row, int col) put_le32(p.payload, static_cast(std::max(0, row))); put_le32(p.payload, static_cast(std::max(0, col))); enqueue(std::move(p)); + maybe_request_checkpoint(buf, 1); } @@ -572,6 +880,68 @@ SwapManager::RecordJoin(Buffer &buf, int row) p.payload.push_back(1); put_le32(p.payload, static_cast(std::max(0, row))); enqueue(std::move(p)); + maybe_request_checkpoint(buf, 1); +} + + +void +SwapManager::maybe_request_checkpoint(Buffer &buf, const std::size_t approx_edit_bytes) +{ + SwapConfig cfg; + bool do_chkpt = false; + { + std::lock_guard 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 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(bytes.size())); + p.payload.insert(p.payload.end(), reinterpret_cast(bytes.data()), + reinterpret_cast(bytes.data()) + bytes.size()); + enqueue(std::move(p)); } @@ -641,17 +1011,17 @@ SwapManager::process_one(const Pending &p) JournalCtx *ctxp = nullptr; std::string path; + std::size_t compact_bytes = 0; { std::lock_guard lg(mtx_); auto it = journals_.find(p.buf); if (it == journals_.end()) return; - if (it->second.suspended) - return; if (it->second.path.empty()) it->second.path = ComputeSidecarPath(buf); - path = it->second.path; - ctxp = &it->second; + path = it->second.path; + ctxp = &it->second; + compact_bytes = cfg_.compact_bytes; } if (!ctxp) return; @@ -680,13 +1050,27 @@ SwapManager::process_one(const Pending &p) crcbytes[2] = static_cast((c >> 16) & 0xFFu); crcbytes[3] = static_cast((c >> 24) & 0xFFu); + std::vector rec; + rec.reserve(sizeof(head) + p.payload.size() + sizeof(crcbytes)); + rec.insert(rec.end(), head, head + sizeof(head)); + if (!p.payload.empty()) + rec.insert(rec.end(), p.payload.begin(), p.payload.end()); + rec.insert(rec.end(), crcbytes, crcbytes + sizeof(crcbytes)); + // Write (handle partial writes and check results) - bool ok = write_full(ctxp->fd, head, sizeof(head)); - if (ok && !p.payload.empty()) - ok = write_full(ctxp->fd, p.payload.data(), p.payload.size()); - if (ok) - ok = write_full(ctxp->fd, crcbytes, sizeof(crcbytes)); - (void) ok; // stage 1: best-effort; future work could mark ctx error state + bool ok = write_full(ctxp->fd, rec.data(), rec.size()); + if (ok) { + ctxp->approx_size_bytes += static_cast(rec.size()); + if (p.urgent_flush) { + (void) ::fsync(ctxp->fd); + ctxp->last_fsync_ns = now_ns(); + } + if (p.type == SwapRecType::CHKPT && compact_bytes > 0 && + ctxp->approx_size_bytes >= static_cast(compact_bytes)) { + (void) compact_to_checkpoint(*ctxp, rec); + } + } + (void) ok; // best-effort; future work could mark ctx error state } @@ -743,6 +1127,20 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & return false; } + // Ensure replayed edits don't get re-journaled if the caller forgot to detach/suspend. + kte::SwapRecorder *prev_rec = buf.SwapRecorder(); + buf.SetSwapRecorder(nullptr); + struct RestoreSwapRecorder { + Buffer &b; + kte::SwapRecorder *prev; + + + ~RestoreSwapRecorder() + { + b.SetSwapRecorder(prev); + } + } restore{buf, prev_rec}; + for (;;) { std::uint8_t head[4]; in.read(reinterpret_cast(head), sizeof(head)); @@ -780,18 +1178,18 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & } // 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) { 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; if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le( payload, off, nbytes)) { @@ -807,6 +1205,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & break; } case SwapRecType::DEL: { + std::size_t off = 0; + if (payload.empty()) { + err = "Swap record missing DEL payload"; + return false; + } + const std::uint8_t encver = payload[off++]; + if (encver != 1) { + err = "Unsupported swap payload encoding"; + return false; + } std::uint32_t row = 0, col = 0, dlen = 0; if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le( payload, off, dlen)) { @@ -817,6 +1225,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & break; } case SwapRecType::SPLIT: { + std::size_t off = 0; + if (payload.empty()) { + err = "Swap record missing SPLIT payload"; + return false; + } + const std::uint8_t encver = payload[off++]; + if (encver != 1) { + err = "Unsupported swap payload encoding"; + return false; + } std::uint32_t row = 0, col = 0; if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) { err = "Malformed SPLIT payload"; @@ -826,6 +1244,16 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & break; } case SwapRecType::JOIN: { + std::size_t off = 0; + if (payload.empty()) { + err = "Swap record missing JOIN payload"; + return false; + } + const std::uint8_t encver = payload[off++]; + if (encver != 1) { + err = "Unsupported swap payload encoding"; + return false; + } std::uint32_t row = 0; if (!parse_u32_le(payload, off, row)) { err = "Malformed JOIN payload"; @@ -834,8 +1262,32 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string & buf.join_lines((int) row); break; } + case SwapRecType::CHKPT: { + std::size_t off = 0; + if (payload.size() < 5) { + err = "Malformed CHKPT payload"; + return false; + } + const std::uint8_t encver = payload[off++]; + if (encver != 1) { + err = "Unsupported swap checkpoint encoding"; + return false; + } + std::uint32_t nbytes = 0; + if (!parse_u32_le(payload, off, nbytes)) { + err = "Malformed CHKPT payload"; + return false; + } + if (off + nbytes > payload.size()) { + err = "Truncated CHKPT payload bytes"; + return false; + } + buf.replace_all_bytes(std::string_view(reinterpret_cast(payload.data() + off), + (std::size_t) nbytes)); + break; + } default: - // Ignore unknown types for forward-compat in stage 1 + // Ignore unknown types for forward-compat break; } } diff --git a/Swap.h b/Swap.h index 64817d1..0a90600 100644 --- a/Swap.h +++ b/Swap.h @@ -32,6 +32,18 @@ struct SwapConfig { // Grouping and durability knobs (stage 1 defaults) unsigned flush_interval_ms{200}; // group small writes unsigned fsync_interval_ms{1000}; // at most once per second + + // Checkpoint/compaction knobs (stage 2 defaults) + // A checkpoint is a full snapshot of the buffer content written as a CHKPT record. + // Compaction rewrites the swap file to contain just the latest checkpoint. + std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes + unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing + std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this + + // Cleanup / retention (best-effort) + bool prune_on_startup{true}; + unsigned prune_max_age_days{30}; + std::size_t prune_max_files{2048}; }; // SwapManager manages sidecar swap files and a single background writer thread. @@ -45,13 +57,36 @@ public: void Attach(Buffer *buf); // Detach and close journal. - void Detach(Buffer *buf); + // If remove_file is true, the swap file is deleted after closing. + // Intended for clean shutdown/close flows. + void Detach(Buffer *buf, bool remove_file = false); + + // Reset (truncate-by-delete) the journal for a buffer after a clean save. + // Best-effort: closes the current fd, deletes the swap file, and resumes recording. + void ResetJournal(Buffer &buf); + + // Best-effort pruning of old swap files under the swap directory. + // Never touches non-`.swp` files. + void PruneSwapDir(); // Block until all currently queued records have been written. // If buf is non-null, flushes all records (stage 1) but is primarily intended // for tests and shutdown. void Flush(Buffer *buf = nullptr); + // Request a full-content checkpoint record for one buffer (or all buffers if buf is null). + // This is best-effort and asynchronous; call Flush() if you need it written before continuing. + void Checkpoint(Buffer *buf = nullptr); + + + void SetConfig(const SwapConfig &cfg) + { + std::lock_guard lg(mtx_); + cfg_ = cfg; + cv_.notify_one(); + } + + // Obtain a per-buffer recorder adapter that emits records for that buffer. // The returned pointer is owned by the SwapManager and remains valid until // Detach(buf) or SwapManager destruction. @@ -67,6 +102,10 @@ public: // treat this as a recovery failure and surface `err`. static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err); + // Compute the swap path for a file-backed buffer by filename. + // Returns empty string if filename is empty. + static std::string ComputeSwapPathForFilename(const std::string &filename); + // Test-only hook to keep swap path logic centralized. // (Avoid duplicating naming rules in unit tests.) #ifdef KTE_TESTS @@ -114,6 +153,10 @@ private: void RecordJoin(Buffer &buf, int row); + void RecordCheckpoint(Buffer &buf, bool urgent_flush); + + void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes); + struct JournalCtx { std::string path; int fd{-1}; @@ -121,6 +164,9 @@ private: bool suspended{false}; std::uint64_t last_flush_ns{0}; std::uint64_t last_fsync_ns{0}; + std::uint64_t last_chkpt_ns{0}; + std::uint64_t edit_bytes_since_chkpt{0}; + std::uint64_t approx_size_bytes{0}; }; struct Pending { @@ -134,16 +180,22 @@ private: // Helpers static std::string ComputeSidecarPath(const Buffer &buf); + static std::string ComputeSidecarPathForFilename(const std::string &filename); + static std::uint64_t now_ns(); static bool ensure_parent_dir(const std::string &path); + static std::string SwapDirRoot(); + static bool write_header(int fd); static bool open_ctx(JournalCtx &ctx, const std::string &path); static void close_ctx(JournalCtx &ctx); + static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector &chkpt_record); + static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0); static void put_le32(std::vector &out, std::uint32_t v); diff --git a/TerminalFrontend.cc b/TerminalFrontend.cc index 8aaa921..514db78 100644 --- a/TerminalFrontend.cc +++ b/TerminalFrontend.cc @@ -94,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running) } ed.SetDimensions(static_cast(r), static_cast(c)); + // Allow deferred opens (including swap recovery prompts) to run. + ed.ProcessPendingOpens(); + MappedInput mi; if (input_.Poll(mi)) { if (mi.hasCommand) { diff --git a/TestFrontend.cc b/TestFrontend.cc index dcea91f..13c6551 100644 --- a/TestFrontend.cc +++ b/TestFrontend.cc @@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed) void TestFrontend::Step(Editor &ed, bool &running) { + // Allow deferred opens (including swap recovery prompts) to run. + ed.ProcessPendingOpens(); + MappedInput mi; if (input_.Poll(mi)) { if (mi.hasCommand) { diff --git a/docs/swap.md b/docs/swap.md new file mode 100644 index 0000000..3f16cf9 --- /dev/null +++ b/docs/swap.md @@ -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 ? (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 . 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: + - `/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 +..swp +``` + +For unnamed/unsaved buffers, kte uses: + +```text +unnamed--.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) diff --git a/main.cc b/main.cc index 3e93448..5b91e10 100644 --- a/main.cc +++ b/main.cc @@ -195,11 +195,12 @@ main(int argc, char *argv[]) } else if (req_term) { use_gui = false; } else { - // Default depends on build target: kge defaults to GUI, kte to terminal + + // Default depends on build target: kge defaults to GUI, kte to terminal #if defined(KTE_DEFAULT_GUI) - use_gui = true; + use_gui = true; #else - use_gui = false; + use_gui = false; #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. // If no files are provided, create an empty buffer. if (optind < argc) { + // Seed a scratch buffer so the UI has something to show while deferred opens + // (and potential swap recovery prompts) are processed. + editor.AddBuffer(Buffer()); std::size_t pending_line = 0; // 0 = no pending line for (int i = optind; i < argc; ++i) { const char *arg = argv[i]; @@ -242,29 +246,9 @@ main(int argc, char *argv[]) // Fall through: not a +number, treat as filename starting with '+' } - std::string err; const std::string path = arg; - if (!editor.OpenFile(path, err)) { - editor.SetStatus("open: " + err); - std::cerr << "kte: " << err << "\n"; - } else if (pending_line > 0) { - // Apply pending +N to the just-opened (current) buffer - if (Buffer *b = editor.CurrentBuffer()) { - std::size_t nrows = b->Nrows(); - std::size_t line = pending_line > 0 ? pending_line - 1 : 0; - // 1-based to 0-based - if (nrows > 0) { - if (line >= nrows) - line = nrows - 1; - } else { - line = 0; - } - b->SetCursor(0, line); - // Do not force viewport offsets here; the frontend/renderer - // will establish dimensions and normalize visibility on first draw. - } - pending_line = 0; // consumed - } + editor.RequestOpenFile(path, pending_line); + pending_line = 0; // consumed (if set) } // If we ended with a pending +N but no subsequent file, ignore it. } else { @@ -318,4 +302,4 @@ main(int argc, char *argv[]) fe->Shutdown(); return 0; -} +} \ No newline at end of file diff --git a/tests/test_swap_cleanup.cc b/tests/test_swap_cleanup.cc new file mode 100644 index 0000000..ccd88d7 --- /dev/null +++ b/tests/test_swap_cleanup.cc @@ -0,0 +1,131 @@ +#include "Test.h" + +#include "Command.h" +#include "Editor.h" + +#include "tests/TestHarness.h" // for ktet::InstallDefaultCommandsOnce + +#include +#include +#include +#include +#include +#include + +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); +} \ No newline at end of file diff --git a/tests/test_swap_recovery_prompt.cc b/tests/test_swap_recovery_prompt.cc new file mode 100644 index 0000000..4ca5c18 --- /dev/null +++ b/tests/test_swap_recovery_prompt.cc @@ -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 +#include +#include +#include +#include +#include + + +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(in)), std::istreambuf_iterator()); +} + + +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); +} \ No newline at end of file diff --git a/tests/test_swap_replay.cc b/tests/test_swap_replay.cc index 8e6e5fc..e64247a 100644 --- a/tests/test_swap_replay.cc +++ b/tests/test_swap_replay.cc @@ -3,9 +3,11 @@ #include "Buffer.h" #include "Swap.h" +#include #include #include #include +#include static void @@ -37,6 +39,30 @@ buffer_bytes_via_views(const Buffer &b) } +static std::vector +record_types_from_bytes(const std::string &bytes) +{ + std::vector 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(bytes[off + 0]); + const std::uint32_t len = (std::uint32_t) static_cast(bytes[off + 1]) | + ((std::uint32_t) static_cast(bytes[off + 2]) << 8) | + ((std::uint32_t) static_cast(bytes[off + 3]) << 16); + const std::size_t crc_off = off + 4 + (std::size_t) len; + if (crc_off + 4 > bytes.size()) + break; + types.push_back(type); + off = crc_off + 4; + } + return types; +} + + TEST (SwapReplay_RecordFlushReopenReplay_ExactBytesMatch) { const std::string path = "./.kte_ut_swap_replay_1.txt"; @@ -111,4 +137,91 @@ TEST (SwapReplay_TruncatedLog_FailsSafely) std::remove(path.c_str()); std::remove(swap_path.c_str()); std::remove(trunc_path.c_str()); +} + + +TEST (SwapReplay_Checkpoint_Midstream_ExactBytesMatch) +{ + const std::string path = "./.kte_ut_swap_replay_chkpt_1.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "base\nline2\n"); + + Buffer b; + std::string err; + ASSERT_TRUE(b.OpenFromFile(path, err)); + + kte::SwapManager sm; + sm.Attach(&b); + b.SetSwapRecorder(sm.RecorderFor(&b)); + + // Some edits, then an explicit checkpoint, then more edits. + b.insert_text(0, 0, std::string("X")); + sm.Checkpoint(&b); + b.insert_text(1, 0, std::string("ZZ")); + b.delete_text(0, 0, 1); + + sm.Flush(&b); + const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b); + const std::string expected = buffer_bytes_via_views(b); + + b.SetSwapRecorder(nullptr); + sm.Detach(&b); + + Buffer b2; + ASSERT_TRUE(b2.OpenFromFile(path, err)); + ASSERT_TRUE(kte::SwapManager::ReplayFile(b2, swap_path, err)); + ASSERT_EQ(buffer_bytes_via_views(b2), expected); + + std::remove(path.c_str()); + std::remove(swap_path.c_str()); +} + + +TEST (SwapCompaction_RewritesToSingleCheckpoint) +{ + const std::string path = "./.kte_ut_swap_compact_1.txt"; + std::remove(path.c_str()); + write_file_bytes(path, "base\n"); + + Buffer b; + std::string err; + ASSERT_TRUE(b.OpenFromFile(path, err)); + + kte::SwapManager sm; + kte::SwapConfig cfg; + cfg.checkpoint_bytes = 0; + cfg.checkpoint_interval_ms = 0; + cfg.compact_bytes = 1; // force compaction on any checkpoint + sm.SetConfig(cfg); + + sm.Attach(&b); + b.SetSwapRecorder(sm.RecorderFor(&b)); + + // Ensure there is at least one non-checkpoint record on disk first. + b.insert_text(0, 0, std::string("abc")); + sm.Flush(&b); + + // Now emit a checkpoint; compaction should rewrite the file to just that checkpoint. + sm.Checkpoint(&b); + sm.Flush(&b); + + const std::string swap_path = kte::SwapManager::ComputeSwapPathForTests(b); + const std::string expected = buffer_bytes_via_views(b); + + // Close journal. + b.SetSwapRecorder(nullptr); + sm.Detach(&b); + + const std::string bytes = read_file_bytes(swap_path); + const std::vector 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()); } \ No newline at end of file diff --git a/tests/test_swap_writer.cc b/tests/test_swap_writer.cc index 2bb6c59..22442e0 100644 --- a/tests/test_swap_writer.cc +++ b/tests/test_swap_writer.cc @@ -71,8 +71,10 @@ TEST (SwapWriter_Header_Records_And_CRC) (std::string("kte_ut_xdg_state_") + std::to_string((int) ::getpid())); std::filesystem::remove_all(xdg_root); - const char *old_xdg = std::getenv("XDG_STATE_HOME"); - setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1); + const char *old_xdg_p = std::getenv("XDG_STATE_HOME"); + const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string(); + const std::string xdg_root_s = xdg_root.string(); + setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1); const std::string path = (xdg_root / "work" / "kte_ut_swap_writer.txt").string(); std::filesystem::create_directories((xdg_root / "work")); @@ -148,14 +150,15 @@ TEST (SwapWriter_Header_Records_And_CRC) off = crc_off + 4; } - ASSERT_EQ(types.size(), (std::size_t) 2); + ASSERT_EQ(types.size(), (std::size_t) 3); ASSERT_EQ(types[0], (std::uint8_t) kte::SwapRecType::INS); ASSERT_EQ(types[1], (std::uint8_t) kte::SwapRecType::DEL); + ASSERT_EQ(types[2], (std::uint8_t) kte::SwapRecType::CHKPT); std::remove(path.c_str()); std::remove(swp.c_str()); - if (old_xdg) { - setenv("XDG_STATE_HOME", old_xdg, 1); + if (!old_xdg.empty()) { + setenv("XDG_STATE_HOME", old_xdg.c_str(), 1); } else { unsetenv("XDG_STATE_HOME"); } @@ -171,8 +174,10 @@ TEST (SwapWriter_NoStomp_SameBasename) std::filesystem::remove_all(xdg_root); std::filesystem::create_directories(xdg_root); - const char *old_xdg = std::getenv("XDG_STATE_HOME"); - setenv("XDG_STATE_HOME", xdg_root.string().c_str(), 1); + const char *old_xdg_p = std::getenv("XDG_STATE_HOME"); + const std::string old_xdg = old_xdg_p ? std::string(old_xdg_p) : std::string(); + const std::string xdg_root_s = xdg_root.string(); + setenv("XDG_STATE_HOME", xdg_root_s.c_str(), 1); const std::filesystem::path d1 = xdg_root / "p1"; const std::filesystem::path d2 = xdg_root / "p2"; @@ -227,8 +232,8 @@ TEST (SwapWriter_NoStomp_SameBasename) std::remove(swp2.c_str()); std::remove(f1.string().c_str()); std::remove(f2.string().c_str()); - if (old_xdg) { - setenv("XDG_STATE_HOME", old_xdg, 1); + if (!old_xdg.empty()) { + setenv("XDG_STATE_HOME", old_xdg.c_str(), 1); } else { unsetenv("XDG_STATE_HOME"); }