pmacs/docs/autosave-recovery-framing.md

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_async regardless (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.sleep parks a pool thread for its full duration (src/async_runtime.rs:724-733,1277-1290), and the pool is only available_parallelism - 1 (:566-602). A 30-second sleep would hold a worker hostage. process.after-tick (builtin/hooks/default.lua:70, fired every frame from src/editor.rs:411-420) plus pmacs.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. BufferIdLua exposes :len() :name() :is_modified() :is_valid() :slice() and the mutators (src/lua_bindings/mod.rs:1156-1235) — no :file_path(). The only path getter is pmacs.editor.file_path() (active buffer, :10902). Contents are readable for any buffer (:slice resolves by id). Rust has everything: registry.ids(), Buffer::file_path() (src/buffer.rs:263), is_modified() (:452), and the whole-buffer byte snapshot save() 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 todayEditorCore::save() overwrites unconditionally (:1231-1268), so the external-change guard is new code.
  • No buffer.before-load seam — contents are installed, then after-load fires. Substitution must happen after the fact via buf:replace(0, buf:len(), bytes) (:1216-1234).
  • after-load fires once per restored leaf during desktop-restore (phase 2, src/desktop.rs). A modal prompt there would stack N prompts. There is also no y_or_n/confirm helper — only the callback-driven pmacs.minibuffer.read (:11040).
  • Cleanup seams exist: buffer.after-save hook (builtin/hooks/default.lua:57, active buffer), per-buffer pmacs.buffer.on_removed(id, fn) (:2695-2718 — there is no global kill hook), and editor.before-quit already has two listeners (multiple listeners are fine).
  • pmacs.state.remove(name) confirmed (:2073). But state::read returns String (read_to_string, src/state.rs) — recovery contents are arbitrary bytes, so a read_bytes is needed.
  • State files are not private. state::write does a plain create_dir_all (src/state.rs:186, default 0755), and save_atomic only preserves the mode of an existing target (src/file_io.rs:143-145) — a new file gets the umask default, typically 0644. 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_path but leaves file_meta unset and fire_after_load = false (src/editor.rs:512-519). And Lua's from_file/find_or_open error on a missing path (src/lua_bindings/mod.rs:2488,2551) — so a [new file] buffer only ever arrives via argv EditorState::open, which fires nothing.
  • buf:replace does not fire buffer.after-edit. The mutators call notify_buffer_edit_to_windows (windows + CRDT queue only, src/lua_bindings/mod.rs:1374-1385). after-edit is fired solely by dispatch_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 an M-x command body is invisible to LSP/syntax. pmacs.hook.run(name) is public (src/lua_bindings/mod.rs:4962), the escape hatch builtin/commands/default.lua:226 already uses.
  • sha256_hex is 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.rs only loads init.lua. The convention is an ad-hoc validated setter: pmacs.async_config.frame_target_ms(ms) (builtin/runtime/async.lua:458-466) and fs.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, the recover-file / discard-recovery commands, 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:

  1. No worker thread is parked. A long workers.sleep holds one of available_parallelism - 1 pool threads for the whole interval.
  2. 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.
  3. 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 pcallprocess.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/enable are 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 (SystemTimeduration_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, the status(path); returns the Fresh paths (and a Corrupt count). 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_report flag on module load (the startup scan) and on buffer.after-load (runtime opens). The tick handler — not the hook — does the reporting: if flagged, call pending() once, emit a single aggregate status, clear the flag. N synchronous after-load fires 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 one stat per open file buffer, only when flagged — never per frame.

Recovery itself happens through an explicit command:

  • recover-file — confirms via minibuffer.read (typed yes), pins to the origin buffer handle, not merely its path (finding: pmacs.buffer.from_file does not dedup, so two buffers can visit one path and a path check alone could recover into the wrong one), then buf:replace(0, buf:len(), recovery_bytes), then explicitly pmacs.hook.run("buffer.after-edit"). That last step is load-bearing (finding): the mutators only notify windows and queue CRDT, and after-edit is fired by dispatch_key's post-command check — which the minibuffer shadow returns before. Without the explicit fire, LSP didChange and the syntax reparse would never see the recovered contents. (Both read the active buffer, which is exactly the one recover-file operates 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 (including Corrupt).

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) and discard-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";
  • save and kill delete 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 — as Stale, so it is never auto-offered, but it is still there to recover or discard.
  • recover-file adopts by buffer, not by path (finding). Adopt records a written entry for that BufferId at 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-recovery clears the matching written entries 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 conflicted and reported — "autosave paused for N buffer(s): another buffer is visiting the same file" — never silently mis-protected. It records no written entry, 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-file adopting 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-savediscard_buffer(active buffer).
  • Buffer killeddiscard_buffer(id). There is no global kill hook, so after-load registers a per-buffer pmacs.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 LSP WorkspaceEdit changes the path while the BufferId stays). 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 BufferId has left the registry has its recovery file deleted. This is what covers argv [new file] buffers, which fire no after-load and 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:

  1. If no buffer qualifies, the sweep returns immediately (no IO).
  2. 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 at 0644. (A chmod-after-write leaves exactly that window.) Plain save_atomic delegates with None.
  • state::write_private(base, name, content) — creates the parent with DirBuilder::mode(0o700) and writes the file 0600. It also tightens a pre-existing lax autosave/ to 0700 (finding): the birth-mode only applies to directories that call creates, so a 0755 directory left by an older run would still leak recovery-file names, sizes, and mtimes despite 0600 contents. It never re-modes base itself — the state root is shared with history/recentf/desktop and may predate us.
  • Recovery files use it; the autosave/ directory is 0700.
  • Unix-only (PermissionsExt / DirBuilderExt are 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)

  1. after-tick + monotonic_ms is the right substrate — no worker thread parked, interval live-reconfigurable, no measurable per-frame cost.
  2. The one-file envelope is crash-atomic — a mode-aware save_atomic means a recovery file is never a torn header/contents pair nor briefly world-readable, and the first-newline split survives arbitrary binary contents.
  3. The nullable-origin guard covers new files[new file] buffers round-trip, and the Fresh/Stale table never offers to clobber an externally-changed (or externally-created) file.
  4. 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-timeout idle semantics); v1 is plain wall-clock elapsed.
  • Autosaving non-file (scratch) buffers.
  • Orphan recovery-file GC / a list-recovery-files browser.
  • 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_n minibuffer 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 to 0700 (Q#AS11) — it would re-mode a directory users already have.
  • Firing buffer.after-load for [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_bytes reason).
  • [new file] buffer (path that does not exist): swept, header origin: null, status Fresh while the file is still absent; and Stale once the file exists on disk. Recoverable either way.
  • Permissions (Q#AS11): on Unix, the autosave/ dir is 0700 and each recovery file is 0600 — 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 (what recover-file calls) or _discard resumes 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-recovery on 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 ⟹ owner invariant).
  • A failing sweep is reported (Q#AS14): with autosave/ unwritable, sweep() raises rather than returning 0, and before-quit surfaces "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 (no after-load fired, so no removal callback exists).
  • A pre-existing 0755 autosave/ dir is tightened to 0700.
  • Open a file with a Fresh recovery → 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) yields Corrupt, is never offered, does not error the report or the commands, and discard-recovery removes it.
  • recover-file → buffer contents become the recovery bytes, the buffer is is_modified(), and a probe on buffer.after-edit observes the recovery (the explicit hook.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-quit sweeps once, synchronously, and does not veto quit.