-- 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 -- Migrated to `autosave.interval-ms` (Q#CR8's third adopter: integer, -- validated, re-read live). The tick below re-reads the registry every -- frame — see its comment — so storage moves there instead of a -- module-local, but the wrapper's shape and its lenient coercion (F4) -- stay exactly as they were. pmacs.config.define { name = "autosave.interval-ms", description = "Milliseconds between periodic autosave sweeps.", type = "integer", default = DEFAULT_INTERVAL_MS, min = MIN_INTERVAL_MS, mutability = "live", } 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 -- the registry every frame, so a change -- through this wrapper OR a -- direct `pmacs.config.set` -- takes effect immediately, no restart. -- Legacy coercion stays lenient (F4): floor a fractional `ms` FIRST, -- then hand the registry an already-conforming integer, since -- `pmacs.config.set` itself demands exactness. The floor-then-min error -- message and threshold are unchanged. function pmacs.autosave.interval_ms(ms) if ms == nil then return pmacs.config.get("autosave.interval-ms") 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 local floored = math.floor(ms) pmacs.config.set("autosave.interval-ms", floored) return floored 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, one registry -- get (a borrowed/copied scalar, no Lua table built -- Q#CR15) and 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 >= pmacs.config.get("autosave.interval-ms") 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, }