16 KiB
Persistence — framing (Arc 3)
pmacs forgets everything on exit except minibuffer history. Reopen a
file and the cursor is at the top; restart and your open buffers,
splits, and recently-visited files are gone; a crash loses unsaved
work with no recovery. This arc adds the four classic session-
persistence features — saveplace (cursor memory), recentf
(recent files), desktop-save (buffers + layout + positions), and
autosave + crash recovery — generalizing the one persistence
pattern that already works ($XDG_STATE_HOME/pmacs/ history).
Roadmap: docs/roadmap-2026-07.md Arc 3, including its open question
"what is a 'session' in a daemon world" (Q#PS6).
What already exists (verified)
- State-dir precedent (
src/minibuffer.rs):resolve_history_dir→$XDG_STATE_HOME/pmacs/history(fallback~/.local/state/pmacs/history);load_history_file/append_history_file(newline-delimited,create_dir_allon write). Env is passed as args, never read inline — the#![forbid(unsafe_code)]discipline. But the/historysegment is baked in; there is no genericstate_dir()returning the bare.../pmacs/base (Q#PS2). - Buffer/path model:
Buffer.file_path: Option<PathBuf>(Nonefor scratch/unsaved) +file_meta: Option<FileMeta>; registryids()is stable insertion order. Open flow is Lua (pmacs.buffer.find_or_open→file_io::load_file→set_buffer_path/set_buffer_meta→buffer.after-load). Nois_specialflag — file buffers are justfile_path().is_some(). Gap:pmacs.buffer.list()yields ids with no per-idfile_path()method; onlypmacs.editor.file_path()(active buffer) reads a path. - Layout (
src/window.rs):LayoutNode = Leaf(WindowId) | Split{orientation, weights: Vec<u32>, children}; ratios are the integer weights (survive resize). Not serde;WindowIdis a process counter (not restart-stable) — a saved layout must key leaves by path + cursor + view_top and rebuild structurally. Lua window API is splits + switch only (can't read/build an arbitrary tree). - Cursor/view: per-
Windowcursor: Position+view_top;switch_active_bufferzeroes both (restore must run after open). Reads exist (pmacs.editor.cursor()byte); the only Lua setter is line-basedmove_to_line— no byte-offset setter, no realset_view_top. - File I/O (
src/file_io.rs, complete):save_atomic(temp + rename + fsync, mode-preserving),load_file,current_meta;FileMeta{mtime,size}is the external-change detector. - Cadence:
process.after-tickhook (every frame — needs amonotonic_ms()throttle) orpmacs.async+pmacs.workers.sleep(thefs.watchpattern — cleaner for a fixed interval). - Session identity:
pmacs.instance.identity()→{ instance_name, working_directory }.--socket workvs--socket personalgive distinctinstance_names;instance_nameisNonefor the default daemon / in-process (fall back to aworking_directoryhash). - Recentf / saveplace / desktop / autosave: confirmed absent.
Decisions
Q#PS1 — Hybrid: four thin Rust primitives + Lua policy
Orchestration is Lua-friendly (enumerate via pmacs.buffer.list,
observe via buffer.after-load / buffer.before-save /
editor.before-quit hooks, cadence via pmacs.async, session key via
pmacs.instance.identity). But Lua can't do the load-bearing parts.
Add exactly four Rust surfaces; keep serialization + policy + cadence
in Lua builtin modules:
state_dir()+pmacs.state.{read,write,remove,path}— a generic, path-confined key→file store under the generalized state dir, atomic-write viafile_io::save_atomic(avoids the audit-flagged rawio.open).removeis needed by autosave cleanup (Q#PS8).- per-
BufferIdfile_path()getter (desktop-save enumerates file buffers without switching to each). pmacs.editor.goto_byte(pos)+set_view_top(n)— byte-exact restore (saveplace/desktop), since switch zeroes both.pmacs.session.save_desktop()/restore_desktop()— the layout serde mirror + structural rebuild live in Rust (WindowIds aren't restart-stable; the tree can't be rebuilt from the thin Lua split API). This one primitive owns buffer-set + layout + cursor serialization end-to-end.
Q#PS2 — state_dir() generalization + pmacs.state.* (path-confined)
Factor state_dir(xdg_state, home) -> Option<PathBuf> returning
.../pmacs; rewrite resolve_history_dir as
state_dir().map(|d| d.join("history")). This is NOT a
behavior-preserving refactor at one edge (an empty XDG_STATE_HOME):
the current resolve_history_dir treats Some("") as present and
returns a relative pmacs/history (writes into the cwd — a latent
bug). state_dir() fixes this deliberately: an empty/blank
XDG_STATE_HOME is treated as absent and falls through to
~/.local/state/pmacs. A test pins the new empty-XDG behavior; history
persistence keeps working (its own tests are the guard). Bet #3 is
scored on this being the only observable change to history.
pmacs.state.{write(name,str), read(name)->str?, remove(name), path(name)} over state_dir().join(name), atomic write +
create_dir_all. Name confinement (High): a name is a relative
key — reject absolute paths, empty names, any ./.. component,
leading/trailing/// separators, and control chars; the only allowed
shape is one-or-more components of [A-Za-z0-9._-]+ joined by /
(so recentf, places, autosave/<hash> pass; ../x, /etc/x,
a//b, `` all reject). The resolved path is additionally asserted to
start with state_dir() (canonical-prefix belt). Without this,
pmacs.state would be the arbitrary-io primitive it exists to avoid.
Reads/writes are no-ops when the state dir is unconfigured —
which is the case under cfg(test) (Q#PS9), so default-on builtins
never touch a developer's real $XDG_STATE_HOME in cargo test.
Q#PS3 — Serialization: line-based text for Lua state; Rust serde for desktop
Decided up front so phase 1 doesn't discover a fifth primitive: Lua
has no public JSON codec (lua_to_json exists but only internally
for the MCP wire), and it doesn't need one. The Lua-owned state is
line-based text, the history-file shape pmacs.state already returns:
recentf is newline-delimited paths; places is one
<cursor> <view_top> <path> line per file (numbers first so the path —
which may contain spaces — is the whitespace-split remainder). The one
breaker, a newline inside a path, is pathological and named as
deferred. desktop-save serializes in Rust (pmacs.session.*, Q#PS5)
with serde_json internally — never crossing the Lua boundary — so no
Lua JSON primitive is added anywhere in the arc.
Q#PS3b — saveplace (Lua, phase 1)
builtin/runtime/saveplace.lua: on buffer.before-save and on
editor.before-quit, record path → {cursor, view_top} into the
places state file (the line format above); on buffer.after-load,
look up the active buffer's path and goto_byte + set_view_top.
LRU-cap the map (~200 files). On by default with a disable knob
(Q#PS9).
Q#PS4 — recentf (Lua, phase 1)
builtin/runtime/recentf.lua: a handler on both buffer.after-load
(first open) and buffer.after-switch (re-visiting an already-open
file buffer) moves the active buffer's path to the front of a deduped,
capped (~50) recentf state file — MRU, so re-visits refresh the
order, not just first loads. A recent-files command + binding opens
minibuffer.read over the list (the editor.switch-buffer shape) →
find_or_open. Recording is automatic; the picker is invoked on demand.
(The Arc 1b listview panel is an alternative surface if a browsable
list is wanted later.)
Q#PS5 — desktop-save (Rust pmacs.session.*, phase 2)
save_desktop() serializes { session-key, layout mirror (orientation/weights tree; leaf = {path, cursor byte, view_top}), active_leaf: usize } to state_dir()/desktop/<session-key>. Only
file buffers (file_path().is_some()) — scratch/*special* leaves
are dropped from the tree. The active window is a leaf preorder
index, not a path (Med/high): the same file can appear in multiple
leaves with different cursor/view state, so a path can't identify which
one had focus — the ordinal into the preorder leaf sequence can.
Does NOT save buffer contents. Like Emacs desktop.el, it saves
the file list + positions, not unsaved edits (that's autosave's job,
Q#PS8). A leaf whose buffer was modified at save time is recorded
informationally only — restore opens the on-disk file (clean) and
surfaces a one-line warning ("N buffers had unsaved changes when the
desktop was saved"); it never reconstructs dirty state. restore_desktop()
opens each file (find_or_open), rebuilds the split tree structurally,
sets each leaf's cursor/view_top, and focuses active_leaf.
Opt-in (auto-restore surprises): pmacs.session.desktop_mode(true)
in init.lua wires editor.before-quit → save and arms startup
restore (Q#PS7 — restore is triggered by the entry point, not inline).
Q#PS6 — Session key (the daemon question)
Key on pmacs.instance.identity(). Never use raw instance_name as
a filename (even though / is mostly constrained today, other
separators/dots aren't) — use a stable encoded key: name:<encoded>
when instance_name is set (--socket NAME), else cwd:<hash> of
working_directory (Emacs's per-directory desktop model). The
encoding must itself satisfy the Q#PS2 name confinement (it becomes
the desktop/<key> subpath), so <encoded> is a hex/percent form or
a hash, never the raw string. So --socket work and --socket personal restore different desktops; two plain pmacs sessions in
different project dirs likewise; the default daemon in one cwd shares
one desktop. Tests cover both the named-socket key and the
cwd-fallback key. No new identity is needed — the socket name already
threads to InstanceIdentity.
Q#PS7 — Restore timing (restore is deferred, never inline in init)
The startup order is the trap: EditorState::open(path) calls
EditorState::new() first, and new() loads init.lua before the
file is opened (src/editor.rs). So desktop_mode(true) running inside
init cannot know a positional file arg is coming — if it restored
inline it would clobber (or race) the file the user asked for.
Therefore desktop_mode(true) does not restore; it arms restore.
It registers intent (a flag the core reads). The startup entry point —
after new()/open() has done its file-open routing — calls
pmacs.session.restore_desktop() exactly once, and only when no
positional file arg was given (a file arg means "open this," not
"restore my desktop" — Emacs's rule). Concretely: main threads a
restore_desktop: bool (true iff armed AND no file arg) into the
post-construction step that triggers the restore. Restore never runs
from init; init only sets the mode.
Q#PS8 — autosave + crash recovery (Lua, phase 3)
A pmacs.async + sleep(N s) loop (the fs.watch pattern) writes a
recovery copy of each modified file buffer to
state_dir()/autosave/<path-hash> plus a sidecar recording the origin
path + the on-disk FileMeta. On find_or_open, if a recovery file
exists and the on-disk file's FileMeta matches the sidecar (the file
wasn't changed elsewhere), prompt to recover. A clean save/kill
deletes the recovery file via pmacs.state.remove (the Q#PS2
addition — no raw pmacs.fs.remove(pmacs.state.path(...)), which would
route around the confinement). Writes use pmacs.state.write under the
autosave/ subpath; no new primitive beyond Q#PS2.
Q#PS9 — Default-on policy + the disable knob + test inertness
saveplace + recentf: on by default (low-risk, quietly useful,
Emacs's save-place-mode/recentf-mode are commonly enabled).
desktop-save + autosave: opt-in via init.lua (auto-restore and
background writes are surprising to enable silently).
Two hard requirements on default-on:
- A clear disable knob:
pmacs.saveplace.enable(false)/pmacs.recentf.enable(false)(callable from init.lua) short-circuits the hooks. The modules read an enabled flag at the top of each handler. - No writes in
cargo test. The mechanism is already there: the state dir is configured once at startup (likehistory_dir), and that wiring is skipped undercfg(test)(the same guard that skips init.lua loading). With no state dir configured,pmacs.state.write/read/removeare no-ops (Q#PS2), so the default-on saveplace/recentf hooks fire but touch no disk — the lib suite never writes a developer's real$XDG_STATE_HOME. Acceptance tests that do exercise persistence inject a tempdir state root explicitly (theload_user_config_atprecedent).
Phasing
Three PRs; phase 1 delivers standalone value and is validated before phase 2.
- State foundation + saveplace + recentf. Rust:
state_dir()(+ empty-XDG fix + test) +pmacs.state.{read,write,remove,path}(+ name-confinement rejection tests) +goto_byte/set_view_top. Lua:saveplace.lua+recentf.lua+recent-filescommand/binding +enable(false)knobs. Acceptance (tempdir state root injected): state round-trip, confinement rejects../x/ absolute / control chars, saveplace restores byte position after reopen, recentf records + dedups + MRU-refreshes on re-visit + the picker opens; and a check that with no state dir configured the hooks write nothing. - desktop-save. Rust: per-buffer
file_path()+ layout serde mirror +pmacs.session.save_desktop/restore_desktop+ encoded session-key. Lua:desktop_modewiring (before-quit / arm-startup). Acceptance: save→restore reconstructs a nested/asymmetric split layout with correct buffers + cursors +active_leaffocus; the same-file-in-two-leaves case; named-socket vs cwd-fallback key; the no-file-arg restore gate; modified-at-save surfaces a warning and restores clean (never dirty). - autosave + crash recovery. Lua: async-timer autosave +
recovery-on-open prompt;
FileMetaexternal-change guard.
Categorical bets (score at close)
- Four Rust primitives are enough — the Lua policy fills in saveplace/recentf/autosave and only desktop-save's layout needs Rust. No fifth surface surfaces mid-arc.
- Structural layout rebuild is faithful — reopening the serde mirror reconstructs weighted split trees correctly (the untested claim; nested/asymmetric splits are the risk).
- The
state_dir()change touches history at exactly one edge — the deliberate empty-XDG_STATE_HOMEfix (Q#PS2) is the only observable difference; normal history persistence is unchanged (its tests + a new empty-XDG test are the guard). Not a pure refactor — scored on nothing else shifting. - On-by-default saveplace/recentf is unsurprising — no "why did my cursor jump" or "what's writing this file" reports.
Deferred (named, not silently dropped)
- Saving unsaved buffer content in the desktop (autosave's job).
- Named/multiple desktops per session key (one desktop per key in v1).
- Restoring window-local overlays / minor state (buffers + positions only).
- Remote/cross-machine desktop (paths are local).
- recentf as a browsable
listviewpanel (minibuffer picker in v1). - Per-project
init.luainteraction with desktop scoping (post-v0.1, perdocs/project.md). - saveplace for non-file buffers.
- Paths containing a literal newline (the line-based
places/recentfformat breaks on them; pathological, unhandled in v1).