- 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.
6.7 KiB
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:
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 normallyC-g: cancel opening the file
-
If a swap file exists but is unreadable/corrupt, kte prompts:
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)y: delete the swap file (best-effort) and open the file normallyEnter(default): keep the swap file and open the file normallyC-g: cancel opening the file
Where swap files live
Swap files are stored under an XDG-style per-user state directory:
- If
XDG_STATE_HOMEis set and non-empty:$XDG_STATE_HOME/kte/swap/…
- Otherwise, if
HOMEis set:~/.local/state/kte/swap/…
- Last resort fallback:
<system-temp>/kte/state/kte/swap/…(viastd::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:
- Take a canonical-ish path key (
std::filesystem::weakly_canonical, elseabsolute, else the rawBuffer::Filename()). - Encode it so it’s human-identifiable:
- Strip one leading path separator (
/or\\) - Replace path separators (
/and\\) with! - Append
.swp
- Strip one leading path separator (
Example:
/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:
<basename>.<fnv1a64(path-key-as-hex)>.swp
For unnamed/unsaved buffers, kte uses:
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.Bufferemits swap events from its low-level edit APIs:Buffer::insert_text()callsSwapRecorder::OnInsert()Buffer::delete_text()callsSwapRecorder::OnDelete()Buffer::split_line()/join_lines()are represented as insert/delete of\n(they do not emitSPLIT/JOINrecords 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(default200ms) to write any queued records. fsync()is throttled to at most once perSwapConfig::fsync_interval_ms(default1000ms) 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:
- A fixed-size 64-byte header
- 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\0version(u32): currently1flags(u32): currently0created_time(u64): Unix seconds- remaining bytes are reserved/padding (currently zeroed)
Record framing
Each record is:
[type: u8][len: u24][payload: len bytes][crc32: u32]
lenis a 24-bit little-endian length of the payload (0..0xFFFFFF).crc32is 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): deletelenbytes 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:
[encver: u8]
Currently encver must be 1.
INS payload (encver = 1)
[encver: u8 = 1]
[row: u32]
[col: u32]
[nbytes:u32]
[bytes: nbytes]
DEL payload (encver = 1)
[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
falseand setserr. - 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)