Merge pull request #100 from levineuwirth/session-persistence-p3-autosave
feat(persistence): autosave + crash recovery (Arc 3 phase 3)
This commit is contained in:
commit
1cf60d8199
|
|
@ -0,0 +1,252 @@
|
|||
-- autosave.lua --- periodic recovery copies + crash recovery (Arc 3 phase 3).
|
||||
--
|
||||
-- Every `interval_ms` a sweep writes a private recovery copy of each
|
||||
-- modified file buffer. If pmacs dies, the next session notices the copy
|
||||
-- and says so; `M-x recover-file` installs it. Emacs's `auto-save-mode`
|
||||
-- + `recover-file`.
|
||||
--
|
||||
-- The Rust half (`pmacs.autosave._*`) does the sweep and the
|
||||
-- external-change guard; this file owns the cadence, the configurable
|
||||
-- interval, and the UX.
|
||||
--
|
||||
-- On by default. Configure from init.lua:
|
||||
-- pmacs.autosave.interval_ms(60000) -- default 30000, floor 1000
|
||||
-- pmacs.autosave.enable(false) -- turn it off entirely
|
||||
--
|
||||
-- Recovery files live under `$XDG_STATE_HOME/pmacs/autosave/` (0700 dir,
|
||||
-- 0600 files), are deleted when the buffer is saved or killed, and
|
||||
-- survive a crash or a quit with unsaved changes.
|
||||
--
|
||||
-- Framing: docs/autosave-recovery-framing.md.
|
||||
|
||||
pmacs.autosave = pmacs.autosave or {}
|
||||
|
||||
local DEFAULT_INTERVAL_MS = 30000 -- Emacs's auto-save-timeout
|
||||
local MIN_INTERVAL_MS = 1000 -- each sweep fsyncs; don't storm
|
||||
|
||||
local interval = DEFAULT_INTERVAL_MS
|
||||
local enabled = true
|
||||
local last_sweep_ms = nil
|
||||
-- Report on the first tick (the startup scan), and after every load.
|
||||
local needs_report = true
|
||||
|
||||
-- enable(on) --- turn autosave off (or back on).
|
||||
function pmacs.autosave.enable(on)
|
||||
enabled = (on ~= false)
|
||||
return enabled
|
||||
end
|
||||
|
||||
-- interval_ms([ms]) --- getter when `ms` is nil, else a validated setter.
|
||||
-- Shape follows `pmacs.async_config.frame_target_ms`. The tick re-reads
|
||||
-- this every frame, so a change takes effect immediately -- no restart.
|
||||
function pmacs.autosave.interval_ms(ms)
|
||||
if ms == nil then return interval end
|
||||
if type(ms) ~= "number" or ms ~= ms or ms < MIN_INTERVAL_MS then
|
||||
error("pmacs.autosave.interval_ms: expected a number >= " .. MIN_INTERVAL_MS)
|
||||
end
|
||||
interval = math.floor(ms)
|
||||
return interval
|
||||
end
|
||||
|
||||
-- sweep() --- force a pass now. Returns (written, blocked, conflicted).
|
||||
-- blocked --- an unclaimed crash recovery sits at the buffer's key.
|
||||
-- Overwriting it would destroy exactly what autosave
|
||||
-- protects; recovering or discarding it resumes autosave.
|
||||
-- conflicted --- another buffer already owns that file's recovery slot.
|
||||
-- Recovery files are keyed by path, so when two buffers
|
||||
-- visit one file only the first can be protected.
|
||||
function pmacs.autosave.sweep()
|
||||
if not enabled then return 0, 0, 0 end
|
||||
local written, blocked, conflicted = pmacs.autosave._sweep()
|
||||
if blocked and blocked > 0 then
|
||||
pmacs.editor.set_status(
|
||||
"autosave paused for " .. blocked .. " file(s) with unclaimed recovery"
|
||||
.. " --- M-x recover-file or M-x discard-recovery")
|
||||
elseif conflicted and conflicted > 0 then
|
||||
pmacs.editor.set_status(
|
||||
"autosave paused for " .. conflicted .. " buffer(s): another buffer"
|
||||
.. " is visiting the same file")
|
||||
end
|
||||
return written, blocked, conflicted
|
||||
end
|
||||
|
||||
local function basename(path)
|
||||
return path:match("[^/]+$") or path
|
||||
end
|
||||
|
||||
-- The last sweep failure we reported, so a persistent fault (ENOSPC, a
|
||||
-- read-only state dir) logs once but keeps warning in the status line.
|
||||
local last_error = nil
|
||||
|
||||
-- Run a sweep, surfacing any failure. `write_private` can fail --- a full
|
||||
-- disk, a permission change, a clobbered state dir --- and this is a
|
||||
-- data-protection feature: silently swallowing the error would leave the
|
||||
-- user believing their work is safe when nothing is being written.
|
||||
local function sweep_reporting()
|
||||
local ok, err = pcall(pmacs.autosave.sweep)
|
||||
if ok then
|
||||
last_error = nil
|
||||
return
|
||||
end
|
||||
local msg = "autosave FAILED: " .. tostring(err)
|
||||
pmacs.editor.set_status(msg .. " --- your work is NOT being protected")
|
||||
-- Log once per distinct fault; the status line keeps nagging every sweep.
|
||||
if msg ~= last_error then
|
||||
last_error = msg
|
||||
if pmacs.error then pcall(pmacs.error, msg) end
|
||||
end
|
||||
end
|
||||
|
||||
-- One aggregate message however many files are recoverable. N synchronous
|
||||
-- `after-load` fires (a desktop restore) collapse into a single report.
|
||||
local function report_pending()
|
||||
local fresh = pmacs.autosave._pending()
|
||||
local n = #fresh
|
||||
if n == 1 then
|
||||
pmacs.editor.set_status(basename(fresh[1]) .. " has autosave recovery --- M-x recover-file")
|
||||
elseif n > 1 then
|
||||
pmacs.editor.set_status(n .. " files have autosave recovery --- M-x recover-file")
|
||||
end
|
||||
-- Corrupt copies are counted but stay quiet: a malformed recovery file
|
||||
-- must not make startup noisy. `M-x discard-recovery` removes them.
|
||||
end
|
||||
|
||||
-- The cadence (Q#AS2). `process.after-tick` fires every frame -- and the
|
||||
-- run loops tick on a frame *timeout*, not only on input, so this keeps
|
||||
-- running while the editor is idle. Costs one clock read + a compare per
|
||||
-- frame, and parks no worker thread (a long `workers.sleep` would hold
|
||||
-- one of only `available_parallelism - 1` pool threads).
|
||||
pmacs.hook.add("process.after-tick", function()
|
||||
if needs_report then
|
||||
needs_report = false
|
||||
pcall(report_pending)
|
||||
end
|
||||
if not enabled then return end
|
||||
local now = pmacs.editor.monotonic_ms()
|
||||
if last_sweep_ms == nil then
|
||||
last_sweep_ms = now
|
||||
return
|
||||
end
|
||||
if now - last_sweep_ms >= interval then
|
||||
last_sweep_ms = now
|
||||
sweep_reporting()
|
||||
end
|
||||
end)
|
||||
|
||||
-- A load may reveal a recovery file; report on the next tick (never from
|
||||
-- inside the hook -- a desktop restore fires this once per leaf).
|
||||
pmacs.hook.add("buffer.after-load", function()
|
||||
needs_report = true
|
||||
-- A kill retires the recovery copy. There is no global kill hook, so
|
||||
-- register per buffer. `_discard_buffer` is keyed by BufferId, not by a
|
||||
-- path captured here: after a rename the buffer's recovery lives under
|
||||
-- a different key than the path it loaded with. Buffers that fire no
|
||||
-- after-load (argv `[new file]`) are covered by the sweep-time GC.
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
pcall(pmacs.buffer.on_removed, buf, function(dead)
|
||||
pcall(pmacs.autosave._discard_buffer, dead or buf)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- A clean save retires the recovery copy. Keyed by buffer, so a renamed
|
||||
-- buffer's real recovery key (written under the *old* path) is removed
|
||||
-- too, not just the current path's.
|
||||
pmacs.hook.add("buffer.after-save", function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf then pcall(pmacs.autosave._discard_buffer, buf) end
|
||||
end)
|
||||
|
||||
-- A final synchronous sweep on quit: async ticks stop after this, so a
|
||||
-- quit with unsaved changes must capture them here. A failure here means
|
||||
-- the quit is about to discard work that was never written anywhere, so
|
||||
-- it is reported rather than swallowed. Returns nil -- before-quit is
|
||||
-- short-circuit and this must never veto.
|
||||
pmacs.hook.add("editor.before-quit", function()
|
||||
sweep_reporting()
|
||||
end)
|
||||
|
||||
pmacs.command.define {
|
||||
name = "recover-file",
|
||||
description = "Replace this buffer with its autosave recovery copy.",
|
||||
fn = function()
|
||||
local path = pmacs.editor.file_path()
|
||||
if not path then
|
||||
pmacs.editor.set_status("recover-file: not visiting a file")
|
||||
return
|
||||
end
|
||||
local st = pmacs.autosave._status(path)
|
||||
if st == "none" then
|
||||
pmacs.editor.set_status("recover-file: no autosave recovery for this file")
|
||||
return
|
||||
end
|
||||
if st == "corrupt" then
|
||||
pmacs.editor.set_status("recover-file: recovery file is corrupt --- M-x discard-recovery")
|
||||
return
|
||||
end
|
||||
local warn = ""
|
||||
if st == "stale" then
|
||||
warn = " [WARNING: file changed on disk since the autosave]"
|
||||
end
|
||||
-- Pin to the exact BUFFER we started on, not merely its path: two
|
||||
-- buffers can visit the same path (`pmacs.buffer.from_file` does not
|
||||
-- dedup), so a path check alone could recover into the wrong one.
|
||||
local origin_buf = pmacs.window.buffer()
|
||||
if not origin_buf then
|
||||
pmacs.editor.set_status("recover-file: no buffer")
|
||||
return
|
||||
end
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Recover from autosave?" .. warn .. " (yes/no): ",
|
||||
source = function() return { "yes", "no" } end,
|
||||
on_accept = function(answer)
|
||||
if answer ~= "yes" then
|
||||
pmacs.editor.set_status("recover-file: cancelled")
|
||||
return
|
||||
end
|
||||
-- Focus can drift while the prompt is up, and recovering into the
|
||||
-- wrong buffer is unrecoverable.
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf ~= origin_buf or pmacs.editor.file_path() ~= path then
|
||||
pmacs.editor.set_status("recover-file: buffer changed; aborted")
|
||||
return
|
||||
end
|
||||
local bytes = pmacs.autosave._recover_bytes(path)
|
||||
if not bytes then
|
||||
pmacs.editor.set_status("recover-file: recovery unreadable")
|
||||
return
|
||||
end
|
||||
buf:replace(0, buf:len(), bytes)
|
||||
-- The crash data now lives in the buffer, so the copy is no
|
||||
-- longer irreplaceable: claim it (Q#AS12). Claiming by BUFFER,
|
||||
-- right after the replace, records the recovery under this
|
||||
-- buffer's id at the revision whose contents it holds -- which
|
||||
-- un-blocks autosave for the path AND lets a later kill retire
|
||||
-- the copy (a removal callback runs after the buffer is gone,
|
||||
-- with no path left to read).
|
||||
pmacs.autosave._adopt(buf)
|
||||
-- The mutators notify windows and queue CRDT but do NOT fire
|
||||
-- `buffer.after-edit` --- that comes from dispatch_key's
|
||||
-- post-command revision check, which the minibuffer shadow
|
||||
-- returns before. Fire it so LSP didChange and the syntax
|
||||
-- reparse see the recovered contents.
|
||||
pmacs.hook.run("buffer.after-edit")
|
||||
pmacs.editor.set_status("recovered from autosave --- save to keep it")
|
||||
end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "discard-recovery",
|
||||
description = "Delete this file's autosave recovery copy.",
|
||||
fn = function()
|
||||
local path = pmacs.editor.file_path()
|
||||
if not path then
|
||||
pmacs.editor.set_status("discard-recovery: not visiting a file")
|
||||
return
|
||||
end
|
||||
pmacs.autosave._discard(path)
|
||||
pmacs.editor.set_status("discarded autosave recovery")
|
||||
end,
|
||||
}
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
# 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 today** —
|
||||
`EditorCore::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 `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:
|
||||
|
||||
```lua
|
||||
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 `fsync`s), 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
|
||||
(`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**,
|
||||
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 `BufferId`s — 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-save`** → `discard_buffer(active buffer)`.
|
||||
- **Buffer killed** → `discard_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-`fsync`s 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.
|
||||
|
|
@ -0,0 +1,723 @@
|
|||
// autosave.rs --- periodic recovery copies + crash recovery (Arc 3 phase 3).
|
||||
|
||||
//! Every modified file buffer is periodically written to a private
|
||||
//! recovery file under `$XDG_STATE_HOME/pmacs/autosave/`. If pmacs dies,
|
||||
//! the next session notices the copy and offers `M-x recover-file`.
|
||||
//! Emacs's `auto-save-mode` + `recover-file`.
|
||||
//!
|
||||
//! This module owns the parts Lua cannot do: enumerating **all** buffers'
|
||||
//! paths (Lua has no per-buffer path getter), and the `FileMeta`
|
||||
//! external-change guard (`FileMeta` is neither Lua-visible nor serde).
|
||||
//! `builtin/runtime/autosave.lua` owns the cadence, the configurable
|
||||
//! interval, and the recovery UX.
|
||||
//!
|
||||
//! Recovery files are written `0600` under a `0700` directory
|
||||
//! (Q#AS11) — they hold *unsaved file contents*, a different class of
|
||||
//! secret from saveplace's cursor offsets.
|
||||
//!
|
||||
//! Framing: docs/autosave-recovery-framing.md.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use mlua::Lua;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::buffer::BufferId;
|
||||
use crate::file_io::FileMeta;
|
||||
use crate::hash::sha256_hex;
|
||||
use crate::lua_bindings::{SharedCore, StateDir};
|
||||
|
||||
/// Bump when the envelope shape changes incompatibly. A recovery file
|
||||
/// with an unrecognized version reads as [`RecoveryStatus::Corrupt`] —
|
||||
/// never silently applied.
|
||||
pub const AUTOSAVE_VERSION: u32 = 1;
|
||||
|
||||
/// The header of a recovery file: one line of JSON, then `\n`, then the
|
||||
/// raw buffer bytes (Q#AS4). One atomic write, so a crash can never leave
|
||||
/// a torn header/contents pair.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct Header {
|
||||
version: u32,
|
||||
/// The buffer's path, for provenance and orphan inspection.
|
||||
path: String,
|
||||
/// The origin file's identity when this copy was taken.
|
||||
///
|
||||
/// **Nullable**: 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. `None` means "there was no file on
|
||||
/// disk when this was autosaved".
|
||||
origin: Option<Origin>,
|
||||
}
|
||||
|
||||
/// `FileMeta` hand-serialized — it is not serde, and `SystemTime` has no
|
||||
/// stable wire form. Stored as an offset from the Unix epoch so the
|
||||
/// comparison is exact; we never reconstruct a `SystemTime`, only compare
|
||||
/// these parts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct Origin {
|
||||
mtime_secs: i64,
|
||||
mtime_nanos: u32,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
fn from_meta(m: &FileMeta) -> Self {
|
||||
let (mtime_secs, mtime_nanos) = match m.mtime.duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(d) => (
|
||||
i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
|
||||
d.subsec_nanos(),
|
||||
),
|
||||
// Pre-epoch mtimes are exotic but representable.
|
||||
Err(e) => {
|
||||
let d = e.duration();
|
||||
(
|
||||
i64::try_from(d.as_secs()).map_or(i64::MIN, |s| -s),
|
||||
d.subsec_nanos(),
|
||||
)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
mtime_secs,
|
||||
mtime_nanos,
|
||||
size: m.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a recovery file means for a given path (Q#AS5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecoveryStatus {
|
||||
/// No recovery file.
|
||||
None,
|
||||
/// The on-disk file is unchanged since the copy was taken (or is
|
||||
/// still absent, for a `[new file]`), so the recovery is strictly
|
||||
/// newer. The only status that is announced.
|
||||
Fresh,
|
||||
/// The file changed underneath us — externally edited, deleted, or
|
||||
/// (for a `[new file]`) created by someone else. Never auto-offered:
|
||||
/// silently clobbering it is the one unrecoverable mistake here.
|
||||
Stale,
|
||||
/// Unparseable or unrecognized version. Never offered, never errors;
|
||||
/// `discard-recovery` removes it.
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
impl RecoveryStatus {
|
||||
/// The lowercase name Lua sees.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RecoveryStatus::None => "none",
|
||||
RecoveryStatus::Fresh => "fresh",
|
||||
RecoveryStatus::Stale => "stale",
|
||||
RecoveryStatus::Corrupt => "corrupt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `pmacs.state` key a path's recovery file lives under.
|
||||
#[must_use]
|
||||
pub fn key_for(path: &Path) -> String {
|
||||
format!("autosave/{}", sha256_hex(&path.display().to_string()))
|
||||
}
|
||||
|
||||
/// Per-session autosave bookkeeping.
|
||||
#[derive(Default)]
|
||||
pub struct AutosaveCache(RefCell<CacheInner>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct CacheInner {
|
||||
/// Skip cache: `BufferId → (path_hash, revision)` (Q#AS8).
|
||||
///
|
||||
/// Keyed on the **path hash as well as the revision**, not the
|
||||
/// revision alone: a buffer keeps its `BufferId` across a path change
|
||||
/// (an 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. It also
|
||||
/// remembers *where a buffer's recovery currently lives*, which is
|
||||
/// what makes cleanup work after a rename.
|
||||
written: HashMap<BufferId, (String, u64)>,
|
||||
/// Which buffer owns each recovery slot: `path_hash → BufferId`
|
||||
/// (Q#AS12, Q#AS13).
|
||||
///
|
||||
/// Two roles in one map:
|
||||
///
|
||||
/// * **Absent** = the recovery file at that hash (if any) is
|
||||
/// *unclaimed crash data* — this session did not write it. Sweeping
|
||||
/// would overwrite the crash copy with the current buffer,
|
||||
/// destroying exactly what autosave protects. So it blocks the
|
||||
/// sweep until `recover-file` adopts it or `discard-recovery`
|
||||
/// removes it, and neither save nor kill may delete it.
|
||||
/// * **Present** = the slot belongs to exactly *one* buffer. A
|
||||
/// recovery file is keyed by path (a later session knows only
|
||||
/// paths, never old `BufferId`s), but `pmacs.buffer.from_file` can
|
||||
/// open a *second* buffer on the same path. Both cannot be
|
||||
/// protected under one key: the later write would win on disk while
|
||||
/// both buffers believed themselves saved. So the first modified
|
||||
/// buffer claims the slot and any other buffer on that path is
|
||||
/// reported as conflicted, not silently mis-protected.
|
||||
owner: HashMap<String, BufferId>,
|
||||
}
|
||||
|
||||
/// Encode a header + contents into the one-file envelope.
|
||||
fn encode(header: &Header, contents: &[u8]) -> Result<Vec<u8>, String> {
|
||||
// serde_json's compact form never contains a raw newline, so the
|
||||
// first `\n` unambiguously ends the header.
|
||||
let mut out = serde_json::to_vec(header).map_err(|e| e.to_string())?;
|
||||
debug_assert!(!out.contains(&b'\n'));
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(contents);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Split an envelope at its **first** newline. Contents may contain
|
||||
/// newlines and arbitrary non-UTF-8 bytes, so only the first one counts.
|
||||
/// Returns `None` for anything malformed — the caller maps that to
|
||||
/// [`RecoveryStatus::Corrupt`].
|
||||
fn decode(bytes: &[u8]) -> Option<(Header, &[u8])> {
|
||||
let nl = bytes.iter().position(|&b| b == b'\n')?;
|
||||
let header: Header = serde_json::from_slice(&bytes[..nl]).ok()?;
|
||||
if header.version != AUTOSAVE_VERSION {
|
||||
return None;
|
||||
}
|
||||
Some((header, &bytes[nl + 1..]))
|
||||
}
|
||||
|
||||
/// The configured state dir, if any (absent under tests / no HOME).
|
||||
fn base_dir(lua: &Lua) -> Option<std::path::PathBuf> {
|
||||
lua.app_data_ref::<StateDir>().map(|d| d.0.clone())
|
||||
}
|
||||
|
||||
/// Classify the recovery file for `path` (Q#AS5's table).
|
||||
#[must_use]
|
||||
pub fn status(base: &Path, path: &Path) -> RecoveryStatus {
|
||||
let key = key_for(path);
|
||||
let Ok(Some(bytes)) = crate::state::read_bytes(base, &key) else {
|
||||
return RecoveryStatus::None;
|
||||
};
|
||||
let Some((header, _)) = decode(&bytes) else {
|
||||
return RecoveryStatus::Corrupt;
|
||||
};
|
||||
let on_disk = crate::file_io::current_meta(path).ok();
|
||||
match (header.origin, on_disk) {
|
||||
// Had an origin, file still there: fresh iff identity matches.
|
||||
(Some(o), Some(cur)) if o == Origin::from_meta(&cur) => RecoveryStatus::Fresh,
|
||||
// `[new file]`: fresh while it is still absent.
|
||||
(None, None) => RecoveryStatus::Fresh,
|
||||
// Everything else changed underneath us: the file was edited
|
||||
// externally, deleted, or (for a `[new file]`) created by someone
|
||||
// else. Never auto-offered.
|
||||
_ => RecoveryStatus::Stale,
|
||||
}
|
||||
}
|
||||
|
||||
/// The recovered contents for `path`, if a parseable recovery exists.
|
||||
/// Returns bytes for `Stale` too — the command warns and confirms.
|
||||
#[must_use]
|
||||
pub fn recover_bytes(base: &Path, path: &Path) -> Option<Vec<u8>> {
|
||||
let bytes = crate::state::read_bytes(base, &key_for(path))
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let (_, contents) = decode(&bytes)?;
|
||||
Some(contents.to_vec())
|
||||
}
|
||||
|
||||
/// Delete the recovery file for `path` (idempotent).
|
||||
pub fn discard(base: &Path, path: &Path) -> bool {
|
||||
crate::state::remove(base, &key_for(path)).is_ok()
|
||||
}
|
||||
|
||||
/// A buffer that needs a recovery copy written, gathered under the core
|
||||
/// borrow so all IO happens after it is released.
|
||||
struct Pending {
|
||||
id: BufferId,
|
||||
path: String,
|
||||
path_hash: String,
|
||||
revision: u64,
|
||||
origin: Option<Origin>,
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One autosave pass: write a recovery copy of every modified file
|
||||
/// buffer whose contents changed since its last copy. Returns how many
|
||||
/// were written.
|
||||
///
|
||||
/// Runs on the main thread; the two skips in Q#AS8 keep that bounded.
|
||||
/// Unlike desktop-save this is **not** daemon-gated — autosave is
|
||||
/// per-buffer, not per-frontend, and a daemon holds the unsaved work.
|
||||
///
|
||||
/// Returns `(written, blocked, conflicted)`:
|
||||
///
|
||||
/// * `blocked` — an **unclaimed** recovery file already sits at the
|
||||
/// buffer's key (Q#AS12): crash data this session did not write.
|
||||
/// * `conflicted` — another buffer already owns that path's recovery
|
||||
/// slot (Q#AS13): two buffers visit the same file and only one can be
|
||||
/// protected under a path-keyed recovery file.
|
||||
///
|
||||
/// # Errors
|
||||
/// A state-write failure. Individual buffers never abort the pass.
|
||||
pub fn sweep(lua: &Lua) -> Result<(usize, usize, usize), String> {
|
||||
let Some(base) = base_dir(lua) else {
|
||||
return Ok((0, 0, 0));
|
||||
};
|
||||
let core = lua
|
||||
.app_data_ref::<SharedCore>()
|
||||
.ok_or("no editor core")?
|
||||
.clone();
|
||||
let gathered = gather(lua, &core, &base)?;
|
||||
let Gathered {
|
||||
writes,
|
||||
orphans,
|
||||
live,
|
||||
blocked,
|
||||
conflicted,
|
||||
} = gathered;
|
||||
|
||||
let mut written = 0usize;
|
||||
{
|
||||
let cache = lua
|
||||
.app_data_ref::<AutosaveCache>()
|
||||
.ok_or("no autosave cache")?;
|
||||
let mut cache = cache.0.borrow_mut();
|
||||
// A buffer whose path moved leaves its old recovery behind.
|
||||
for old in orphans {
|
||||
let _ = crate::state::remove(&base, &format!("autosave/{old}"));
|
||||
cache.owner.remove(&old);
|
||||
}
|
||||
// GC: a buffer that left the registry (killed) takes its recovery
|
||||
// copy with it. This is the backstop that covers `[new file]`
|
||||
// buffers, which fire no `after-load` and so never get a
|
||||
// per-buffer removal callback registered. Only the slot's owner
|
||||
// may retire it.
|
||||
let dead: Vec<(BufferId, String)> = cache
|
||||
.written
|
||||
.iter()
|
||||
.filter(|(id, _)| !live.contains(id))
|
||||
.map(|(id, (hash, _))| (*id, hash.clone()))
|
||||
.collect();
|
||||
for (id, hash) in dead {
|
||||
if cache.owner.get(&hash) == Some(&id) {
|
||||
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
||||
cache.owner.remove(&hash);
|
||||
}
|
||||
cache.written.remove(&id);
|
||||
}
|
||||
for p in writes {
|
||||
let header = Header {
|
||||
version: AUTOSAVE_VERSION,
|
||||
path: p.path,
|
||||
origin: p.origin,
|
||||
};
|
||||
let bytes = encode(&header, &p.contents)?;
|
||||
crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
cache.owner.insert(p.path_hash.clone(), p.id);
|
||||
cache.written.insert(p.id, (p.path_hash, p.revision));
|
||||
written += 1;
|
||||
}
|
||||
}
|
||||
Ok((written, blocked, conflicted))
|
||||
}
|
||||
|
||||
/// What one pass of the registry decided, before any IO.
|
||||
struct Gathered {
|
||||
writes: Vec<Pending>,
|
||||
/// Recovery keys left behind by buffers whose path moved.
|
||||
orphans: Vec<String>,
|
||||
/// Every buffer still in the registry (drives the dead-buffer GC).
|
||||
live: Vec<BufferId>,
|
||||
blocked: usize,
|
||||
conflicted: usize,
|
||||
}
|
||||
|
||||
/// Walk the registry under a single borrow and decide what to write.
|
||||
/// All IO happens in [`sweep`] after this returns, because a recovery
|
||||
/// write must not run while the core is borrowed.
|
||||
fn gather(lua: &Lua, core: &SharedCore, base: &Path) -> Result<Gathered, String> {
|
||||
let mut writes: Vec<Pending> = Vec::new();
|
||||
let mut orphans: Vec<String> = Vec::new();
|
||||
let mut live: Vec<BufferId> = Vec::new();
|
||||
let mut blocked = 0usize;
|
||||
let mut conflicted = 0usize;
|
||||
// Slots claimed earlier in *this* pass. `owner` is only updated in the
|
||||
// write loop, so without this two dirty duplicates of one path would
|
||||
// both queue a write to the same key.
|
||||
let mut queued: HashMap<String, BufferId> = HashMap::new();
|
||||
{
|
||||
let cache = lua
|
||||
.app_data_ref::<AutosaveCache>()
|
||||
.ok_or("no autosave cache")?;
|
||||
let cache = cache.0.borrow();
|
||||
let c = core.borrow();
|
||||
let reg = c.registry.borrow();
|
||||
for &id in reg.ids() {
|
||||
let Ok(buf) = reg.get(id) else { continue };
|
||||
live.push(id);
|
||||
// Skips scratch / *special* (no path). Includes `[new file]`
|
||||
// buffers: path set, `file_meta` absent.
|
||||
let Some(path) = buf.file_path() else {
|
||||
continue;
|
||||
};
|
||||
if !buf.is_modified() {
|
||||
continue;
|
||||
}
|
||||
let path_s = path.display().to_string();
|
||||
let path_hash = sha256_hex(&path_s);
|
||||
let revision = buf.revision();
|
||||
// Exactly one buffer may own a path's recovery slot (Q#AS13):
|
||||
// the file is keyed by path, so a second buffer on the same
|
||||
// path cannot also be protected — the later write would win on
|
||||
// disk while both believed themselves saved.
|
||||
let slot_owner = cache
|
||||
.owner
|
||||
.get(&path_hash)
|
||||
.or_else(|| queued.get(&path_hash));
|
||||
match slot_owner {
|
||||
Some(&owner_id) if owner_id != id => {
|
||||
conflicted += 1;
|
||||
continue;
|
||||
}
|
||||
Some(_) => {} // we already own the slot
|
||||
None => {
|
||||
// Unowned. Never clobber unclaimed crash data (Q#AS12):
|
||||
// a recovery file this session did not write is the
|
||||
// crash copy the user has not recovered yet.
|
||||
if crate::state::exists(base, &format!("autosave/{path_hash}")).unwrap_or(false)
|
||||
{
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((prev_hash, prev_rev)) = cache.written.get(&id) {
|
||||
if prev_hash == &path_hash && *prev_rev == revision {
|
||||
continue; // unchanged since its last copy
|
||||
}
|
||||
if prev_hash != &path_hash {
|
||||
// The path moved: the old key is now an orphan.
|
||||
orphans.push(prev_hash.clone());
|
||||
}
|
||||
}
|
||||
queued.insert(path_hash.clone(), id);
|
||||
let len = buf.len();
|
||||
let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)];
|
||||
if len > 0 {
|
||||
buf.snapshot_rope().slice(0, len, &mut contents);
|
||||
}
|
||||
writes.push(Pending {
|
||||
id,
|
||||
path: path_s,
|
||||
path_hash,
|
||||
revision,
|
||||
origin: buf.file_meta().map(Origin::from_meta),
|
||||
contents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Gathered {
|
||||
writes,
|
||||
orphans,
|
||||
live,
|
||||
blocked,
|
||||
conflicted,
|
||||
})
|
||||
}
|
||||
|
||||
/// Claim `buffer`'s recovery file for this session (Q#AS12). Called by
|
||||
/// `recover-file` once the contents are installed in the buffer — the
|
||||
/// crash data now lives in the buffer, so the copy is no longer
|
||||
/// irreplaceable.
|
||||
///
|
||||
/// It records a `written` entry as well as the ownership, because after a
|
||||
/// recover the file's contents *are* the buffer's contents. That makes
|
||||
/// two things right at once: the skip cache correctly declines to rewrite
|
||||
/// it, and `discard_buffer` can find and retire it — including from a
|
||||
/// removal callback that fires *after* the buffer is gone, when there is
|
||||
/// no path left to read (finding).
|
||||
pub fn adopt(lua: &Lua, id: BufferId) {
|
||||
let Some(core) = lua.app_data_ref::<SharedCore>() else {
|
||||
return;
|
||||
};
|
||||
let entry = {
|
||||
let c = core.borrow();
|
||||
let reg = c.registry.borrow();
|
||||
let Ok(buf) = reg.get(id) else { return };
|
||||
let Some(p) = buf.file_path() else { return };
|
||||
(sha256_hex(&p.display().to_string()), buf.revision())
|
||||
};
|
||||
drop(core);
|
||||
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
||||
let mut cache = cache.0.borrow_mut();
|
||||
let (hash, revision) = entry;
|
||||
// Recovering into this buffer makes it the slot's owner — its
|
||||
// contents are now what the file holds. Any previous owner of the
|
||||
// slot (a duplicate buffer on the same path) loses the claim and
|
||||
// will report as conflicted on the next sweep, which is truthful:
|
||||
// the file no longer corresponds to it.
|
||||
//
|
||||
// Dropping the old owner's skip-cache entry maintains the
|
||||
// invariant `written[id] ⟹ owner[hash] == id` (finding). Without
|
||||
// it: 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.
|
||||
cache
|
||||
.written
|
||||
.retain(|&other, (h, _)| other == id || h != &hash);
|
||||
cache.owner.insert(hash.clone(), id);
|
||||
cache.written.insert(id, (hash, revision));
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the recovery file for `path` and drop every claim and skip-cache
|
||||
/// entry pointing at it.
|
||||
///
|
||||
/// This is the **explicit** release path (`discard-recovery`), so it
|
||||
/// ignores ownership — the user asked. Clearing the matching `written`
|
||||
/// entries matters (finding): otherwise a still-dirty buffer would hit the
|
||||
/// unchanged-`(path_hash, revision)` fast path on the next sweep and go
|
||||
/// unprotected until its next edit.
|
||||
pub fn discard_path(lua: &Lua, path: &Path) -> bool {
|
||||
let Some(base) = base_dir(lua) else {
|
||||
return false;
|
||||
};
|
||||
let hash = sha256_hex(&path.display().to_string());
|
||||
if let Some(cache) = lua.app_data_ref::<AutosaveCache>() {
|
||||
let mut cache = cache.0.borrow_mut();
|
||||
cache.owner.remove(&hash);
|
||||
cache.written.retain(|_, (h, _)| h != &hash);
|
||||
}
|
||||
discard(&base, path)
|
||||
}
|
||||
|
||||
/// Retire the recovery copy of a specific **buffer** (Q#AS12).
|
||||
///
|
||||
/// Keyed by `BufferId`, not by the path captured when the buffer loaded:
|
||||
/// it considers both the buffer's *current* path key (if it is still
|
||||
/// live) and the key its last recovery was actually **written** under.
|
||||
/// Those differ after a rename — an LSP `WorkspaceEdit` changes the path
|
||||
/// while the `BufferId` stays — and a path-captured callback would leave
|
||||
/// the real recovery file behind.
|
||||
///
|
||||
/// **Only keys this session owns are removed** (Q#AS12, finding). Saving
|
||||
/// or killing a buffer you reopened after a crash must *not* destroy the
|
||||
/// unclaimed recovery copy sitting at its path — you never recovered it.
|
||||
/// Only `recover-file` (which adopts) or an explicit `discard-recovery`
|
||||
/// releases unclaimed crash data.
|
||||
pub fn discard_buffer(lua: &Lua, id: BufferId) {
|
||||
let Some(base) = base_dir(lua) else {
|
||||
return;
|
||||
};
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
// The key the last sweep (or an adopt) recorded for this buffer. This
|
||||
// is the only source that still works once the buffer is gone — a
|
||||
// removal callback fires after it has left the registry.
|
||||
if let Some(cache) = lua.app_data_ref::<AutosaveCache>()
|
||||
&& let Some((hash, _)) = cache.0.borrow().written.get(&id)
|
||||
{
|
||||
keys.push(hash.clone());
|
||||
}
|
||||
// The buffer's current path, which may have moved since that write.
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
let c = core.borrow();
|
||||
let reg = c.registry.borrow();
|
||||
if let Ok(buf) = reg.get(id)
|
||||
&& let Some(p) = buf.file_path()
|
||||
{
|
||||
keys.push(sha256_hex(&p.display().to_string()));
|
||||
}
|
||||
}
|
||||
let Some(cache) = lua.app_data_ref::<AutosaveCache>() else {
|
||||
return;
|
||||
};
|
||||
let mut cache = cache.0.borrow_mut();
|
||||
// Retire only slots **this buffer** owns. Two guards in one check:
|
||||
// * an unowned slot is unclaimed crash data — saving or killing the
|
||||
// buffer you reopened after a crash must not destroy it (Q#AS12);
|
||||
// * a slot owned by a *different* buffer belongs to that buffer's
|
||||
// recovery — a duplicate buffer on the same path must not retire
|
||||
// it (Q#AS13).
|
||||
keys.retain(|h| cache.owner.get(h) == Some(&id));
|
||||
for hash in &keys {
|
||||
let _ = crate::state::remove(&base, &format!("autosave/{hash}"));
|
||||
cache.owner.remove(hash);
|
||||
}
|
||||
// This buffer's own bookkeeping goes regardless: it is being saved or
|
||||
// killed, so any skip-cache entry for it is spent.
|
||||
cache.written.remove(&id);
|
||||
}
|
||||
|
||||
/// Every open file buffer that has a recovery file, with its status
|
||||
/// (Q#AS6). Enumerating in Rust is what makes this cover argv
|
||||
/// `[new file]` buffers, which fire no hook at all — the Lua reporter
|
||||
/// never has to know they exist.
|
||||
///
|
||||
/// Returns `(fresh_paths, corrupt_count)`.
|
||||
#[must_use]
|
||||
pub fn pending(lua: &Lua) -> (Vec<String>, usize) {
|
||||
let mut fresh = Vec::new();
|
||||
let mut corrupt = 0usize;
|
||||
let (Some(base), Some(core)) = (base_dir(lua), lua.app_data_ref::<SharedCore>()) else {
|
||||
return (fresh, corrupt);
|
||||
};
|
||||
// Collect paths first so the guard drops before any IO re-entrancy.
|
||||
let paths: Vec<std::path::PathBuf> = {
|
||||
let c = core.borrow();
|
||||
let reg = c.registry.borrow();
|
||||
reg.ids()
|
||||
.iter()
|
||||
.filter_map(|&id| reg.get(id).ok()?.file_path().map(Path::to_path_buf))
|
||||
.collect()
|
||||
};
|
||||
for p in paths {
|
||||
match status(&base, &p) {
|
||||
RecoveryStatus::Fresh => fresh.push(p.display().to_string()),
|
||||
RecoveryStatus::Corrupt => corrupt += 1,
|
||||
RecoveryStatus::None | RecoveryStatus::Stale => {}
|
||||
}
|
||||
}
|
||||
(fresh, corrupt)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn header(origin: Option<Origin>) -> Header {
|
||||
Header {
|
||||
version: AUTOSAVE_VERSION,
|
||||
path: "/tmp/a.rs".into(),
|
||||
origin,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trips_arbitrary_bytes() {
|
||||
// Contents with newlines AND invalid UTF-8 — the reason we split
|
||||
// at the first newline and read bytes, not a String.
|
||||
let contents = [0xffu8, b'\n', b'a', 0x00, b'\n'];
|
||||
let h = header(Some(Origin {
|
||||
mtime_secs: 5,
|
||||
mtime_nanos: 7,
|
||||
size: 5,
|
||||
}));
|
||||
let bytes = encode(&h, &contents).unwrap();
|
||||
let (got_h, got_c) = decode(&bytes).unwrap();
|
||||
assert_eq!(got_h, h);
|
||||
assert_eq!(got_c, &contents[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trips_null_origin() {
|
||||
// A `[new file]` buffer: no origin meta.
|
||||
let h = header(None);
|
||||
let bytes = encode(&h, b"draft").unwrap();
|
||||
let (got_h, got_c) = decode(&bytes).unwrap();
|
||||
assert!(got_h.origin.is_none());
|
||||
assert_eq!(got_c, b"draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_malformed_and_wrong_version() {
|
||||
assert!(decode(b"no newline at all").is_none());
|
||||
assert!(decode(b"{not json}\nbody").is_none());
|
||||
assert!(decode(b"\nbody").is_none(), "empty header");
|
||||
let bad_version = br#"{"version":999,"path":"/x","origin":null}"#;
|
||||
let mut bytes = bad_version.to_vec();
|
||||
bytes.push(b'\n');
|
||||
assert!(decode(&bytes).is_none(), "unrecognized version → corrupt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_is_a_valid_state_key() {
|
||||
let k = key_for(Path::new("/home/u/a b.rs"));
|
||||
assert!(k.starts_with("autosave/"));
|
||||
assert!(crate::state::validate_name(&k).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_none_when_no_recovery_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-as-none-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(
|
||||
status(&dir, Path::new("/tmp/nonexistent.rs")),
|
||||
RecoveryStatus::None
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_corrupt_for_garbage_envelope() {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-as-corrupt-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = Path::new("/tmp/whatever.rs");
|
||||
crate::state::write_private(&dir, &key_for(target), b"garbage, no newline").unwrap();
|
||||
assert_eq!(status(&dir, target), RecoveryStatus::Corrupt);
|
||||
// And it is discardable.
|
||||
assert!(discard(&dir, target));
|
||||
assert_eq!(status(&dir, target), RecoveryStatus::None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_status_is_fresh_until_the_file_appears() {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-as-newfile-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = dir.join("draft.rs");
|
||||
// origin: null — a `[new file]` buffer.
|
||||
let bytes = encode(
|
||||
&Header {
|
||||
version: AUTOSAVE_VERSION,
|
||||
path: target.display().to_string(),
|
||||
origin: None,
|
||||
},
|
||||
b"unsaved draft",
|
||||
)
|
||||
.unwrap();
|
||||
crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap();
|
||||
|
||||
assert_eq!(status(&dir, &target), RecoveryStatus::Fresh, "file absent");
|
||||
assert_eq!(
|
||||
recover_bytes(&dir, &target).as_deref(),
|
||||
Some(&b"unsaved draft"[..])
|
||||
);
|
||||
|
||||
// Someone created the file meanwhile → stale, never auto-offered.
|
||||
std::fs::write(&target, b"someone else's content").unwrap();
|
||||
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_file_fresh_until_it_changes_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-as-exist-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = dir.join("a.rs");
|
||||
std::fs::write(&target, b"on disk").unwrap();
|
||||
let meta = crate::file_io::current_meta(&target).unwrap();
|
||||
let bytes = encode(
|
||||
&Header {
|
||||
version: AUTOSAVE_VERSION,
|
||||
path: target.display().to_string(),
|
||||
origin: Some(Origin::from_meta(&meta)),
|
||||
},
|
||||
b"unsaved edits",
|
||||
)
|
||||
.unwrap();
|
||||
crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap();
|
||||
assert_eq!(status(&dir, &target), RecoveryStatus::Fresh);
|
||||
|
||||
// Touch the file (different size ⇒ different identity).
|
||||
std::fs::write(&target, b"changed underneath us").unwrap();
|
||||
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
|
||||
|
||||
// Delete it entirely → still stale (the base is gone).
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
assert_eq!(status(&dir, &target), RecoveryStatus::Stale);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
|
@ -20,10 +20,10 @@ use std::path::Path;
|
|||
|
||||
use mlua::Lua;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::buffer::BufferId;
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::hash::sha256_hex;
|
||||
use crate::lua_bindings::{LocalInstanceInfo, SharedCore, StateDir, fire_after_load_hook};
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::text_view::TextView;
|
||||
|
|
@ -141,18 +141,6 @@ pub fn desktop_state_key(session_key: &str) -> String {
|
|||
format!("desktop/{session_key}")
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(s.as_bytes());
|
||||
let digest = h.finalize();
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for b in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "{b:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the serializable layout tree from a core [`LayoutNode`],
|
||||
/// resolving each leaf window to a [`SavedLeaf`] (returning `None` for a
|
||||
/// non-file leaf, which is dropped and its split collapsed).
|
||||
|
|
|
|||
|
|
@ -321,6 +321,12 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/desktop.lua"),
|
||||
)
|
||||
.expect("load desktop builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/autosave.lua"),
|
||||
include_str!("../builtin/runtime/autosave.lua"),
|
||||
)
|
||||
.expect("load autosave builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
|
|||
|
|
@ -130,7 +130,32 @@ impl Drop for TempCleanup {
|
|||
/// identity for future change-detection comparisons.
|
||||
///
|
||||
/// Threading: any thread.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`SaveError`].
|
||||
pub fn save_atomic(path: &Path, content: &[u8]) -> Result<FileMeta, SaveError> {
|
||||
save_atomic_with_mode(path, content, None)
|
||||
}
|
||||
|
||||
/// [`save_atomic`], but forcing the target's Unix mode to `mode` instead
|
||||
/// of inheriting the existing file's mode (or the umask default for a new
|
||||
/// file).
|
||||
///
|
||||
/// The mode is applied to the **temp file before the rename**, so the
|
||||
/// target never exists — not even momentarily — at a laxer mode. A
|
||||
/// `chmod` after the write would leave exactly that window, which matters
|
||||
/// because autosave (Arc 3 Q#AS11) stores *unsaved file contents*: a
|
||||
/// recovery copy must never be briefly world-readable.
|
||||
///
|
||||
/// `mode` is ignored on non-Unix platforms (the write is still atomic).
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`SaveError`].
|
||||
pub fn save_atomic_with_mode(
|
||||
path: &Path,
|
||||
content: &[u8],
|
||||
mode: Option<u32>,
|
||||
) -> Result<FileMeta, SaveError> {
|
||||
// `Path::parent` returns:
|
||||
// * `None` for `/` or `""` --- no place to put a sibling temp file;
|
||||
// * `Some("")` for a bare filename like `notes.txt` --- means cwd, fine;
|
||||
|
|
@ -140,10 +165,22 @@ pub fn save_atomic(path: &Path, content: &[u8]) -> Result<FileMeta, SaveError> {
|
|||
return Err(SaveError::NoParent(path.to_path_buf()));
|
||||
}
|
||||
|
||||
// Snapshot the target's current permissions so an existing file keeps
|
||||
// its mode across the replace (F-006) — e.g. a `0755` script stays
|
||||
// executable. `None` for a new file, which then gets the default mode.
|
||||
let existing_perms = fs::metadata(path).ok().map(|m| m.permissions());
|
||||
// An explicit `mode` wins; otherwise snapshot the target's current
|
||||
// permissions so an existing file keeps its mode across the replace
|
||||
// (F-006) — e.g. a `0755` script stays executable. `None` for a new
|
||||
// file, which then gets the default mode.
|
||||
#[cfg(unix)]
|
||||
let existing_perms = mode
|
||||
.map(|m| {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::Permissions::from_mode(m)
|
||||
})
|
||||
.or_else(|| fs::metadata(path).ok().map(|m| m.permissions()));
|
||||
#[cfg(not(unix))]
|
||||
let existing_perms = {
|
||||
let _ = mode; // no mode concept; the write is still atomic
|
||||
fs::metadata(path).ok().map(|m| m.permissions())
|
||||
};
|
||||
|
||||
// Open a fresh temp, retrying on the rare name collision (a stale temp
|
||||
// left by a crashed prior run whose pid+nanos recurs) instead of
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
// hash.rs --- shared content hashing (Arc 3 phase 3, Q#AS9).
|
||||
|
||||
//! One `sha256_hex` for the whole crate. Three call sites want a stable,
|
||||
//! filename-safe digest of a string:
|
||||
//!
|
||||
//! * [`crate::desktop`] — the desktop session key,
|
||||
//! * [`crate::autosave`] — the recovery-file key (a hash of the path),
|
||||
//! * `crate::packages::fetcher` — the package cache/mirror key.
|
||||
//!
|
||||
//! Each had grown (or was about to grow) its own private copy. A
|
||||
//! *cryptographic* digest matters for the fetcher: a non-cryptographic
|
||||
//! hash is trivially collidable, and a deliberate collision would make
|
||||
//! two URLs share one bare mirror + lock file.
|
||||
//!
|
||||
//! Lowercase hex, so the output passes
|
||||
//! [`crate::state::validate_name`]'s `[A-Za-z0-9._-]` charset.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Lowercase-hex SHA-256 of `s`.
|
||||
pub(crate) fn sha256_hex(s: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for b in digest {
|
||||
let _ = write!(out, "{b:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_vector_and_charset() {
|
||||
// The canonical empty-string SHA-256.
|
||||
assert_eq!(
|
||||
sha256_hex(""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
let h = sha256_hex("/home/u/a.rs");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(
|
||||
h.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
|
||||
);
|
||||
// Filename-safe: passes the state-key charset.
|
||||
assert!(crate::state::validate_name(&format!("autosave/{h}")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_inputs_distinct_digests() {
|
||||
assert_ne!(sha256_hex("a"), sha256_hex("b"));
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ pub mod attach;
|
|||
pub mod attach_dispatch;
|
||||
pub mod attach_reconnect;
|
||||
pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod buffer;
|
||||
pub mod buffer_registry;
|
||||
pub mod builtin_packages;
|
||||
|
|
@ -75,6 +76,7 @@ pub mod file_io;
|
|||
pub mod formatting;
|
||||
pub mod frontend;
|
||||
pub mod fs;
|
||||
mod hash;
|
||||
pub mod help;
|
||||
pub mod highlight;
|
||||
pub mod hook;
|
||||
|
|
|
|||
|
|
@ -1963,10 +1963,100 @@ pub fn install(
|
|||
pmacs.set("packages", install_packages_module(lua)?)?;
|
||||
pmacs.set("state", install_state_module(lua)?)?;
|
||||
pmacs.set("session", install_session_module(lua)?)?;
|
||||
pmacs.set("autosave", install_autosave_module(lua)?)?;
|
||||
lua.globals().set("pmacs", pmacs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pmacs.autosave.*` — the Rust half of autosave + crash recovery
|
||||
/// (Arc 3 phase 3). Lua cannot enumerate non-active buffers' paths, and
|
||||
/// `FileMeta` is neither Lua-visible nor serde, so the sweep and the
|
||||
/// external-change guard live in Rust (Q#AS1). `autosave.lua` layers the
|
||||
/// cadence, the configurable interval, and the recovery UX on top.
|
||||
///
|
||||
/// The `_`-prefixed names are the raw primitives; `autosave.lua` adds the
|
||||
/// public `enable` / `interval_ms` / `sweep` onto the same table.
|
||||
fn install_autosave_module(lua: &Lua) -> mlua::Result<Table> {
|
||||
// The skip cache lives for the life of the VM.
|
||||
lua.set_app_data(crate::autosave::AutosaveCache::default());
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// _sweep() -> (written, blocked). `blocked` counts buffers whose
|
||||
// sweep was refused because unclaimed crash data sits at their key.
|
||||
m.set(
|
||||
"_sweep",
|
||||
lua.create_function(|lua, ()| crate::autosave::sweep(lua).map_err(mlua::Error::external))?,
|
||||
)?;
|
||||
|
||||
// _adopt(buf): claim a buffer's recovery file for this session, so
|
||||
// later sweeps may overwrite it and a kill can retire it.
|
||||
// `recover-file` calls this once the contents are installed.
|
||||
m.set(
|
||||
"_adopt",
|
||||
lua.create_function(|lua, id: BufferIdLua| {
|
||||
crate::autosave::adopt(lua, id.0);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// _discard_buffer(buf): retire a buffer's recovery copy by BufferId —
|
||||
// removes both its current-path key and the key its last sweep wrote
|
||||
// (they differ after a rename).
|
||||
m.set(
|
||||
"_discard_buffer",
|
||||
lua.create_function(|lua, id: BufferIdLua| {
|
||||
crate::autosave::discard_buffer(lua, id.0);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"_status",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
|
||||
return Ok("none");
|
||||
};
|
||||
Ok(crate::autosave::status(&base, std::path::Path::new(&path)).as_str())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"_recover_bytes",
|
||||
lua.create_function(|lua, path: String| {
|
||||
let Some(base) = lua.app_data_ref::<StateDir>().map(|d| d.0.clone()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match crate::autosave::recover_bytes(&base, std::path::Path::new(&path)) {
|
||||
Some(bytes) => Ok(Some(lua.create_string(&bytes)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// _discard(path): delete a recovery file and drop any claim on it.
|
||||
m.set(
|
||||
"_discard",
|
||||
lua.create_function(|lua, path: String| {
|
||||
Ok(crate::autosave::discard_path(
|
||||
lua,
|
||||
std::path::Path::new(&path),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// _pending() -> (fresh_paths, corrupt_count). Enumerates in Rust so
|
||||
// argv `[new file]` buffers — which fire no hook — are covered too.
|
||||
m.set(
|
||||
"_pending",
|
||||
lua.create_function(|lua, ()| {
|
||||
let (fresh, corrupt) = crate::autosave::pending(lua);
|
||||
Ok((fresh, corrupt))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Marker app-data set by `pmacs.session.arm_restore()` (Arc 3 phase 2,
|
||||
/// Q#DS7). Its presence tells the `RunLocal` startup trigger to attempt
|
||||
/// a desktop restore; `desktop_mode(true)` in init.lua arms it.
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ use std::process::{Child, Command, ExitStatus, Stdio};
|
|||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -512,19 +511,11 @@ fn dot_git_strip_applies(u: &str) -> bool {
|
|||
/// The cache dir is keyed by a hash of the (attacker-adjacent) repo URL,
|
||||
/// so a *cryptographic* digest is used: a non-cryptographic hash like the
|
||||
/// former 64-bit FNV-1a is trivially collidable, and a deliberate
|
||||
/// collision would make two URLs share one bare mirror + lock file. `sha2`
|
||||
/// is already a dependency (M7.6 lockfile content hashing).
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for b in digest {
|
||||
let _ = write!(out, "{b:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
/// collision would make two URLs share one bare mirror + lock file.
|
||||
///
|
||||
/// The implementation now lives in [`crate::hash`] — shared with the
|
||||
/// desktop session key and the autosave recovery key (Q#AS9).
|
||||
use crate::hash::sha256_hex;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LockGuard --- per-cache-entry flock(2)
|
||||
|
|
|
|||
151
src/state.rs
151
src/state.rs
|
|
@ -182,14 +182,112 @@ pub fn read(base: &Path, name: &str) -> Result<Option<String>, StateError> {
|
|||
/// # Errors
|
||||
/// Invalid key, or a save failure.
|
||||
pub fn write(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
|
||||
write_inner(base, name, content, None)
|
||||
}
|
||||
|
||||
/// Like [`write`], but the parent directory is created `0700` and the
|
||||
/// file written `0600` (Arc 3 Q#AS11).
|
||||
///
|
||||
/// Autosave stores **unsaved file contents**, a different class of secret
|
||||
/// from saveplace's cursor offsets or recentf's path list. The default
|
||||
/// path would give a new recovery file the umask default (typically
|
||||
/// `0644`) and its directory `0755` — leaving a recovery copy of an
|
||||
/// unsaved edit to a `0600` file *more exposed than the original*. The
|
||||
/// mode is applied to the temp before the rename, so there is no window
|
||||
/// at a laxer mode.
|
||||
///
|
||||
/// Permissions are Unix-only; elsewhere this is [`write`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Invalid key, or a save failure.
|
||||
pub fn write_private(base: &Path, name: &str, content: &[u8]) -> Result<(), StateError> {
|
||||
write_inner(base, name, content, Some(0o600))
|
||||
}
|
||||
|
||||
/// True when a state file exists (no read, no parse).
|
||||
///
|
||||
/// # Errors
|
||||
/// Invalid key.
|
||||
pub fn exists(base: &Path, name: &str) -> Result<bool, StateError> {
|
||||
let path = resolve(base, name).map_err(StateError::Name)?;
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
fn write_inner(
|
||||
base: &Path,
|
||||
name: &str,
|
||||
content: &[u8],
|
||||
mode: Option<u32>,
|
||||
) -> Result<(), StateError> {
|
||||
let path = resolve(base, name).map_err(StateError::Name)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(StateError::Io)?;
|
||||
create_dir_all_with_mode(parent, mode.map(|_| 0o700)).map_err(StateError::Io)?;
|
||||
// A directory *we* own beneath the state root (e.g. `autosave/`)
|
||||
// must actually be `0700`, even if a previous run — or a user —
|
||||
// created it laxer. Otherwise the mode only applies to the dirs
|
||||
// this call happened to create, and a pre-existing `0755`
|
||||
// `autosave/` would still leak recovery-file names, sizes, and
|
||||
// mtimes despite the `0600` contents.
|
||||
//
|
||||
// Never re-mode `base` itself: the state root is a directory the
|
||||
// user may already have, shared with history/recentf/desktop.
|
||||
if mode.is_some() && parent != base {
|
||||
enforce_dir_mode(parent, 0o700).map_err(StateError::Io)?;
|
||||
}
|
||||
}
|
||||
crate::file_io::save_atomic(&path, content).map_err(StateError::Save)?;
|
||||
crate::file_io::save_atomic_with_mode(&path, content, mode).map_err(StateError::Save)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `create_dir_all`, birthing any directory this call creates at `mode`
|
||||
/// (so it is never briefly world-readable).
|
||||
fn create_dir_all_with_mode(dir: &Path, mode: Option<u32>) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
if let Some(m) = mode {
|
||||
use std::os::unix::fs::DirBuilderExt as _;
|
||||
return std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(m)
|
||||
.create(dir);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = mode;
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
|
||||
/// Tighten an existing directory to `mode` if it is laxer. No-op on
|
||||
/// non-Unix, and cheap when already correct.
|
||||
fn enforce_dir_mode(dir: &Path, mode: u32) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let current = std::fs::metadata(dir)?.permissions().mode() & 0o777;
|
||||
if current != mode {
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode))?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = (dir, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a state file's raw bytes, or `Ok(None)` when it does not exist.
|
||||
///
|
||||
/// [`read`] returns a `String` (`read_to_string`), which fails on
|
||||
/// non-UTF-8 content. pmacs buffers hold arbitrary bytes, so an autosave
|
||||
/// recovery file cannot be read that way (Arc 3 Q#AS4).
|
||||
///
|
||||
/// # Errors
|
||||
/// Invalid key, or an IO error other than not-found.
|
||||
pub fn read_bytes(base: &Path, name: &str) -> Result<Option<Vec<u8>>, StateError> {
|
||||
let path = resolve(base, name).map_err(StateError::Name)?;
|
||||
match std::fs::read(&path) {
|
||||
Ok(b) => Ok(Some(b)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(StateError::Io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a state file. Missing file is success (idempotent).
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -343,6 +441,55 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_round_trips_non_utf8() {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-bytes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Invalid UTF-8 — what `read` (read_to_string) would choke on.
|
||||
let raw = [0xffu8, 0xfe, b'\n', 0x00, b'a'];
|
||||
write(&dir, "blob", &raw).unwrap();
|
||||
assert_eq!(read_bytes(&dir, "blob").unwrap().as_deref(), Some(&raw[..]));
|
||||
assert!(read(&dir, "blob").is_err(), "read_to_string rejects it");
|
||||
assert!(read_bytes(&dir, "absent").unwrap().is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_private_uses_0700_dir_and_0600_file() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-priv-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
write_private(&dir, "autosave/secret", b"unsaved contents").unwrap();
|
||||
|
||||
let file = dir.join("autosave").join("secret");
|
||||
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600, "recovery file is 0600, not umask default");
|
||||
let dmode = std::fs::metadata(dir.join("autosave"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(dmode, 0o700, "autosave dir is 0700");
|
||||
|
||||
// Rewriting keeps the private mode (save_atomic inherits it).
|
||||
write_private(&dir, "autosave/secret", b"more").unwrap();
|
||||
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600);
|
||||
|
||||
// The plain `write` path is unchanged (umask default, not 0600).
|
||||
write(&dir, "plain", b"x").unwrap();
|
||||
let pmode = std::fs::metadata(dir.join("plain"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_ne!(pmode, 0o600, "plain write keeps existing behavior");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_xdg_falls_through_to_home_not_a_relative_path() {
|
||||
// The Q#PS2 fix: an empty / whitespace XDG_STATE_HOME must NOT
|
||||
|
|
|
|||
|
|
@ -0,0 +1,863 @@
|
|||
//! Autosave + crash-recovery acceptance (Arc 3 phase 3).
|
||||
//!
|
||||
//! Each test injects a private tempdir `StateDir` (integration tests link
|
||||
//! the lib without `cfg(test)`), so nothing touches a developer's real
|
||||
//! state dir. Sweeps are driven directly rather than through the timer,
|
||||
//! so the 1-second interval floor never slows the suite.
|
||||
//!
|
||||
//! Framing: `docs/autosave-recovery-framing.md`.
|
||||
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::StateDir;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fresh_state_dir() -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pmacs-autosave-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn editor(state_dir: &std::path::Path) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
s.lua_host.lua().remove_app_data::<StateDir>();
|
||||
s.lua_host
|
||||
.lua()
|
||||
.set_app_data(StateDir(state_dir.to_path_buf()));
|
||||
s
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
/// Force a sweep; returns how many buffers were written.
|
||||
fn sweep(s: &EditorState) -> i64 {
|
||||
let (written, _blocked): (i64, i64) = eval(s, "return pmacs.autosave.sweep()");
|
||||
written
|
||||
}
|
||||
|
||||
/// Force a sweep; returns `(written, blocked)`.
|
||||
fn sweep2(s: &EditorState) -> (i64, i64) {
|
||||
eval(s, "return pmacs.autosave.sweep()")
|
||||
}
|
||||
|
||||
/// Force a sweep; returns `(written, blocked, conflicted)`.
|
||||
fn sweep3(s: &EditorState) -> (i64, i64, i64) {
|
||||
eval(s, "return pmacs.autosave.sweep()")
|
||||
}
|
||||
|
||||
/// Open `path`, dirty it, then open a SECOND buffer on the same path via
|
||||
/// `from_file` (which does not dedup) and dirty that differently.
|
||||
/// Returns with the duplicate active.
|
||||
fn two_buffers_one_path(s: &EditorState, path: &str) {
|
||||
exec(s, &format!("_G.a = pmacs.buffer.find_or_open({path:?})"));
|
||||
exec(s, "pmacs.window.buffer():insert(0, 'AAA ')");
|
||||
exec(s, &format!("_G.b = pmacs.buffer.from_file({path:?})"));
|
||||
exec(s, "pmacs.window.buffer():insert(0, 'BBB ')");
|
||||
}
|
||||
|
||||
fn recovered(s: &EditorState, path: &str) -> Vec<u8> {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
&format!("return pmacs.autosave._recover_bytes({path:?})"),
|
||||
);
|
||||
b.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn status(s: &EditorState, path: &str) -> String {
|
||||
eval(s, &format!("return pmacs.autosave._status({path:?})"))
|
||||
}
|
||||
|
||||
/// Open a file and dirty it by `n` inserted bytes at the front.
|
||||
fn open_and_dirty(s: &EditorState, path: &str, text: &str) {
|
||||
exec(
|
||||
s,
|
||||
&format!("pmacs.buffer.find_or_open({path:?}); pmacs.window.buffer():insert(0, {text:?})"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_writes_recovery_for_a_modified_file_buffer() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s, &f, "unsaved ");
|
||||
assert_eq!(sweep(&s), 1, "one modified file buffer written");
|
||||
assert_eq!(status(&s, &f), "fresh");
|
||||
|
||||
// The recovery contents are the buffer's, not the file's.
|
||||
let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})"));
|
||||
assert_eq!(&*bytes.as_bytes(), b"unsaved on disk\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trips_non_utf8_contents() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "bin.dat", "");
|
||||
// 0xff is invalid UTF-8; the envelope reads bytes, not a String.
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"pmacs.buffer.find_or_open({f:?}); pmacs.window.buffer():insert(0, '\\255\\n\\0a')"
|
||||
),
|
||||
);
|
||||
assert_eq!(sweep(&s), 1);
|
||||
let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({f:?})"));
|
||||
assert_eq!(&*bytes.as_bytes(), &[0xffu8, b'\n', 0x00, b'a'][..]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_skips_clean_scratch_and_unchanged_buffers() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "hello\n");
|
||||
|
||||
// A clean file buffer + the scratch buffer: nothing to write.
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
assert_eq!(sweep(&s), 0, "clean buffer and scratch are skipped");
|
||||
|
||||
// Dirty it → one write. Sweeping again with no further edit → zero
|
||||
// (the (path_hash, revision) skip cache).
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'x')");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(sweep(&s), 0, "unchanged since last copy → no rewrite");
|
||||
|
||||
// Another edit → written again.
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'y')");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_change_writes_new_key_and_discards_the_old() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let old = write_file(&dir, "old.txt", "body\n");
|
||||
let new = dir.join("new.txt").display().to_string();
|
||||
open_and_dirty(&s, &old, "dirty ");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(status(&s, &old), "fresh");
|
||||
|
||||
// Rename WITHOUT editing the buffer — what an LSP WorkspaceEdit
|
||||
// rename does: the file moves on disk (preserving mtime/size) and the
|
||||
// buffer keeps its BufferId *and* its revision, only its path changes.
|
||||
std::fs::rename(&old, &new).unwrap();
|
||||
{
|
||||
let id = s.core.borrow().active_buffer_id();
|
||||
s.core
|
||||
.borrow_mut()
|
||||
.set_buffer_path(id, Some(PathBuf::from(&new)));
|
||||
}
|
||||
// A revision-only cache would skip this write, never create the
|
||||
// recovery under the new key, and orphan the old one.
|
||||
assert_eq!(sweep(&s), 1, "path change forces a rewrite");
|
||||
assert_eq!(status(&s, &new), "fresh", "new key written");
|
||||
assert_eq!(status(&s, &old), "none", "old key discarded");
|
||||
let bytes: mlua::String = eval(
|
||||
&s,
|
||||
&format!("return pmacs.autosave._recover_bytes({new:?})"),
|
||||
);
|
||||
assert_eq!(&*bytes.as_bytes(), b"dirty body\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_file_buffer_is_swept_with_null_origin_and_recovers() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
// A `[new file]`: a path with no file on disk, so no `file_meta`.
|
||||
// Lua's find_or_open *errors* on a missing path, so this is built the
|
||||
// way argv `pmacs draft.txt` does — an empty buffer with a path.
|
||||
let missing = dir.join("draft.txt");
|
||||
exec(
|
||||
&s,
|
||||
"_G.nb = pmacs.buffer.create('draft.txt'); pmacs.window.switch_buffer(_G.nb)",
|
||||
);
|
||||
{
|
||||
let id = s.core.borrow().active_buffer_id();
|
||||
s.core
|
||||
.borrow_mut()
|
||||
.set_buffer_path(id, Some(missing.clone()));
|
||||
}
|
||||
// Typing into it is what makes it modified (and worth recovering).
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'unsaved draft')");
|
||||
let p = missing.display().to_string();
|
||||
assert_eq!(sweep(&s), 1, "a new-file buffer is swept");
|
||||
// origin is null → fresh while the file is still absent.
|
||||
assert_eq!(status(&s, &p), "fresh");
|
||||
let bytes: mlua::String = eval(&s, &format!("return pmacs.autosave._recover_bytes({p:?})"));
|
||||
assert_eq!(&*bytes.as_bytes(), b"unsaved draft");
|
||||
|
||||
// Someone creates the file meanwhile → stale, never auto-offered.
|
||||
std::fs::write(&missing, b"theirs").unwrap();
|
||||
assert_eq!(status(&s, &p), "stale");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_change_makes_recovery_stale() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "original\n");
|
||||
open_and_dirty(&s, &f, "mine ");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(status(&s, &f), "fresh");
|
||||
|
||||
// Someone else edits the file on disk.
|
||||
std::fs::write(&f, b"theirs, quite different\n").unwrap();
|
||||
assert_eq!(status(&s, &f), "stale", "never auto-offered");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_recovery_is_typed_quiet_and_discardable() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
|
||||
// Plant a malformed envelope under the right key.
|
||||
let key = pmacs::autosave::key_for(std::path::Path::new(&f));
|
||||
pmacs::state::write_private(&dir, &key, b"garbage without a newline").unwrap();
|
||||
assert_eq!(status(&s, &f), "corrupt");
|
||||
|
||||
// The aggregate report must not error or announce it.
|
||||
let (fresh, corrupt): (Vec<String>, i64) = eval(&s, "return pmacs.autosave._pending()");
|
||||
assert!(fresh.is_empty(), "corrupt is never offered");
|
||||
assert_eq!(corrupt, 1, "counted separately");
|
||||
|
||||
// And it is discardable.
|
||||
exec(&s, &format!("pmacs.autosave._discard({f:?})"));
|
||||
assert_eq!(status(&s, &f), "none");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_aggregates_and_names_a_single_file() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let mut paths = Vec::new();
|
||||
for i in 0..3 {
|
||||
let f = write_file(&dir, &format!("f{i}.txt"), "body\n");
|
||||
open_and_dirty(&s, &f, "x");
|
||||
paths.push(f);
|
||||
}
|
||||
assert_eq!(sweep(&s), 3);
|
||||
let (fresh, corrupt): (Vec<String>, i64) = eval(&s, "return pmacs.autosave._pending()");
|
||||
assert_eq!(fresh.len(), 3, "all three reported in ONE call");
|
||||
assert_eq!(corrupt, 0);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_never_overwrites_unclaimed_crash_recovery() {
|
||||
let dir = fresh_state_dir();
|
||||
// Session 1 crashes with unsaved work: a recovery copy is on disk.
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s1, &f, "CRASH WORK ");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
let crash_copy = recovered(&s1, &f);
|
||||
assert_eq!(&crash_copy, b"CRASH WORK on disk\n");
|
||||
|
||||
// Session 2 reopens the file (on-disk contents) and edits it BEFORE
|
||||
// running recover-file. Sweeping must NOT clobber the crash copy.
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s2, "pmacs.window.buffer():insert(0, 'new edits ')");
|
||||
let (written, blocked) = sweep2(&s2);
|
||||
assert_eq!(written, 0, "must not write over unclaimed crash data");
|
||||
assert_eq!(blocked, 1, "the sweep is blocked and reported");
|
||||
assert_eq!(
|
||||
recovered(&s2, &f),
|
||||
crash_copy,
|
||||
"the crash recovery survives intact"
|
||||
);
|
||||
assert_eq!(status(&s2, &f), "fresh", "still offered to the user");
|
||||
|
||||
// Once recover-file adopts it, autosave resumes for that path.
|
||||
exec(&s2, "pmacs.autosave._adopt(pmacs.window.buffer())");
|
||||
// Adopt records the copy at the buffer's *current* revision, so the
|
||||
// very next sweep sees no change; an edit makes it write again.
|
||||
exec(&s2, "pmacs.window.buffer():insert(0, 'more ')");
|
||||
let (written, blocked) = sweep2(&s2);
|
||||
assert_eq!((written, blocked), (1, 0), "adopted → sweeps again");
|
||||
assert_eq!(recovered(&s2, &f), b"more new edits on disk\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_buffers_on_one_path_conflict_instead_of_corrupting() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
two_buffers_one_path(&s, &f);
|
||||
|
||||
// A recovery file is keyed by path, so only ONE of the two dirty
|
||||
// buffers can be protected. The first claims the slot; the other is
|
||||
// reported, never silently mis-protected.
|
||||
let (written, blocked, conflicted) = sweep3(&s);
|
||||
assert_eq!((written, blocked, conflicted), (1, 0, 1));
|
||||
assert_eq!(
|
||||
recovered(&s, &f),
|
||||
b"AAA on disk\n",
|
||||
"the slot's owner is what is on disk"
|
||||
);
|
||||
|
||||
// The loser must NOT be marked protected: it keeps conflicting, and
|
||||
// its contents never silently overwrite the owner's copy.
|
||||
let (written, _, conflicted) = sweep3(&s);
|
||||
assert_eq!(
|
||||
(written, conflicted),
|
||||
(0, 1),
|
||||
"owner unchanged, dup still conflicts"
|
||||
);
|
||||
exec(&s, "pmacs.window.switch_buffer(_G.b)");
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'more ')");
|
||||
let (written, _, conflicted) = sweep3(&s);
|
||||
assert_eq!(
|
||||
(written, conflicted),
|
||||
(0, 1),
|
||||
"editing the dup does not win the slot"
|
||||
);
|
||||
assert_eq!(recovered(&s, &f), b"AAA on disk\n", "owner's copy intact");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_buffers_save_does_not_retire_the_owners_recovery() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
two_buffers_one_path(&s, &f);
|
||||
assert_eq!(sweep3(&s), (1, 0, 1));
|
||||
let owner_copy = recovered(&s, &f);
|
||||
|
||||
// Save the DUPLICATE. Its cleanup must not touch the other buffer's
|
||||
// recovery — that copy is the only record of the owner's unsaved work.
|
||||
exec(&s, "pmacs.window.switch_buffer(_G.b)");
|
||||
exec(&s, "pmacs.command.invoke('buffer.save')");
|
||||
assert_ne!(status(&s, &f), "none", "the owner's recovery survives");
|
||||
assert_eq!(recovered(&s, &f), owner_copy);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_the_owner_frees_the_slot_for_the_duplicate() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
two_buffers_one_path(&s, &f);
|
||||
assert_eq!(sweep3(&s), (1, 0, 1));
|
||||
|
||||
// Killing the owner retires its copy and releases the slot; the
|
||||
// duplicate can then claim it and finally be protected.
|
||||
exec(&s, "pmacs.buffer.kill(_G.a)");
|
||||
assert_eq!(status(&s, &f), "none", "owner's copy retired with it");
|
||||
let (written, blocked, conflicted) = sweep3(&s);
|
||||
assert_eq!(
|
||||
(written, blocked, conflicted),
|
||||
(1, 0, 0),
|
||||
"dup claims the slot"
|
||||
);
|
||||
assert_eq!(recovered(&s, &f), b"BBB on disk\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_clears_the_previous_owners_stale_skip_cache() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
two_buffers_one_path(&s, &f);
|
||||
// A owns the slot; B is the conflicted duplicate.
|
||||
assert_eq!(sweep3(&s), (1, 0, 1));
|
||||
assert_eq!(recovered(&s, &f), b"AAA on disk\n");
|
||||
|
||||
// B recovers (adopts), stealing the slot. A keeps its dirty contents.
|
||||
exec(&s, "pmacs.window.switch_buffer(_G.b)");
|
||||
exec(&s, "pmacs.autosave._adopt(pmacs.window.buffer())");
|
||||
|
||||
// Now kill B without saving: the slot is freed and its file deleted.
|
||||
exec(&s, "pmacs.buffer.kill(_G.b)");
|
||||
assert_eq!(status(&s, &f), "none");
|
||||
|
||||
// A is still dirty and now unprotected. The next sweep must write it.
|
||||
// A stale `written[A]` (same hash, same revision) would make the skip
|
||||
// cache call it "unchanged since its last copy" and leave it exposed.
|
||||
let (written, blocked, conflicted) = sweep3(&s);
|
||||
assert_eq!(
|
||||
(written, blocked, conflicted),
|
||||
(1, 0, 0),
|
||||
"the old owner is re-protected once the slot frees, without an edit"
|
||||
);
|
||||
assert_eq!(recovered(&s, &f), b"AAA on disk\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_sweep_is_reported_not_swallowed() {
|
||||
let dir = fresh_state_dir();
|
||||
// Plant a regular file where the `autosave/` directory must go, so
|
||||
// every recovery write fails (stands in for ENOSPC / a read-only
|
||||
// state dir).
|
||||
std::fs::write(dir.join("autosave"), b"not a directory").unwrap();
|
||||
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "precious ");
|
||||
|
||||
// The raw sweep surfaces the error rather than returning 0 silently.
|
||||
let ok: bool = eval(&s, "return (pcall(pmacs.autosave.sweep))");
|
||||
assert!(!ok, "a write failure must not look like a successful sweep");
|
||||
|
||||
// And the quit path reports it instead of swallowing it — a failure
|
||||
// there means the quit is about to discard unprotected work.
|
||||
s.core.borrow_mut().status.clear();
|
||||
let not_vetoed: bool = eval(&s, "return pmacs.hook.run('editor.before-quit')");
|
||||
assert!(not_vetoed, "reporting must still never veto quit");
|
||||
let status = s.core.borrow().status.clone();
|
||||
assert!(
|
||||
status.contains("autosave FAILED") && status.contains("NOT being protected"),
|
||||
"the failure is surfaced: {status:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_without_recovering_preserves_unclaimed_crash_data() {
|
||||
let dir = fresh_state_dir();
|
||||
// Session 1 crashes with unsaved work.
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s1, &f, "CRASH WORK ");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
let crash_copy = recovered(&s1, &f);
|
||||
|
||||
// Session 2 reopens, edits, and SAVES — without ever recovering or
|
||||
// discarding. The save must not destroy the crash copy: only
|
||||
// recover-file (adopt) or discard-recovery may release it.
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s2, "pmacs.window.buffer():insert(0, 'new ')");
|
||||
exec(&s2, "pmacs.command.invoke('buffer.save')");
|
||||
assert_ne!(
|
||||
status(&s2, &f),
|
||||
"none",
|
||||
"saving must not delete unclaimed crash data"
|
||||
);
|
||||
assert_eq!(
|
||||
recovered(&s2, &f),
|
||||
crash_copy,
|
||||
"the crash recovery survives a save"
|
||||
);
|
||||
// It is now stale (the file changed on disk), so it is never
|
||||
// auto-offered — but it is still there to recover or discard.
|
||||
assert_eq!(status(&s2, &f), "stale");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_without_recovering_preserves_unclaimed_crash_data() {
|
||||
let dir = fresh_state_dir();
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s1, &f, "CRASH ");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
let crash_copy = recovered(&s1, &f);
|
||||
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("_G.b = pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s2, "pmacs.buffer.kill(_G.b)");
|
||||
assert_eq!(recovered(&s2, &f), crash_copy, "kill preserves it too");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_then_kill_retires_the_adopted_recovery() {
|
||||
let dir = fresh_state_dir();
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s1, &f, "crash ");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
|
||||
// Reopen, recover, then kill immediately — before any save or sweep.
|
||||
// The removal callback fires after the buffer is gone, so the only
|
||||
// way to find the copy is the entry `_adopt` recorded for its id.
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("_G.b = pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(
|
||||
&s2,
|
||||
&format!(
|
||||
"
|
||||
local bytes = pmacs.autosave._recover_bytes({f:?})
|
||||
local b = pmacs.window.buffer()
|
||||
b:replace(0, b:len(), bytes)
|
||||
pmacs.autosave._adopt(b)
|
||||
"
|
||||
),
|
||||
);
|
||||
exec(&s2, "pmacs.buffer.kill(_G.b)");
|
||||
assert_eq!(
|
||||
status(&s2, &f),
|
||||
"none",
|
||||
"an adopted recovery is retired on kill, not left to be re-offered"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_recovery_lets_the_next_sweep_reprotect_immediately() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "mine ");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(sweep(&s), 0, "unchanged → skipped");
|
||||
|
||||
// Explicitly discard while the buffer is still dirty. The next sweep
|
||||
// must re-create protection at once: a stale skip-cache entry would
|
||||
// leave the buffer unprotected until its next edit.
|
||||
exec(&s, &format!("pmacs.autosave._discard({f:?})"));
|
||||
assert_eq!(status(&s, &f), "none");
|
||||
assert_eq!(sweep(&s), 1, "protection restored without needing an edit");
|
||||
assert_eq!(recovered(&s, &f), b"mine body\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarding_an_unclaimed_recovery_unblocks_the_sweep() {
|
||||
let dir = fresh_state_dir();
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s1, &f, "crash ");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s2, "pmacs.window.buffer():insert(0, 'mine ')");
|
||||
assert_eq!(sweep2(&s2), (0, 1), "blocked");
|
||||
|
||||
exec(&s2, &format!("pmacs.autosave._discard({f:?})"));
|
||||
assert_eq!(sweep2(&s2), (1, 0), "discarded → sweeps again");
|
||||
assert_eq!(recovered(&s2, &f), b"mine on disk\n");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_a_new_file_buffer_gcs_its_recovery() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
// A `[new file]` fires no after-load, so no per-buffer removal
|
||||
// callback is registered — the sweep-time GC is the backstop.
|
||||
let missing = dir.join("draft.txt");
|
||||
exec(
|
||||
&s,
|
||||
"_G.nb = pmacs.buffer.create('draft.txt'); pmacs.window.switch_buffer(_G.nb)",
|
||||
);
|
||||
{
|
||||
let id = s.core.borrow().active_buffer_id();
|
||||
s.core
|
||||
.borrow_mut()
|
||||
.set_buffer_path(id, Some(missing.clone()));
|
||||
}
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'draft')");
|
||||
let p = missing.display().to_string();
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(status(&s, &p), "fresh");
|
||||
|
||||
exec(&s, "pmacs.buffer.kill(_G.nb)");
|
||||
// The next sweep GCs the dead buffer's recovery copy.
|
||||
sweep(&s);
|
||||
assert_eq!(
|
||||
status(&s, &p),
|
||||
"none",
|
||||
"killed new-file buffer is cleaned up"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_a_rename_removes_the_recovery_written_under_the_old_path() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let old = write_file(&dir, "old.txt", "body\n");
|
||||
let new = dir.join("new.txt").display().to_string();
|
||||
open_and_dirty(&s, &old, "dirty ");
|
||||
assert_eq!(sweep(&s), 1, "recovery written under the OLD key");
|
||||
|
||||
// Rename, then save — without an intervening sweep. A path-captured
|
||||
// cleanup would delete the new key and leave the old one behind.
|
||||
std::fs::rename(&old, &new).unwrap();
|
||||
{
|
||||
let id = s.core.borrow().active_buffer_id();
|
||||
s.core
|
||||
.borrow_mut()
|
||||
.set_buffer_path(id, Some(PathBuf::from(&new)));
|
||||
}
|
||||
exec(&s, "pmacs.command.invoke('buffer.save')");
|
||||
assert_eq!(status(&s, &old), "none", "old key removed");
|
||||
assert_eq!(status(&s, &new), "none", "new key removed");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_existing_lax_autosave_dir_is_tightened() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = fresh_state_dir();
|
||||
// Someone (an older pmacs, or the user) left autosave/ at 0755.
|
||||
let autosave_dir = dir.join("autosave");
|
||||
std::fs::create_dir_all(&autosave_dir).unwrap();
|
||||
std::fs::set_permissions(&autosave_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "secret ");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
|
||||
let dmode = std::fs::metadata(&autosave_dir)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(dmode, 0o700, "a lax autosave dir is tightened, not left");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_reports_recoveries_once_aggregated() {
|
||||
let dir = fresh_state_dir();
|
||||
// Seed three recovery copies, then "crash" and reopen the files.
|
||||
let s1 = editor(&dir);
|
||||
let mut paths = Vec::new();
|
||||
for i in 0..3 {
|
||||
let f = write_file(&dir, &format!("f{i}.txt"), "body\n");
|
||||
open_and_dirty(&s1, &f, "x");
|
||||
paths.push(f);
|
||||
}
|
||||
assert_eq!(sweep(&s1), 3);
|
||||
|
||||
let s2 = editor(&dir);
|
||||
for f in &paths {
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
}
|
||||
// Each `after-load` only raises a flag; the tick does the reporting,
|
||||
// so three loads collapse into ONE aggregate message.
|
||||
exec(&s2, "pmacs.hook.run('process.after-tick')");
|
||||
let status = s2.core.borrow().status.clone();
|
||||
assert!(
|
||||
status.contains("3 files have autosave recovery"),
|
||||
"one aggregated message, not three: {status:?}"
|
||||
);
|
||||
|
||||
// A second tick does not re-report (the flag was cleared).
|
||||
s2.core.borrow_mut().status.clear();
|
||||
exec(&s2, "pmacs.hook.run('process.after-tick')");
|
||||
assert!(s2.core.borrow().status.is_empty(), "no repeat report");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_names_the_file_when_exactly_one_is_recoverable() {
|
||||
let dir = fresh_state_dir();
|
||||
let s1 = editor(&dir);
|
||||
let f = write_file(&dir, "solo.txt", "body\n");
|
||||
open_and_dirty(&s1, &f, "x");
|
||||
assert_eq!(sweep(&s1), 1);
|
||||
|
||||
let s2 = editor(&dir);
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s2, "pmacs.hook.run('process.after-tick')");
|
||||
let status = s2.core.borrow().status.clone();
|
||||
assert!(
|
||||
status.contains("solo.txt") && status.contains("recover-file"),
|
||||
"single recovery names the file: {status:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_kill_delete_the_recovery_copy() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
|
||||
// Clean save deletes it (buffer.after-save).
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "x");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
exec(&s, "pmacs.command.invoke('buffer.save')");
|
||||
assert_eq!(status(&s, &f), "none", "clean save retires the recovery");
|
||||
|
||||
// Kill deletes it (per-buffer on_removed registered at after-load).
|
||||
let g = write_file(&dir, "b.txt", "body\n");
|
||||
exec(&s, &format!("_G.gb = pmacs.buffer.find_or_open({g:?})"));
|
||||
exec(&s, "pmacs.window.buffer():insert(0, 'x')");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
assert_eq!(status(&s, &g), "fresh");
|
||||
exec(&s, "pmacs.buffer.kill(_G.gb)");
|
||||
assert_eq!(status(&s, &g), "none", "kill retires the recovery");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_file_installs_contents_fires_after_edit_and_leaves_modified() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "on disk\n");
|
||||
open_and_dirty(&s, &f, "recovered ");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
|
||||
// Simulate the crash-then-reopen: a fresh editor over the same store,
|
||||
// opening the file whose on-disk contents are the OLD ones.
|
||||
let s2 = editor(&dir);
|
||||
exec(
|
||||
&s2,
|
||||
r#"
|
||||
_G.after_edit = 0
|
||||
pmacs.hook.add("buffer.after-edit", function() _G.after_edit = _G.after_edit + 1 end)
|
||||
"#,
|
||||
);
|
||||
exec(&s2, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
assert_eq!(status(&s2, &f), "fresh");
|
||||
// The buffer still holds the on-disk contents (no silent substitution).
|
||||
let before: mlua::String = eval(
|
||||
&s2,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
assert_eq!(&*before.as_bytes(), b"on disk\n");
|
||||
|
||||
// Drive recover-file's accept path directly (the command opens a
|
||||
// minibuffer; we exercise what its on_accept does).
|
||||
exec(
|
||||
&s2,
|
||||
&format!(
|
||||
r#"
|
||||
local bytes = pmacs.autosave._recover_bytes({f:?})
|
||||
local b = pmacs.window.buffer()
|
||||
b:replace(0, b:len(), bytes)
|
||||
pmacs.hook.run("buffer.after-edit")
|
||||
"#
|
||||
),
|
||||
);
|
||||
let after: mlua::String = eval(
|
||||
&s2,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
assert_eq!(&*after.as_bytes(), b"recovered on disk\n");
|
||||
let fired: i64 = eval(&s2, "return _G.after_edit");
|
||||
assert!(
|
||||
fired >= 1,
|
||||
"after-edit fired so LSP/syntax see the recovery"
|
||||
);
|
||||
let modified: bool = eval(&s2, "return pmacs.window.buffer():is_modified()");
|
||||
assert!(
|
||||
modified,
|
||||
"recovered buffer is dirty; user must save to keep"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_files_are_private_0600_under_a_0700_dir() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "secret.txt", "");
|
||||
open_and_dirty(&s, &f, "unsaved secret");
|
||||
assert_eq!(sweep(&s), 1);
|
||||
|
||||
let key = pmacs::autosave::key_for(std::path::Path::new(&f));
|
||||
let file = dir.join(&key);
|
||||
let fmode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(fmode, 0o600, "recovery file holds unsaved contents");
|
||||
let dmode = std::fs::metadata(dir.join("autosave"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(dmode, 0o700);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_is_a_validated_getter_setter_and_enable_gates_the_sweep() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
|
||||
let default_ms: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
|
||||
assert_eq!(default_ms, 30000, "Emacs's auto-save-timeout");
|
||||
|
||||
let set: i64 = eval(&s, "return pmacs.autosave.interval_ms(60000)");
|
||||
assert_eq!(set, 60000);
|
||||
let read_back: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
|
||||
assert_eq!(read_back, 60000, "change takes effect immediately");
|
||||
|
||||
// Floats floor; bad values error.
|
||||
let floored: i64 = eval(&s, "return pmacs.autosave.interval_ms(1500.9)");
|
||||
assert_eq!(floored, 1500);
|
||||
for bad in ["'soon'", "0", "999", "-1", "{}"] {
|
||||
let ok: bool = eval(
|
||||
&s,
|
||||
&format!("return (pcall(pmacs.autosave.interval_ms, {bad}))"),
|
||||
);
|
||||
assert!(!ok, "interval_ms({bad}) must be rejected");
|
||||
}
|
||||
// A rejected set leaves the previous value intact.
|
||||
let still: i64 = eval(&s, "return pmacs.autosave.interval_ms()");
|
||||
assert_eq!(still, 1500);
|
||||
|
||||
// enable(false) makes sweep a no-op even with a dirty buffer.
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "x");
|
||||
exec(&s, "pmacs.autosave.enable(false)");
|
||||
assert_eq!(sweep(&s), 0, "disabled → no sweep");
|
||||
assert_eq!(status(&s, &f), "none");
|
||||
exec(&s, "pmacs.autosave.enable(true)");
|
||||
assert_eq!(sweep(&s), 1, "re-enabled → sweeps");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_quit_sweeps_synchronously_without_vetoing() {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, "a.txt", "body\n");
|
||||
open_and_dirty(&s, &f, "unsaved ");
|
||||
// Not swept yet.
|
||||
assert_eq!(status(&s, &f), "none");
|
||||
|
||||
// before-quit is short-circuit: a `true` result means "not vetoed".
|
||||
let not_vetoed: bool = eval(&s, "return pmacs.hook.run('editor.before-quit')");
|
||||
assert!(not_vetoed, "autosave must never veto quit");
|
||||
assert_eq!(
|
||||
status(&s, &f),
|
||||
"fresh",
|
||||
"quitting with unsaved changes leaves a recovery copy"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
Loading…
Reference in New Issue