fix(daemon): contain failed target sessions

Shut down bootstrap sockets on every dispatcher-side failure and reject
frontend events whose session state was never installed. This prevents a
lingering failed client from reaching absent render/size state.

Track target-side CRDT upgrades independently from load/create status so a
deduplicated hidden buffer is published to every existing grid replica. Add
real-daemon regressions for both failure containment and replica publication.
This commit is contained in:
Levi Neuwirth 2026-07-24 10:10:31 -04:00
parent 65e500eba1
commit be8c67c30c
5 changed files with 215 additions and 62 deletions

View File

@ -63,19 +63,19 @@ If it does not, stop and repair the remote/fetch configuration.
- Original implementation base: canonical `githubsucks/main` @ `c49a8c7`
(folding Stage 1 #142); current canonical `main` @ `47581f4` is integrated
conflict-free by merge `d6d4be6`. Protocol was v19 before this work.
- State: implementation checkpoint `2dd30ec`; review-fix checkpoint `bef1c08`.
Integrated, smoke-tested, fully gated, and published on 2026-07-23 at
protocol v20. PR #148 remains open for user review:
`https://github.com/levineuwirth/pmacs/pull/148`.
- State: implementation checkpoint `2dd30ec`; first review-fix checkpoint
`bef1c08`. Second-review fixes are complete locally, smoke-tested, and fully
gated at protocol v20; publication is next. PR #148 remains open for user
review: `https://github.com/levineuwirth/pmacs/pull/148`.
- Scope delivered: one session-scoped `pmacs --gpu [--socket …] FILE` target,
protocol-v20 semantic bootstrap, launcher-owned tilde/cwd resolution, exact
Unix path transport, pre-window target readiness, replica coherence, and the
approved behavioral acceptance matrix.
- Post-review verification: formatting and strict Clippy; 1,800 default + 1,977
CRDT library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152;
Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep 3,269 across
87 suites. Two concurrent real Wayland/Vulkan GPU windows remained on distinct
target buffers after the second attach.
- Second-review verification: formatting and strict Clippy; 1,801 default +
1,978 CRDT library tests; target gate 1 default + 14 CRDT; M4 121; required
GPU 152; Vterm Stage 3 5 default + 7 CRDT; isolated-config workspace sweep
3,272 across 87 suites. The prior two-window Wayland/Vulkan isolation smoke
remains valid; this round changes only daemon failure/publication behavior.
- Deferred unchanged: automatic GUI selection, multiple files, general
live-open commands, packaging, and remote GPU paths.

View File

@ -1,12 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-23, after GPU initial-target PR #148 review fixes and
verification completed on branch `gpu-initial-target` (protocol v20), following
one-command GPU invocation (#141), the documentation refresh (#140), Vterm
Stage 3 (#135), tab-width rendering parity (#137), locals-query processing
(#134), modeline detection (#132), mode system wiring (#129), config registry
(#127), Vterm Stages 12 (#126/#130), and completed Themes Arc 4
(#120/#124/#125).**
**Last updated: 2026-07-24, after GPU initial-target PR #148 second-review fixes
and verification completed on branch `gpu-initial-target` (protocol v20),
following one-command GPU invocation (#141), the documentation refresh (#140),
Vterm Stage 3 (#135), tab-width rendering parity (#137), locals-query
processing (#134), modeline detection (#132), mode system wiring (#129),
config registry (#127), Vterm Stages 12 (#126/#130), and completed Themes
Arc 4 (#120/#124/#125).**
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
@ -18,7 +18,7 @@ reads it the way you just did.
For volatile branches, checkpoints, verification, and recovery
commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-23)
## 1. Where the project stands (2026-07-24)
- `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142,
inline-math framing #145, and one-command GPU invocation #141), protocol
@ -31,14 +31,16 @@ commands, read `docs/active-work.md` immediately after this file.
appended `InitialTargetResult` readiness barrier; v6v19 wire encodings stay
pinned. The daemon resolves the path lexically, deduplicates or loads/creates
it in the authenticated frontend's view, runs the established load/switch
hooks, upgrades the buffer for CRDT, publishes fresh buffers to existing grid
replicas, and sends the target snapshot before readiness. Semantic replicas
receive a publication only when displaying that buffer, so a second target
launch cannot switch an existing GPU window; one dead peer cannot fail the
new session. Failed bootstrap removes the provisional session and restores
the ambient active frontend without poisoning the daemon. Existing no-target
managed launch, direct attach, TUI, and legacy protocol behavior remain
intact. See `docs/active-work.md` for the portable checkpoint and verification.
hooks, upgrades the buffer for CRDT, and publishes every target-side CRDT
upgrade to existing grid replicas before readiness. Semantic replicas receive
a publication only when displaying that buffer, so a second target launch
cannot switch an existing GPU window; one dead peer cannot fail the new
session. Failed bootstrap writes a bounded result, shuts down the socket,
removes provisional state, and restores the ambient active frontend. Any
stale event from an uninstalled session is dropped before state access.
Existing no-target managed launch, direct attach, TUI, and legacy protocol
behavior remain intact. See `docs/active-work.md` for the portable checkpoint
and verification.
- **One-command GPU invocation LANDED — #141**
(`docs/gpu-invocation-framing.md` rev 6; merge `63fbc66`; two implementation
reviews). The additive public path is `pmacs --gpu [--socket NAME|PATH]`;

View File

@ -230,7 +230,8 @@ pub struct InitialTarget {
are the authority.
- `cwd` and `path` are Unix path bytes, not text. This stage is the local Unix-
socket GPU path; it does not claim a cross-platform/remote path protocol.
- Each field is bounded to 32 KiB before allocation/use. `path` must be
- Postcard decodes each field under the transport's 16 MiB frame cap; daemon
validation then bounds each to 32 KiB before filesystem use. `path` must be
nonempty, `cwd` must be nonempty and absolute, and embedded NUL is rejected
with a bootstrap failure.
@ -550,9 +551,11 @@ process behavior.
status/path identity, accepts an edit/save through the real session, and
creates the requested file under the launcher cwd—not the daemon cwd.
10. **Open error:** a directory/permission-denied target returns a specific
failure before ready/window creation and makes root fail. An existing daemon
remains connectable; a pre-existing frontend's active buffer and contents
remain unchanged.
failure before ready/window creation and makes root fail. The daemon shuts
down that failed session's socket; a client that lingers or sends another
event cannot reach uninstalled session state. An existing daemon remains
connectable; a pre-existing frontend's active buffer and contents remain
unchanged.
11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file
without saving; target-launch frontend B opens the same normalized path and
receives A's authoritative unsaved text with the same `BufferId`, not disk
@ -561,10 +564,11 @@ process behavior.
target launches B and C open different files. Each result/snapshot pair
names its own view; a subsequent input/resize proof shows A, B, and C remain
independently usable on their original buffers.
13. **Fresh-buffer publication:** keep replica A attached, then target-launch B
onto a previously unknown file. A receives the new buffer snapshot before
any CRDT op for it; both replicas accept later operations without unknown-
buffer fallback or disconnect.
13. **Replica publication after target upgrade:** keep grid replica A attached,
then target-launch B onto a previously unknown file. Repeat for a file that
Lua loaded into a hidden, not-yet-CRDT-backed buffer before the target dedup.
A receives each buffer snapshot before any CRDT op for it; both replicas
accept later operations without unknown-buffer fallback or disconnect.
14. **Hook context and count:** fresh disk load fires `buffer.after-load` once;
dedup fires `buffer.after-switch` once even when the fresh view already
shares that exact buffer and the select itself is a no-op; missing-file
@ -617,20 +621,20 @@ Vterm Stage 3 acceptance in default and CRDT configurations where the suite
supports both. The final full workspace sweep remains required before PR.
The named gate intentionally reuses the managed-lifecycle acceptance module,
so a workspace sweep executes those 13 CRDT cases under both test-binary
so a workspace sweep executes those 14 CRDT cases under both test-binary
names. The duplicate runtime is retained to keep the approved named command
and the complete #141 lifecycle fixture coverage together.
Post-review verification on 2026-07-23:
Post-second-review verification on 2026-07-24:
- `cargo fmt --check` and strict workspace Clippy passed.
- Library gates passed 1,800 default and 1,977 CRDT tests.
- The named initial-target gate passed 1 default and 13 CRDT tests; the
underlying GPU invocation suite passed 13 CRDT tests.
- Library gates passed 1,801 default and 1,978 CRDT tests.
- The named initial-target gate passed 1 default and 14 CRDT tests; the
underlying GPU invocation suite passed 14 CRDT tests.
- M4 passed 121 tests with the documented basedpyright skip; required real-GPU
tests passed 152.
- Vterm Stage 3 passed 5 default and 7 CRDT tests.
- The isolated-config workspace CRDT sweep passed 3,269 tests across 87 suites,
- The isolated-config workspace CRDT sweep passed 3,272 tests across 87 suites,
with 29 ignored and the documented basedpyright case filtered.
- A coherent release build launched two concurrent real Wayland/Vulkan GPU
windows on one daemon, targeting distinct `alpha` and `beta` files. Both

View File

@ -51,6 +51,7 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::io::ErrorKind;
use std::net::Shutdown;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
@ -655,6 +656,7 @@ fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into<Strin
message: bounded_initial_target_error(message.into()),
};
let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result));
let _ = stream.shutdown(Shutdown::Both);
}
fn validate_initial_target(target: &InitialTarget) -> Result<(), String> {
@ -1604,6 +1606,11 @@ struct OpenedInitialTarget {
publish_to_replicas: bool,
}
struct InitialTargetSnapshot {
crdt_snapshot: Vec<u8>,
upgraded_to_crdt: bool,
}
fn resolve_initial_target(target: InitialTarget) -> PathBuf {
let cwd = PathBuf::from(OsString::from_vec(target.cwd));
let path = PathBuf::from(OsString::from_vec(target.path));
@ -1674,30 +1681,35 @@ fn open_initial_target(
fn initial_target_snapshot(
editor: &EditorState,
buffer_id: crate::buffer::BufferId,
) -> Result<Vec<u8>, String> {
) -> Result<InitialTargetSnapshot, String> {
let core = editor.core.borrow();
let mut registry = core.registry.borrow_mut();
let buffer = registry
.get_mut(buffer_id)
.map_err(|error| format!("initial target buffer disappeared: {error}"))?;
if !buffer.is_crdt_backed() {
let upgraded_to_crdt = !buffer.is_crdt_backed();
if upgraded_to_crdt {
let peer_id = crate::crdt::peer_id_from_frontend(FrontendId::LOCAL);
buffer
.upgrade_to_crdt(peer_id)
.map_err(|error| format!("initial target CRDT upgrade failed: {error:?}"))?;
}
buffer
let crdt_snapshot = buffer
.crdt_state()
.ok_or_else(|| "initial target CRDT state is unavailable".to_owned())?
.export_snapshot()
.map_err(|error| format!("initial target snapshot export failed: {error:?}"))
.map_err(|error| format!("initial target snapshot export failed: {error:?}"))?;
Ok(InitialTargetSnapshot {
crdt_snapshot,
upgraded_to_crdt,
})
}
#[cfg(not(feature = "crdt"))]
fn initial_target_snapshot(
_editor: &EditorState,
_buffer_id: crate::buffer::BufferId,
) -> Result<Vec<u8>, String> {
) -> Result<InitialTargetSnapshot, String> {
Err("initial target requires a CRDT-enabled daemon".to_owned())
}
@ -1714,7 +1726,9 @@ fn cleanup_provisional_session(
) {
render_states.remove(&frontend_id);
semantic_states.remove(&frontend_id);
streams.remove(&frontend_id);
if let Some(stream) = streams.remove(&frontend_id) {
let _ = stream.shutdown(Shutdown::Both);
}
term_sizes.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id);
@ -1770,7 +1784,7 @@ fn handle_session_established(
let negotiated_protocol_version = session_state.negotiated_protocol_version;
if let Some(opened) = opened_target.as_ref() {
let snapshot = match initial_target_snapshot(editor, opened.buffer_id) {
let target_snapshot = match initial_target_snapshot(editor, opened.buffer_id) {
Ok(snapshot) => snapshot,
Err(message) => {
send_initial_target_failure(&mut write_stream, message);
@ -1783,9 +1797,9 @@ fn handle_session_established(
};
let snapshot_message = InstanceMessage::BufferSnapshot {
buffer_id: opened.buffer_id,
crdt_snapshot: snapshot,
crdt_snapshot: target_snapshot.crdt_snapshot,
};
if opened.publish_to_replicas {
if opened.publish_to_replicas || target_snapshot.upgraded_to_crdt {
publish_buffer_snapshot_to_replicas(
editor,
opened.buffer_id,
@ -1796,6 +1810,7 @@ fn handle_session_established(
);
}
if write_message(&mut write_stream, &snapshot_message).is_err() {
let _ = write_stream.shutdown(Shutdown::Both);
editor
.core
.borrow_mut()
@ -1888,6 +1903,10 @@ fn handle_dispatcher_event(
);
}
DispatcherEvent::FrontendEvent { source, event } => {
if session_registry.session_state(source).is_none() {
eprintln!("pmacs: dropping frontend event from uninstalled session {source:?}");
return;
}
match event {
FrontendEvent::Detach(_) => {
// The per-attach thread will follow up with a
@ -2102,9 +2121,12 @@ fn handle_dispatcher_event(
}
}
_ => {
let term_size = *term_sizes
.get(&source)
.expect("term_size present for source");
let Some(&term_size) = term_sizes.get(&source) else {
eprintln!(
"pmacs: dropping frontend event without size state for {source:?}"
);
return;
};
let mut term_size = term_size;
if let Some(render_state) = render_states.get_mut(&source) {
apply_event(editor, source, event, &mut term_size, render_state);
@ -2123,10 +2145,8 @@ fn handle_dispatcher_event(
// nothing before B1.)
apply_semantic_input_event(editor, source, event, term_size);
} else {
debug_assert!(
false,
"fid with neither a render_state nor a semantic_state \
sent a frontend event"
eprintln!(
"pmacs: dropping frontend event without render state for {source:?}"
);
}
}
@ -3168,7 +3188,6 @@ mod tests {
let semantic = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let old_peer = FrontendId(2);
@ -3263,6 +3282,44 @@ mod tests {
);
}
#[test]
fn frontend_events_from_uninstalled_sessions_are_dropped_without_state_access() {
let source = FrontendId(77);
let mut editor = EditorState::new();
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
let mut streams = HashMap::new();
let mut term_sizes = HashMap::new();
let mut last_dispatch_idle_sent = HashMap::new();
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source,
event: FrontendEvent::Key(crate::protocol::KeyEvent {
frontend_id: source,
key: crate::protocol::Key::Char('x'),
mods: crate::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
assert_eq!(editor.core.borrow().active_frontend, FrontendId::LOCAL);
assert!(term_sizes.is_empty());
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));
@ -3654,6 +3711,18 @@ mod tests {
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
session_registry.register_session(
source,
crate::presence::SessionState::new(
PROTOCOL_VERSION,
crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: false,
semantic_render: false,
},
0,
),
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {

View File

@ -281,7 +281,11 @@ mod crdt {
}
}
fn request_raw_target(socket: &Path, cwd: Vec<u8>, path: Vec<u8>) -> Vec<InstanceMessage> {
fn open_raw_target(
socket: &Path,
cwd: Vec<u8>,
path: Vec<u8>,
) -> (FrontendId, UnixStream, Vec<InstanceMessage>) {
let mut stream = UnixStream::connect(socket).expect("connect raw target frontend");
stream
.set_read_timeout(Some(Duration::from_secs(5)))
@ -310,14 +314,19 @@ mod crdt {
.expect("send raw target");
let first = read_message::<InstanceMessage>(&mut stream).expect("raw target result");
if matches!(first, InstanceMessage::BufferSnapshot { .. }) {
let messages = if matches!(first, InstanceMessage::BufferSnapshot { .. }) {
vec![
first,
read_message::<InstanceMessage>(&mut stream).expect("raw opened result"),
]
} else {
vec![first]
}
};
(hello.assigned_frontend_id, stream, messages)
}
fn request_raw_target(socket: &Path, cwd: Vec<u8>, path: Vec<u8>) -> Vec<InstanceMessage> {
open_raw_target(socket, cwd, path).2
}
fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child {
@ -666,8 +675,8 @@ mod crdt {
(cwd.clone(), vec![b'x'; 32 * 1024 + 1]),
(cwd.clone(), b".".to_vec()),
];
for (bad_cwd, bad_path) in invalid {
let messages = request_raw_target(&socket, bad_cwd, bad_path);
for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() {
let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path);
assert_eq!(messages.len(), 1, "failure must send no snapshot");
match &messages[0] {
InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => {
@ -676,8 +685,20 @@ mod crdt {
}
other => panic!("expected bounded target failure, got {other:?}"),
}
assert!(
read_message::<InstanceMessage>(&mut stream).is_err(),
"failed bootstrap {index} must close its socket"
);
let _ = write_message(
&mut stream,
&FrontendEvent::Key(pmacs::protocol::KeyEvent {
frontend_id,
key: pmacs::protocol::Key::Char('x'),
mods: pmacs::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
);
}
fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor");
let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt"));
assert_eq!(survivor.replica.materialize_string(), "alive\n");
@ -685,6 +706,63 @@ mod crdt {
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
}
#[test]
fn dedup_upgrade_publishes_the_snapshot_to_preexisting_grid_replicas() {
let temp = secure_tempdir();
let config_dir = temp.path().join("pmacs");
fs::create_dir(&config_dir).expect("create config dir");
let seed_path = temp.path().join("seed.txt");
let hidden_path = temp.path().join("hidden.txt");
fs::write(&seed_path, "seed\n").expect("write seed");
fs::write(&hidden_path, "hidden\n").expect("write hidden");
fs::write(
config_dir.join("init.lua"),
format!(
"local created_hidden = false\n\
pmacs.hook.add('buffer.after-load', function()\n\
if created_hidden then return end\n\
created_hidden = true\n\
pmacs.buffer.find_or_open({hidden_path:?})\n\
end)\n"
),
)
.expect("write hidden-buffer hook");
let socket = temp.path().join("dedup-upgrade.sock");
let mut daemon = spawn_daemon(&socket, &[]);
let (_, mut grid) = attach_surviving_frontend(&socket);
let seed = attach_target(&socket, temp.path(), Path::new("seed.txt"));
loop {
match read_message::<InstanceMessage>(&mut grid).expect("grid seed publication") {
InstanceMessage::BufferSnapshot { buffer_id, .. }
if buffer_id == seed.buffer_id =>
{
break;
}
_ => {}
}
}
let hidden = attach_target(&socket, temp.path(), Path::new("hidden.txt"));
let hidden_snapshot = loop {
match read_message::<InstanceMessage>(&mut grid).expect("grid hidden publication") {
InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} if buffer_id == hidden.buffer_id => break crdt_snapshot,
_ => {}
}
};
let replica = CrdtState::new(900).expect("grid hidden replica");
replica
.import_snapshot(&hidden_snapshot)
.expect("import hidden publication");
assert_eq!(replica.materialize_string(), "hidden\n");
signal_pid(daemon.id(), Signal::SIGTERM);
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
}
#[test]
fn target_killed_by_hook_fails_closed_and_slow_hook_holds_the_ready_barrier() {
let temp = secure_tempdir();