Addresses the PR #100 review round 2. Q#AS12's ownership rule guarded the
sweep but not the RELEASE paths, so three doors were still open.
The rule is now total: exactly two things may release an unclaimed
recovery file --- recover-file (which adopts it) and discard-recovery
(explicit user intent). Not a sweep, not a save, not a kill.
- HIGH: buffer.after-save called _discard_buffer unconditionally, which
removed the live buffer's current-path key without checking ownership.
Repro: session 1 autosaves and crashes; session 2 opens the file, does
not recover, then saves --- the crash artifact was deleted. Same door
was open on kill. discard_buffer now removes ONLY keys this session
owns. The unclaimed copy survives (reported Stale, so never
auto-offered, but still recoverable/discardable). The on-disk file holds
the new work; the crash copy holds work never written anywhere, so
deleting it was the same data loss by a different door.
- MEDIUM/LOW: _adopt only recorded the path in `owned`, not an
association with the buffer. A removal callback fires after the buffer
has left the registry, so discard_buffer had no path to read and no
`written` entry to fall back on --- recover-then-kill leaked the copy
and it was offered again. adopt now takes the BUFFER and records a
`written` entry at the revision whose contents the file holds. That is
correct twice over: the skip cache declines to rewrite an identical
copy, and a kill can find and retire it.
- LOW: _discard(path) removed the file and unowned the hash but left
matching `written` entries, so a still-dirty buffer hit the unchanged
(path_hash, revision) fast path and went unprotected until its next
edit. discard_path now clears those entries; the next sweep re-protects
immediately.
Tests (autosave_acceptance now 24):
saving_without_recovering_preserves_unclaimed_crash_data,
killing_without_recovering_preserves_unclaimed_crash_data,
recover_then_kill_retires_the_adopted_recovery,
discard_recovery_lets_the_next_sweep_reprotect_immediately.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 24;
desktop 11; persistence 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review.
- HIGH data loss: sweep could overwrite an existing crash recovery before
the user ran recover-file. Reopen a file after a crash, edit it, and the
next autosave wrote the current buffer over the recovery key --- losing
exactly what autosave exists to protect. New ownership rule (Q#AS12): 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;
the sweep refuses to write that buffer, counts it `blocked`, and says so
("autosave paused for N file(s) with unclaimed recovery"). recover-file
ADOPTS the copy once its contents are in the buffer; discard-recovery
removes it. Either resumes normal autosave. sweep() now returns
(written, blocked).
- MEDIUM cleanup missed paths autosave can write. Kill/save cleanup now
goes through `discard_buffer(BufferId)`, which removes BOTH the buffer's
current-path key and the key its last sweep actually wrote (they differ
after a rename --- an LSP WorkspaceEdit changes the path while the
BufferId stays; a path-captured callback deleted the wrong key). And a
sweep-time GC deletes the recovery of any buffer that left the registry,
which is the backstop for argv `[new file]` buffers: they fire no
after-load, so no removal callback is ever registered for them.
- LOW/MEDIUM recover-file pinned only on the active path. Two buffers can
visit one path (pmacs.buffer.from_file does not dedup), so focus drift
could recover into the wrong buffer. It now captures and compares the
origin buffer handle as well as the path.
- LOW write_private left a pre-existing lax autosave/ directory alone. The
birth-mode only applies to dirs that call creates, so a 0755 autosave/
from an older run still leaked recovery-file names, sizes, and mtimes
despite 0600 contents. It is now tightened to 0700 --- but never `base`
itself, which is shared with history/recentf/desktop and may predate us.
New `state::exists` (an existence check, no read) backs the ownership
gate.
Tests (autosave_acceptance now 20): sweep_never_overwrites_unclaimed_
crash_recovery (blocked, crash copy byte-identical, adopt resumes),
discarding_an_unclaimed_recovery_unblocks_the_sweep,
killing_a_new_file_buffer_gcs_its_recovery,
saving_after_a_rename_removes_the_recovery_written_under_the_old_path,
a_pre_existing_lax_autosave_dir_is_tightened.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 20;
desktop 11; persistence 5; m4 90; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.
Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.
src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
buffer bytes. Split at the FIRST newline, so contents may hold newlines
and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
disk) has no FileMeta, and its unsaved contents are exactly the work
most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
Stale (file changed, deleted, or created underneath us) is never
auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
unchanged since their last copy. The skip cache is keyed
BufferId -> (path_hash, revision), not revision alone: a buffer keeps
its BufferId across a path change (LSP WorkspaceEdit rename), so a
revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
covers argv `[new file]` buffers -- they fire no hook at all.
Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).
builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
long sleep parks one of only `available_parallelism - 1` pool threads,
and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
the async_config.frame_target_ms shape. Default 30000, floor 1000.
pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
ONE aggregate message ("3 files have autosave recovery"). A modal
prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
then explicitly fires `buffer.after-edit` -- the mutators only notify
windows and queue CRDT, and after-edit comes from dispatch_key's
post-command check, which the minibuffer shadow returns before. Without
the explicit fire, LSP didChange and the syntax reparse never see the
recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
(there is no global kill hook); before-quit does one final synchronous
sweep and never vetoes.
src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).
Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.
Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>