29 KiB
Autosave + crash recovery — framing (Arc 3 phase 3)
Kill pmacs mid-edit and the unsaved work is gone. Autosave writes a
recovery copy of each modified file buffer on a configurable interval;
crash recovery notices that copy on next open and lets you restore
it. Emacs's auto-save-mode + recover-file.
Closes the persistence arc: phase 1 (PR #98) gave the pmacs.state
confined store and state.remove; phase 2 (PR #99) gave the all-Rust
pmacs.session.* precedent and get_or_load_buffer. Parent decision:
docs/persistence-framing.md Q#PS8.
Ground truth (scouted; file:line as of a0a4e7f)
- Timers advance while idle. Both run loops block on a frame
timeout, not on input, and fall through to
tick_asyncregardless (src/editor.rs:1619,1643-1646;src/daemon.rs:1273,1304,1312-1314). So a periodic Lua loop keeps running when nobody is typing. - But
workers.sleepparks a pool thread for its full duration (src/async_runtime.rs:724-733,1277-1290), and the pool is onlyavailable_parallelism - 1(:566-602). A 30-second sleep would hold a worker hostage.process.after-tick(builtin/hooks/default.lua:70, fired every frame fromsrc/editor.rs:411-420) pluspmacs.editor.monotonic_ms()(src/lua_bindings/mod.rs:10804) is the zero-thread alternative — already the LSP-debounce idiom (builtin/runtime/lsp.lua:299). - Lua cannot see a non-active buffer's path.
BufferIdLuaexposes:len() :name() :is_modified() :is_valid() :slice()and the mutators (src/lua_bindings/mod.rs:1156-1235) — no:file_path(). The only path getter ispmacs.editor.file_path()(active buffer,:10902). Contents are readable for any buffer (:sliceresolves by id). Rust has everything:registry.ids(),Buffer::file_path()(src/buffer.rs:263),is_modified()(:452), and the whole-buffer byte snapshotsave()already uses (src/editor_core.rs:1246-1250). FileMeta(src/file_io.rs:47-62,{mtime: SystemTime, size: u64},PartialEq) is not serde and not exposed to Lua.current_meta(path)exists (:66); each buffer stores its load/save meta (src/buffer.rs:194,274-280). Nothing compares them today —EditorCore::save()overwrites unconditionally (:1231-1268), so the external-change guard is new code.- No
buffer.before-loadseam — contents are installed, thenafter-loadfires. Substitution must happen after the fact viabuf:replace(0, buf:len(), bytes)(:1216-1234). after-loadfires once per restored leaf during desktop-restore (phase 2,src/desktop.rs). A modal prompt there would stack N prompts. There is also noy_or_n/confirm helper — only the callback-drivenpmacs.minibuffer.read(:11040).- Cleanup seams exist:
buffer.after-savehook (builtin/hooks/default.lua:57, active buffer), per-bufferpmacs.buffer.on_removed(id, fn)(:2695-2718— there is no global kill hook), andeditor.before-quitalready has two listeners (multiple listeners are fine). pmacs.state.remove(name)confirmed (:2073). Butstate::readreturnsString(read_to_string,src/state.rs) — recovery contents are arbitrary bytes, so aread_bytesis needed.- State files are not private.
state::writedoes a plaincreate_dir_all(src/state.rs:186, default0755), andsave_atomiconly preserves the mode of an existing target (src/file_io.rs:143-145) — a new file gets the umask default, typically0644. Autosave stores unsaved file contents, not metadata; world-readable recovery copies could be more exposed than the original file. - "New file" buffers have no origin meta and fire no hook. Opening a
missing path sets
file_pathbut leavesfile_metaunset andfire_after_load = false(src/editor.rs:512-519). And Lua'sfrom_file/find_or_openerror on a missing path (src/lua_bindings/mod.rs:2488,2551) — so a[new file]buffer only ever arrives via argvEditorState::open, which fires nothing. buf:replacedoes not firebuffer.after-edit. The mutators callnotify_buffer_edit_to_windows(windows + CRDT queue only,src/lua_bindings/mod.rs:1374-1385).after-editis fired solely bydispatch_key's post-command revision check (src/editor.rs:739) and the modal shadows that return before it (:887,:922). The minibuffer shadow is one of those — so an edit made inside anM-xcommand body is invisible to LSP/syntax.pmacs.hook.run(name)is public (src/lua_bindings/mod.rs:4962), the escape hatchbuiltin/commands/default.lua:226already uses.sha256_hexis duplicated privately twice (src/desktop.rs:144,src/packages/fetcher.rs:517). A third copy would be wrong.- There is no configuration system. No
pmacs.config, no options table, no defcustom registry;src/config.rsonly loadsinit.lua. The convention is an ad-hoc validated setter:pmacs.async_config.frame_target_ms(ms)(builtin/runtime/async.lua:458-466) andfs.watch{interval_ms}(builtin/runtime/fs.lua:237-241) both do getter-when-nil / type-check />= 1/math.floor.
Decisions
Q#AS1 — Hybrid: Rust owns the sweep + the guard; Lua owns cadence, config, UX
The same split phase 2 landed on, forced by the same two gaps: Lua can't
read a non-active buffer's path, and FileMeta is neither Lua-visible
nor serde. So:
- Rust (
src/autosave.rs):sweep()(walk the registry, write a recovery file per modified file buffer),status(path)(the external-change guard),recover_bytes(path),discard(path). - Lua (
builtin/runtime/autosave.lua): the timer, the interval and enable knobs, the after-load notification, therecover-file/discard-recoverycommands, and the save/kill/quit cleanup wiring.
Notably this needs no new Lua per-buffer path getter — the sweep
never leaves Rust, and every Lua cleanup seam (after-save,
on_removed, after-load) is either active-buffer or captures the path
at registration.
Q#AS2 — Cadence: process.after-tick + monotonic_ms, not workers.sleep
Three reasons, in order of weight:
- No worker thread is parked. A long
workers.sleepholds one ofavailable_parallelism - 1pool threads for the whole interval. - The interval becomes live-reconfigurable for free — the handler
re-reads it each tick, so
interval_ms(60000)takes effect immediately. A sleeping timer would ignore the change until it woke. - It matches the existing debounce idiom (
lsp.lua).
The handler is: bail if disabled; monotonic_ms(); if
now - last >= interval then last = now and sweep. Every frame this
costs one clock read and a compare. Wrapped in pcall —
process.after-tick is all-must-succeed, and a sweep error must not
poison the chain.
The sweep itself is synchronous on the main thread (a save_atomic
per dirty buffer). Bounded by Q#AS8's skip rules; offloading large-buffer
writes to a worker is deferred.
Q#AS3 — Configuration (the interval)
pmacs has no config registry, so this follows the established validated-setter convention rather than inventing one:
pmacs.autosave.interval_ms() -- getter → current value
pmacs.autosave.interval_ms(60000) -- setter, validated
pmacs.autosave.enable(false) -- disable knob
pmacs.autosave.sweep() -- force a sweep now (manual/test)
interval_ms(ms): returns the current value when ms is nil; otherwise
requires a number, rejects < MIN_INTERVAL_MS (1000, since each
sweep fsyncs), applies math.floor, and errors on anything else —
byte-for-byte the shape of frame_target_ms. Default 30_000 ms
(Emacs's auto-save-timeout). Changes apply on the next tick (Q#AS2).
Tests drive sweep() directly rather than waiting on a timer, so the
1-second floor never makes the suite slow.
Bigger picture, flagged not built: the absence of any config registry is itself a gap. Real configurability — typed, validated, introspectable, defaulted,
M-x customize-able options — is an arc of its own.interval_ms/enableare deliberately shaped as get-or-set-with-validation so they can be migrated into such a registry later without changing call sites. Not in this PR.
Q#AS4 — Recovery file: one atomic file, header line + raw bytes
Key: autosave/<sha256hex(absolute path)> (lowercase hex passes
state::validate_name's [A-Za-z0-9._-] charset).
One file, not a contents/sidecar pair. A pair is two writes: a crash between them leaves contents without meta (or vice versa). Instead a single atomic write of:
<one line of JSON>\n<raw buffer bytes>
The header is {version, path, origin: null | {mtime_secs, mtime_nanos, size}} — FileMeta hand-serialized, since it is not serde
(SystemTime → duration_since(UNIX_EPOCH)).
origin is nullable (finding). A [new file] buffer — a path that
does not exist on disk yet — has no file_meta, and its unsaved
contents are exactly the work most worth recovering. Requiring an origin
meta would have silently excluded it. origin: null means "there was no
file on disk when this was autosaved."
Contents may contain newlines and non-UTF-8 bytes; the reader splits at
the first \n only. This needs a Rust-only state::read_bytes
(today's state::read is read_to_string, which would fail on non-UTF-8
buffer contents).
Q#AS5 — Recovery status: the external-change guard
status(path) reads the envelope and compares header.origin against
current_meta(path):
header.origin |
on disk now | status | meaning |
|---|---|---|---|
Some(m) |
exists, meta == m |
Fresh |
disk untouched; recovery is newer |
Some(m) |
exists, meta != m |
Stale |
file changed externally |
Some(m) |
missing | Stale |
the base file was deleted |
None (new file) |
missing | Fresh |
still a new file; nothing to conflict with |
None (new file) |
exists | Stale |
someone created the file meanwhile |
| unparseable / bad version | — | Corrupt |
never offered; discardable |
| no file | — | None |
Only Fresh is announced (Q#AS6). Stale is never auto-offered
— silently clobbering a file someone else changed is the one
unrecoverable mistake here; recover-file will still recover it, but
says so plainly and requires confirmation. Corrupt is a typed
status, not an error (finding): a malformed envelope must not make
startup noisy or break the commands. It is counted separately, never
offered, and discard-recovery removes it.
Q#AS6 — Notify (aggregated, on the tick), don't prompt
Recovery must not open a modal minibuffer prompt from after-load:
desktop-restore fires after-load once per restored leaf (phase 2), so a
prompt would stack N modals mid-restore — and no y_or_n helper exists
to build one cleanly anyway.
But a per-after-load status message is also wrong (finding): N
restored leaves would each overwrite core.status, so only the last
recoverable file is ever mentioned. And it would miss [new file]
buffers entirely, which fire no hook at all (ground truth).
So the report is pull-based and aggregated on the tick we already own (Q#AS2):
- Rust
pmacs.autosave.pending()→ for every open file buffer, thestatus(path); returns theFreshpaths (and aCorruptcount). Enumerating buffers in Rust is what makes this cover argv[new file]buffers and desktop-restored buffers uniformly, with no hook at all. - Lua sets a
needs_reportflag on module load (the startup scan) and onbuffer.after-load(runtime opens). The tick handler — not the hook — does the reporting: if flagged, callpending()once, emit a single aggregate status, clear the flag. N synchronousafter-loadfires during a restore therefore collapse into one message: "3 files have autosave recovery — M-x recover-file" (or the filename when there is exactly one). pending()runs onestatper open file buffer, only when flagged — never per frame.
Recovery itself happens through an explicit command:
recover-file— confirms viaminibuffer.read(typedyes), pins to the origin buffer handle, not merely its path (finding:pmacs.buffer.from_filedoes not dedup, so two buffers can visit one path and a path check alone could recover into the wrong one), thenbuf:replace(0, buf:len(), recovery_bytes), then explicitlypmacs.hook.run("buffer.after-edit"). That last step is load-bearing (finding): the mutators only notify windows and queue CRDT, andafter-editis fired bydispatch_key's post-command check — which the minibuffer shadow returns before. Without the explicit fire, LSPdidChangeand the syntax reparse would never see the recovered contents. (Both read the active buffer, which is exactly the onerecover-fileoperates on.) The replace leaves the buffer modified — the user must save to accept, which is what deletes the recovery file (Q#AS7).discard-recovery— delete the recovery file for the active file, whatever its status (includingCorrupt).
This also sidesteps re-entrancy: no modal surface is opened from inside a hook fired by Rust.
Q#AS12 — Never overwrite unclaimed crash data (the ownership rule)
The failure this closes (finding): you crash with unsaved work, reopen
the file, and start editing before running recover-file. The next
sweep writes the current buffer to the same key — destroying the crash
copy, which is precisely what autosave exists to protect.
So autosave tracks ownership. A per-session owned set records which
path hashes this session wrote or adopted. A recovery file at a key we
do not own is unclaimed crash data, and the rule is total:
Exactly two things may release an unclaimed recovery file:
recover-file(which adopts it) anddiscard-recovery(explicit user intent). Nothing else — not a sweep, not a save, not a kill.
Concretely:
- the sweep refuses to write that buffer, counts it
blocked, and surfaces "autosave paused for N file(s) with unclaimed recovery — M-x recover-file or M-x discard-recovery"; saveandkilldelete only keys this session owns (finding). You reopen a crashed file, edit, and save without recovering: the on-disk file now holds your new work, but the crash copy still holds work that was never written anywhere. Deleting it would be the same data loss by a different door. It survives — asStale, so it is never auto-offered, but it is still there to recover or discard.recover-fileadopts by buffer, not by path (finding). Adopt records awrittenentry for thatBufferIdat the revision whose contents the file now holds. That makes the skip cache correct and lets a later kill retire the copy — a removal callback fires after the buffer has left the registry, when there is no path left to read.discard-recoveryclears the matchingwrittenentries too (finding), so a still-dirty buffer is re-protected on the very next sweep instead of hitting the unchanged-(path_hash, revision)fast path and going unprotected until its next edit.
The trade is deliberate: while blocked, edits made after the reopen are not autosaved — and the user is told so, every sweep. Losing the new edits to a second crash is recoverable by retyping; losing the original crash copy is not.
Q#AS13 — One buffer owns a path's recovery slot
pmacs.buffer.from_file does not dedup: a second buffer can visit an
already-open path. The recovery file must stay keyed by path — a later
session knows only paths, never old BufferIds — so two dirty duplicates
cannot both be protected under one key. The naive behavior (finding) is
the worst one: both write to the same key, the later write wins on disk,
and both buffers are recorded as protected, so the loser silently skips
future sweeps while its contents are unrecoverable. Either buffer's
save/kill could also retire the other's copy.
So ownership is path_hash → BufferId, not a path-wide set:
- the first modified buffer to reach a free slot claims it (including
within a single sweep pass — the write loop updates
owner, so the gather loop tracks slots queued this pass); - any other buffer on that path is counted
conflictedand reported — "autosave paused for N buffer(s): another buffer is visiting the same file" — never silently mis-protected. It records nowrittenentry, so it re-attempts each sweep rather than believing itself saved; discard_buffer(save/kill) retires only slots this buffer owns, so a duplicate cannot delete the owner's recovery;- when the owner is saved or killed, the slot is released and the duplicate claims it on the next sweep;
recover-fileadopting into a buffer makes that buffer the owner — the file's contents are now its contents, and the previous owner truthfully becomes conflicted.
This is honest rather than clever: pmacs cannot protect two divergent buffers over one file, and says so.
The bookkeeping invariant that makes it safe is
written[id] ⟹ owner[hash] == id: a skip-cache entry only ever names
a slot its buffer owns. adopt is the one operation that transfers a
slot, so it drops the previous owner's entry (finding). Without that:
adopt into B, then kill B without saving — discard_buffer frees the slot
and deletes the file, but A's stale written[A] = (hash, revA) survives,
so the next sweep sees A dirty at an unchanged revision, calls it
"unchanged since its last copy", and leaves it unprotected until its
next edit.
Q#AS14 — A failing sweep is loud
write_private can fail: a full disk, a permission change, a clobbered
state dir. Swallowing that (pcall(...) and drop the error, finding)
is the worst possible behavior for a data-protection feature — the user
keeps working, believing their edits are being captured, while nothing is
written.
So both the tick and the before-quit sweep go through a reporting
wrapper: the status line says "autosave FAILED: … — your work is NOT
being protected" on every failing sweep, and each distinct fault is
logged once via pmacs.error. The quit path reports too — a failure
there means the quit is about to discard work that was never written
anywhere — and still never vetoes.
Q#AS7 — Cleanup lifecycle (keyed by buffer, not by a captured path)
buffer.after-save→discard_buffer(active buffer).- Buffer killed →
discard_buffer(id). There is no global kill hook, soafter-loadregisters a per-bufferpmacs.buffer.on_removed. - Both go through
discard_buffer(BufferId), not a path captured at load time (finding). It removes both the buffer's current-path key and the key its last sweep actually wrote under — which differ after a rename (an LSPWorkspaceEditchanges the path while theBufferIdstays). A path-captured callback would delete the wrong key and leave the real recovery file behind. - Sweep-time GC is the backstop: any cache entry whose
BufferIdhas left the registry has its recovery file deleted. This is what covers argv[new file]buffers, which fire noafter-loadand so never get a removal callback registered (finding). editor.before-quit→ one final synchronous sweep, then return nil (never veto). Async ticks stop after quit, so this must be a direct call. Result: quitting with unsaved changes leaves a recovery copy that the next open notices — which is exactly the point.- Recovery files for buffers never reopened linger. Orphan GC is deferred.
Q#AS8 — What gets swept, and the cost bound
Only buffers with file_path().is_some() && is_modified() — which
includes [new file] buffers (path set, no origin meta, Q#AS4).
Scratch and *special* buffers are skipped (deferred). Two skips keep
the main-thread cost down:
- If no buffer qualifies, the sweep returns immediately (no IO).
- Skip a buffer whose contents are unchanged since its last successful
autosave. Without this, a 30-second interval re-
fsyncs an idle-but-dirty buffer forever.
The skip cache is keyed BufferId → (path_hash, revision), not
BufferId → revision (finding). A buffer keeps its BufferId across a
path change (LSP WorkspaceEdit rename calls set_buffer_path), so a
revision-only cache would skip the write, never create the recovery file
under the new key, and orphan the old one. On sweep, a path_hash
mismatch counts as changed: write the new key and discard the old
one.
Q#AS9 — Extract the duplicated sha256_hex
Two private copies exist (desktop.rs, packages/fetcher.rs); autosave
needs a third. Instead extract one pub(crate) fn sha256_hex into a
small src/hash.rs and point all three at it. In-scope cleanup, not a
drive-by: the alternative is knowingly adding the third copy.
Q#AS11 — Private storage (a precondition for default-on)
Autosave stores unsaved file contents — a different class of secret
from saveplace's cursor offsets or recentf's path list. Today
state::write creates parents with a default 0755 and save_atomic
gives a new file the umask default (typically 0644), preserving mode
only for an already-existing target. A recovery copy of an unsaved edit
to a 0600 file would land world-readable — more exposed than the
original (finding).
So this PR makes autosave storage private:
file_io::save_atomic_with_mode(path, content, mode)— sets the temp file's permissions before the rename, so the target is never momentarily visible at0644. (A chmod-after-write leaves exactly that window.) Plainsave_atomicdelegates withNone.state::write_private(base, name, content)— creates the parent withDirBuilder::mode(0o700)and writes the file0600. It also tightens a pre-existing laxautosave/to0700(finding): the birth-mode only applies to directories that call creates, so a0755directory left by an older run would still leak recovery-file names, sizes, and mtimes despite0600contents. It never re-modesbaseitself — the state root is shared with history/recentf/desktop and may predate us.- Recovery files use it; the
autosave/directory is0700. - Unix-only (
PermissionsExt/DirBuilderExtare safe under#![forbid(unsafe_code)]); on other platforms it degrades to today's behavior, documented.
Retention and the disable knob are documented in the same breath: files
live under $XDG_STATE_HOME/pmacs/autosave/, are deleted on save/kill,
survive a crash or an unsaved quit, and pmacs.autosave.enable(false)
stops all of it. (Hardening the whole state-dir root to 0700 is an
obvious neighbour — noted as deferred, since it would re-mode a directory
users already have.)
Q#AS10 — Default on, conditional on Q#AS11
On by default, with the interval configurable, a disable knob, and only because Q#AS11 lands in the same PR. If private storage slips, this drops to opt-in.
The parent framing (Q#PS9) tentatively said opt-in, grouping autosave with desktop-save because "background writes are surprising." That grouping conflated two things: desktop-save is opt-in because auto-restore changes what you see at startup. Autosave changes nothing observable until the day it saves your work; it writes only into the state dir (never your files), it is inert when nothing is modified, and it is the highest-value safety net in the arc. Emacs ships it on; saveplace and recentf are already default-on and also write.
The reviewer's condition is the right bar and is now the plan of record:
default-on requires 0700/0600 storage plus documented retention
and disable. Both are in scope.
Phasing
One PR (the pieces are useless apart). In-diff order: src/hash.rs +
state::read_bytes + save_atomic_with_mode/state::write_private
(Q#AS11) → src/autosave.rs (envelope, sweep, status, recover, discard,
pending) → pmacs.autosave.* bindings → autosave.lua (timer, config,
commands, cleanup wiring) → tests.
Bets (score at close)
after-tick+monotonic_msis the right substrate — no worker thread parked, interval live-reconfigurable, no measurable per-frame cost.- The one-file envelope is crash-atomic — a mode-aware
save_atomicmeans a recovery file is never a torn header/contents pair nor briefly world-readable, and the first-newline split survives arbitrary binary contents. - The nullable-origin guard covers new files —
[new file]buffers round-trip, and theFresh/Staletable never offers to clobber an externally-changed (or externally-created) file. - Pull-based aggregated notify is sufficient UX — one message
however many files are recoverable, it covers argv
[new file]buffers that fire no hook, and desktop-restore stays clean.
Deferred (named)
- Idle-gated autosave (Emacs's
auto-save-timeoutidle semantics); v1 is plain wall-clock elapsed. - Autosaving non-file (scratch) buffers.
- Orphan recovery-file GC / a
list-recovery-filesbrowser. - Offloading large-buffer writes to a worker thread.
- An external-change guard on
save()itself — the scout found pmacs overwrites unconditionally today. Real bug, adjacent, its own PR. - A general
y_or_nminibuffer helper (build it when a second caller appears). - A central, typed config registry (Q#AS3's note).
- Hardening the whole
$XDG_STATE_HOME/pmacs/root to0700(Q#AS11) — it would re-mode a directory users already have. - Firing
buffer.after-loadfor[new file]buffers. Today argv-opening a missing path fires no hook at all, so a new file gets no syntax, no LSP, and no saveplace. That is a real latent gap, but changing it ripples through four builtins and does not belong in an autosave PR. - Hidden-buffer LSP initial attach (carried from phase 2).
Acceptance (tempdir state root injected; sweep() called directly)
- Modify a file buffer →
sweep()→ recovery file exists; its header path + origin meta match, its bytes equal the buffer. - Non-UTF-8 contents round-trip through the envelope (the
read_bytesreason). [new file]buffer (path that does not exist): swept, headerorigin: null, statusFreshwhile the file is still absent; andStaleonce the file exists on disk. Recoverable either way.- Permissions (Q#AS11): on Unix, the
autosave/dir is0700and each recovery file is0600— asserted, not assumed. - Sweep skips clean buffers, scratch buffers, and buffers unchanged since the last sweep (no second write).
- Unclaimed crash data is never overwritten (Q#AS12): session 1
crashes with a recovery copy; session 2 reopens, edits, sweeps →
(written, blocked) == (0, 1)and the crash copy is byte-identical._adopt(whatrecover-filecalls) or_discardresumes the sweep. - …nor deleted by a save or a kill: session 2 reopens, edits, and
saves (or kills) without recovering → the crash copy survives
byte-identical, now reported
Stale. - Recover then kill immediately (before any save or sweep) → the adopted copy is retired, not left to be re-offered.
- Explicit
discard-recoveryon a still-dirty buffer → the next sweep re-protects it at once, with no intervening edit. - Two dirty buffers on one path (Q#AS13) →
(written, blocked, conflicted) == (1, 0, 1); the owner's copy is on disk; the duplicate never wins the slot by editing, its save never retires the owner's copy, and killing the owner frees the slot for it. - Adopt transfers the slot cleanly: A owns, B adopts, B is killed
unsaved → the freed slot lets the next sweep re-protect the still-dirty
A with no intervening edit (the
written ⟹ ownerinvariant). - A failing sweep is reported (Q#AS14): with
autosave/unwritable,sweep()raises rather than returning0, andbefore-quitsurfaces "autosave FAILED … NOT being protected" while still not vetoing quit. - Path change: rename a buffer's path (
set_buffer_path) without editing it → next sweep writes the new key and removes the old recovery file (the(path_hash, revision)cache). after-save→ recovery deleted. Kill buffer → recovery deleted.- Rename then save with no intervening sweep → the recovery written under the old key is removed (buffer-keyed cleanup, Q#AS7).
- Killing a
[new file]buffer → the sweep-time GC removes its recovery (noafter-loadfired, so no removal callback exists). - A pre-existing
0755autosave/dir is tightened to0700. - Open a file with a
Freshrecovery → the aggregate report names it; buffer contents are still the on-disk ones (no silent substitution). - Aggregation: three recoverable files opened → one status
message reporting
3, not three messages. - Touch the file on disk, then open →
Stale: distinct message, not offered. Corrupt: a malformed envelope (no newline / bad JSON / bad version) yieldsCorrupt, is never offered, does not error the report or the commands, anddiscard-recoveryremoves it.recover-file→ buffer contents become the recovery bytes, the buffer isis_modified(), and a probe onbuffer.after-editobserves the recovery (the explicithook.run); a subsequent save deletes the recovery file.interval_ms()getter/setter: rejects non-numbers and< 1000, floors floats, and a changed interval takes effect without a restart.enable(false)→sweep()is a no-op.before-quitsweeps once, synchronously, and does not veto quit.