feat(process): group lifecycle + null stdin; buf:revision(); jump_back hook parity

Supervisor (Q#CM3, framing additions 1-2): ProcessSpec gains
stdin="null" (Stdio::null, no writer thread, immediate EOF) and
group=true — process_group(0) spawn, group-directed fatal signals,
liveness-probed TERM-to-KILL reap ledger (insert-if-absent arming,
per-tick kill(-pgid,0) probe, GROUP_TERM_GRACE=500ms), leader-exit
group TERM before the final drain with in-drain deadline enforcement
plus ESRCH quiescence window and absolute cancel cap, poll-based
cancellable readers (nix poll feature added), shutdown ledger
force-kill + probe-to-ESRCH, maybe_restart gated once shut_down.
Both options are pipe-mode-only and rejected at spawn under PTY.
Nine unit tests cover framing acceptance 34, including the
TERM-ignoring redirected survivor, the pipe-holding descendant tick
bound, and setsid-escapee resource reclamation via the per-runtime
active-reader counter.

Bindings (additions 3-4): buf:revision() exposes the edit revision
(bumped by edit/undo/redo — unit-pinned); pmacs.editor.jump_back now
fires buffer.after-switch exactly when the jump changed buffers,
matching pmacs.window.switch_buffer; pmacs.process.spawn parses the
stdin/group spec keys.

Deviation from the framing letter, called out for review: the
active-reader counter field is always present (one Arc + two atomics
per reader lifetime) rather than cfg(test)-gated — gating the field
would spread cfg attributes through every construction site; only
the probe accessor is test-gated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-13 14:36:20 +01:00
parent 9a9774fc39
commit a7d5a6fedf
3 changed files with 914 additions and 63 deletions

View File

@ -154,9 +154,9 @@ tree-sitter-cpp = "0.23"
# maintenance status — pick the upstream one. # maintenance status — pick the upstream one.
tree-sitter-md = "0.5" tree-sitter-md = "0.5"
# T M4.4 process supervisor: signal sending without `unsafe`. Keep # T M4.4 process supervisor: signal sending without `unsafe`. Keep
# the feature surface tight to keep build time low (no syscalls # the feature surface tight to keep build time low. `poll` feeds the
# beyond `kill(2)` for v0.1). # compile-mode group readers (cancellable poll-based reads, Q#CM3).
nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket"] } nix = { version = "0.29", default-features = false, features = ["signal", "user", "fs", "term", "socket", "poll"] }
# T M4.4 PTY mode: portable abstraction over openpty / fork+exec # T M4.4 PTY mode: portable abstraction over openpty / fork+exec
# with controlling-tty wiring. The crate uses internal `unsafe` # with controlling-tty wiring. The crate uses internal `unsafe`
# but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"` # but exposes a fully safe API; pmacs's own `unsafe_code = "forbid"`

View File

@ -1181,6 +1181,17 @@ fn add_query_methods<M: UserDataMethods<BufferIdLua>>(methods: &mut M) {
}) })
}); });
// Edit revision: bumped by every edit, undo, and redo. The
// compile-mode external-edit guard (Q#CM2) records this after
// each of its own writes and resyncs on mismatch — byte length
// is not an edit-integrity token (a same-length replace changes
// content while preserving length).
methods.add_method("revision", |lua, this, ()| {
with_registry(lua, |r| {
i64::try_from(resolve(r, this.0)?.revision()).map_err(mlua::Error::external)
})
});
methods.add_method("is_modified", |lua, this, ()| { methods.add_method("is_modified", |lua, this, ()| {
with_registry(lua, |r| Ok(resolve(r, this.0)?.is_modified())) with_registry(lua, |r| Ok(resolve(r, this.0)?.is_modified()))
}); });
@ -6974,6 +6985,28 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
.ok() .ok()
.flatten() .flatten()
.unwrap_or(false); .unwrap_or(false);
// Compile-mode process shape (Q#CM3). Both options are
// pipe-mode-only; the supervisor rejects them at spawn under PTY
// so misconfiguration surfaces as a spawn error, not silence.
let stdin = match table
.get::<Option<String>>("stdin")
.ok()
.flatten()
.as_deref()
{
None | Some("piped") => crate::process::StdinMode::Piped,
Some("null") => crate::process::StdinMode::Null,
Some(other) => {
return Err(mlua::Error::external(format!(
"stdin must be \"piped\" or \"null\"; got {other:?}"
)));
}
};
let group = table
.get::<Option<bool>>("group")
.ok()
.flatten()
.unwrap_or(false);
Ok(ProcessSpec { Ok(ProcessSpec {
label, label,
command, command,
@ -6983,6 +7016,8 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
mode, mode,
restart, restart,
ansi_events, ansi_events,
stdin,
group,
}) })
} }
@ -10796,7 +10831,25 @@ fn install_motion(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<
let cc = core.clone(); let cc = core.clone();
editor.set( editor.set(
"jump_back", "jump_back",
lua.create_function(move |_, ()| Ok(cc.borrow_mut().jump_back()))?, lua.create_function(move |lua, ()| {
let (jumped, buffer_changed) = {
let mut core = cc.borrow_mut();
let before = core.active_buffer_id();
let jumped = core.jump_back();
(jumped, core.active_buffer_id() != before)
};
// Parity with `pmacs.window.switch_buffer` (compile-mode
// additions #3): a jump that lands in another buffer
// clears the destination window's overlays exactly like
// any other switch, so overlay subscribers need the same
// re-attach signal. Without this, RET → M-, permanently
// stripped a generated buffer's styling. Same-buffer
// jumps stay hook-silent.
if buffer_changed {
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
}
Ok(jumped)
})?,
)?; )?;
} }
Ok(()) Ok(())
@ -12584,6 +12637,34 @@ mod tests {
assert!(called); assert!(called);
} }
#[test]
fn buffer_revision_bumps_on_edit_undo_and_redo() {
// Compile-mode's external-edit guard (Q#CM2) leans on all
// three bump sources: a same-length replace changes content
// without changing length, and undo/redo are exactly the
// mutations the guard exists to catch.
let (lua, _reg, _cmds, _kms, _hks) = fresh();
let ok: bool = lua
.load(
r#"
local b = pmacs.buffer.from_bytes("rev", "abcd")
local r0 = b:revision()
b:insert(4, "e")
local r1 = b:revision()
b:replace(0, 1, "X") -- same-length replace still bumps
local r2 = b:revision()
assert(b:undo(), "undo applies")
local r3 = b:revision()
assert(b:redo(), "redo applies")
local r4 = b:revision()
return r1 > r0 and r2 > r1 and r3 > r2 and r4 > r3
"#,
)
.eval()
.unwrap();
assert!(ok, "revision must be strictly monotonic across edit/undo/redo");
}
#[test] #[test]
fn buffer_remove_prunes_buffer_local_keymaps() { fn buffer_remove_prunes_buffer_local_keymaps() {
let (lua, _reg, _cmds, kms, _hks) = fresh(); let (lua, _reg, _cmds, kms, _hks) = fresh();

File diff suppressed because it is too large Load Diff