diff --git a/docs/active-work.md b/docs/active-work.md index 25550f4..abb2554 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 4a01325..742e093 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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 1–2 (#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 1–2 (#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; v6–v19 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]`; diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index 96ecd9a..e326671 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -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 diff --git a/src/daemon.rs b/src/daemon.rs index e114464..7421bde 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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 Result<(), String> { @@ -1604,6 +1606,11 @@ struct OpenedInitialTarget { publish_to_replicas: bool, } +struct InitialTargetSnapshot { + crdt_snapshot: Vec, + 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, String> { +) -> Result { 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, String> { +) -> Result { 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 { diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 267c330..5371438 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -281,7 +281,11 @@ mod crdt { } } - fn request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> Vec { + fn open_raw_target( + socket: &Path, + cwd: Vec, + path: Vec, + ) -> (FrontendId, UnixStream, Vec) { 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::(&mut stream).expect("raw target result"); - if matches!(first, InstanceMessage::BufferSnapshot { .. }) { + let messages = if matches!(first, InstanceMessage::BufferSnapshot { .. }) { vec![ first, read_message::(&mut stream).expect("raw opened result"), ] } else { vec![first] - } + }; + (hello.assigned_frontend_id, stream, messages) + } + + fn request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> Vec { + 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::(&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::(&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::(&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();