diff --git a/README.md b/README.md index bf72a53..515c259 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ pmacs --gpu README.md # default instance; open one file pmacs --gpu --socket NAME FILE # named instance; bare NAME → # /pmacs/NAME.sock pmacs --gpu -- --leading-dash # `--` ends option parsing +``` `pmacs --gpu` requires the root `pmacs` binary to be built with the `crdt` feature. It discovers a sibling `pmacs-gpu` binary first, then diff --git a/docs/active-work.md b/docs/active-work.md index 057c0d8..4baa59c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -64,17 +64,18 @@ If it does not, stop and repair the remote/fetch configuration. (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`; integrated, smoke-tested, fully - gated, and published on 2026-07-23 at protocol v20. PR #148 is open for user - review: `https://github.com/levineuwirth/pmacs/pull/148`. + gated, and published on 2026-07-23 at protocol v20. PR #148 review fixes are + complete locally and awaiting publication; PR: + `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. -- Verification after current-main integration: formatting and strict Clippy; - 1,800 default + 1,976 CRDT library tests; target gate 1 default + 13 CRDT; - M4 121; required GPU 152; Vterm Stage 3 5 default + 7 CRDT; workspace sweep - 3,268 across 87 suites. - A coherent release launch displayed `README.md` first at protocol v20. +- 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. - 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 b4c52bf..4a01325 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,11 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-23, after GPU initial-target implementation completed -on branch `gpu-initial-target` (protocol v20, PR pending), 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-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).** 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` @@ -22,7 +23,7 @@ commands, read `docs/active-work.md` immediately after this file. - `main` @ `47581f4` (web grammars #146 atop folding Stage 1 #142, inline-math framing #145, and one-command GPU invocation #141), protocol **v19** (`SUPPORTED=[6..=19]`; v19 = terminal frames/events). -- **GPU INITIAL TARGET IMPLEMENTED — PR pending** +- **GPU INITIAL TARGET IMPLEMENTED — PR #148 under user review** (`docs/gpu-initial-target-framing.md` rev 3; branch `gpu-initial-target`). `pmacs --gpu [--socket NAME|PATH] FILE` now transports exact Unix path bytes plus launcher cwd to the managed GPU client. Protocol v20 adds a @@ -30,12 +31,14 @@ 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 - replicas, and sends the target snapshot before readiness. Failed bootstrap - removes the provisional session 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, 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. - **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 c93ad68..96ecd9a 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -7,8 +7,8 @@ the implementation base includes folding Stage 1 through `c49a8c7`.** Revision 3 records the completed implementation and verification. Revision 2 pinned launcher-owned tilde expansion, required `after-switch` even when dedup selects the view's existing buffer, failed bootstrap when a hook kills the -target, and recorded the deliberate stderr-only wait during slow pre-window -bootstrap. It also sharpened the observed argv panic and negotiated +target, and kept slow pre-window bootstrap terminal-only (no graphical +progress surface). It also sharpened the observed argv panic and negotiated protocol-version echo. One-command GPU startup landed in #141: @@ -344,13 +344,12 @@ daemon detail. Managed startup returns nonzero; root reflects that status. The daemon itself remains alive, whether reused or newly spawned. Because the connector waits before winit creates a window, slow dispatcher -work has no graphical “Connecting…” surface. This is deliberate for the -explicit terminal command: before blocking, `pmacs-gpu` writes one bounded, -lossy-display-only `opening …` notice to stderr. There is no second target -timeout beyond #141's bounded daemon-start retry; file I/O and user hooks may -legitimately exceed five seconds, and timing out the client would not cancel -dispatcher work. Ctrl-C remains the escape hatch and still cannot reach the -isolated daemon process group. +work has no graphical “Connecting…” surface. The managed GPU child waits +silently and reports a failure on stderr if bootstrap fails. There is no +second target timeout beyond #141's bounded daemon-start retry; file I/O and +user hooks may legitimately exceed five seconds, and timing out the client +would not cancel dispatcher work. Ctrl-C remains the escape hatch and still +cannot reach the isolated daemon process group. ### Q#GT9 — Failure cleanup never creates a ghost session @@ -524,8 +523,9 @@ process behavior. pointed usage error rather than panic or replacement characters. 3. **Private GPU grammar:** managed and headless modes accept either their unchanged no-target arity or exactly `--initial-target CWD FILE`; missing, - trailing, duplicated, or relative-cwd forms exit 2. An option-like `FILE` - after the marker remains literal. + trailing, or duplicated forms exit 2. An option-like `FILE` after the marker + remains literal. A relative `CWD` is syntactically accepted and then fails + the daemon's bootstrap validation before readiness. 4. **v20 wire and legacy pins:** bootstrap request/result round trips preserve arbitrary Unix bytes and enforce bounds. Every pinned v6–v19 encoding stays unchanged; the new result discriminant is appended. The supported ladder is @@ -616,20 +616,25 @@ Also rerun the touched GPU invocation suite, protocol/transport tests, and Vterm Stage 3 acceptance in default and CRDT configurations where the suite supports both. The final full workspace sweep remains required before PR. -As-built verification on 2026-07-23: +The named gate intentionally reuses the managed-lifecycle acceptance module, +so a workspace sweep executes those 13 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: - `cargo fmt --check` and strict workspace Clippy passed. -- Library gates passed 1,800 default and 1,976 CRDT tests. +- 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. - 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 workspace CRDT sweep passed 3,268 tests across 87 suites, with 29 ignored - and the documented basedpyright case filtered. -- A coherent release build launched `target/release/pmacs --gpu --socket - initial-target-smoke README.md` on the real Wayland/Vulkan workstation, - attached at protocol v20, and displayed README rather than scratch. +- The isolated-config workspace CRDT sweep passed 3,269 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 + remained visible on their own buffer after the second target attached. ## Deferred (named) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index d28994b..7dd97a9 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -478,10 +478,7 @@ fn read_initial_target_bootstrap( other => { return Err(AttachClientError::InitialTargetProtocol(format!( "unexpected {} before target readiness", - match other { - InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", - _ => "instance message", - } + crate::instance_message_label(&other) ))); } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a640947..29acbe3 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -593,6 +593,7 @@ fn decimal_digits(mut n: usize) -> u32 { } fn main() { + env_logger::init(); let mode = match parse_args(&std::env::args_os().skip(1).collect::>()) { Ok(mode) => mode, Err(error) => { @@ -861,8 +862,7 @@ fn run_headless_managed_probe( use std::sync::mpsc; use std::time::{Duration, Instant}; - let (event_tx, event_rx) = mpsc::channel::(); - let connector_tx = event_tx.clone(); + let (connector_tx, event_rx) = mpsc::channel::(); let managed = match attach::connect_managed_with_target_and_sink( socket, daemon_executable, @@ -878,9 +878,10 @@ fn run_headless_managed_probe( } }; let mut client = managed.client; - if let Some(message) = client.take_initial_message() { - let _ = event_tx.send(AttachEvent::Message(Box::new(message))); - } + let initial_target_ready = matches!( + client.take_initial_message(), + Some(InstanceMessage::BufferSnapshot { .. }) + ); let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -895,12 +896,22 @@ fn run_headless_managed_probe( .expect("spawn managed probe stdin reader"); let deadline = Instant::now() + Duration::from_secs(20); - let mut ready = false; + let mut ready = initial_target_ready; let mut stdin_closed = false; let mut disconnect = String::new(); let mut last_reaped = false; let mut last_wait_result = None; let mut last_disconnect = String::new(); + if ready + && let Err(error) = + write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + { + eprintln!( + "pmacs-gpu managed probe: writing {} failed: {error}", + report.display() + ); + return 5; + } loop { if stdin_rx.try_recv().is_ok() { stdin_closed = true; diff --git a/src/daemon.rs b/src/daemon.rs index baf1e35..e114464 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1085,12 +1085,12 @@ fn dispatcher_loop( .is_some_and(|s| s.negotiated_capabilities.crdt_replica) { // F29 — when a mid-session upgrade occurs, push a - // `BufferSnapshot` for the newly-CRDT-backed buffer - // to every currently-attached replica so their - // `BufferMirror`s gain an entry for it. Without - // this, replicas attached before the upgrade - // permanently fall back to v0.1 round-trip on that - // buffer. + // `BufferSnapshot` to every grid replica so its + // `BufferMirror` gains an entry for the buffer. A + // semantic replica receives it only when that replica + // is displaying this buffer: applying a foreign-buffer + // snapshot would switch the GPU window away from its + // own active view. if let Some(upgraded) = ensure_active_buffer_crdt_backed(editor, *fid) { broadcast_buffer_snapshot_to_replicas( editor, @@ -1786,25 +1786,14 @@ fn handle_session_established( crdt_snapshot: snapshot, }; if opened.publish_to_replicas { - for (peer_id, peer_stream) in streams.iter_mut() { - let is_replica = session_registry - .session_state(*peer_id) - .is_some_and(|state| state.negotiated_capabilities.crdt_replica); - if is_replica && let Err(error) = write_message(peer_stream, &snapshot_message) { - send_initial_target_failure( - &mut write_stream, - format!("cannot publish initial target snapshot to {peer_id:?}: {error}"), - ); - editor - .core - .borrow_mut() - .unregister_frontend_view(frontend_id); - return; - } - if is_replica && let Some(state) = semantic_states.get_mut(peer_id) { - state.on_buffer_snapshot_sent(opened.buffer_id); - } - } + publish_buffer_snapshot_to_replicas( + editor, + opened.buffer_id, + &snapshot_message, + session_registry, + streams, + semantic_states, + ); } if write_message(&mut write_stream, &snapshot_message).is_err() { editor @@ -2400,28 +2389,56 @@ fn broadcast_buffer_snapshot_to_replicas( streams: &mut HashMap, semantic_states: &mut HashMap, ) { - let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else { + let Some(snapshot) = export_buffer_snapshot(editor, buffer_id) else { return; }; - let msg = InstanceMessage::BufferSnapshot { + let message = InstanceMessage::BufferSnapshot { buffer_id, - crdt_snapshot: snapshot_bytes, + crdt_snapshot: snapshot, }; - for (fid, stream) in streams.iter_mut() { - let is_replica = session_registry - .session_state(*fid) - .is_some_and(|s| s.negotiated_capabilities.crdt_replica); - if !is_replica { + publish_buffer_snapshot_to_replicas( + editor, + buffer_id, + &message, + session_registry, + streams, + semantic_states, + ); +} + +fn publish_buffer_snapshot_to_replicas( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, + message: &InstanceMessage, + session_registry: &SessionRegistry, + streams: &mut HashMap, + semantic_states: &mut HashMap, +) { + for (peer_id, stream) in streams { + let Some(session) = session_registry.session_state(*peer_id) else { + continue; + }; + if !session.negotiated_capabilities.crdt_replica { continue; } - if let Err(e) = write_message(stream, &msg) { - eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}"); + if session.negotiated_capabilities.semantic_render { + let displays_buffer = editor + .core + .borrow() + .active_window_for(*peer_id) + .is_some_and(|window| window.buffer_id == buffer_id); + if !displays_buffer { + continue; + } } - // PR #120 round 2 — same reset contract as the follow path: - // the snapshot wiped this replica's buffer-scoped render - // state, so its emission baselines for the buffer die too. - if let Some(sem) = semantic_states.get_mut(fid) { - sem.on_buffer_snapshot_sent(buffer_id); + if let Err(error) = write_message(stream, message) { + eprintln!( + "pmacs: BufferSnapshot publish for {buffer_id:?} to {peer_id:?} failed: {error}" + ); + continue; + } + if let Some(semantic) = semantic_states.get_mut(peer_id) { + semantic.on_buffer_snapshot_sent(buffer_id); } } } @@ -3151,6 +3168,7 @@ mod tests { let semantic = crate::protocol::NegotiatedCapabilities { multi_frontend: true, crdt_replica: true, + semantic_render: true, }; let old_peer = FrontendId(2); @@ -3170,6 +3188,81 @@ mod tests { assert!(!peer_declared_terminal_support(®istry, FrontendId(99))); } + #[cfg(feature = "crdt")] + #[test] + fn snapshot_publication_skips_foreign_semantic_views_and_ignores_dead_peers() { + let mut editor = EditorState::new(); + let semantic_peer = FrontendId(20); + let live_grid_peer = FrontendId(21); + let dead_grid_peer = FrontendId(22); + let semantic_view = build_fresh_frontend_view(&mut editor); + editor + .core + .borrow_mut() + .register_frontend_view(semantic_peer, semantic_view); + + let semantic_caps = crate::protocol::NegotiatedCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }; + let grid_caps = crate::protocol::NegotiatedCapabilities { + semantic_render: false, + ..semantic_caps + }; + let mut registry = SessionRegistry::new(); + registry.register_session( + semantic_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, semantic_caps, 0), + ); + registry.register_session( + live_grid_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 1), + ); + registry.register_session( + dead_grid_peer, + crate::presence::SessionState::new(PROTOCOL_VERSION, grid_caps, 2), + ); + + let (semantic_server, mut semantic_client) = + UnixStream::pair().expect("semantic socketpair"); + let (live_grid_server, mut live_grid_client) = + UnixStream::pair().expect("live grid socketpair"); + let (dead_grid_server, dead_grid_client) = + UnixStream::pair().expect("dead grid socketpair"); + drop(dead_grid_client); + let mut streams = HashMap::from([ + (semantic_peer, semantic_server), + (live_grid_peer, live_grid_server), + (dead_grid_peer, dead_grid_server), + ]); + let published_buffer = crate::buffer::BufferId::from_raw(900); + let message = InstanceMessage::BufferSnapshot { + buffer_id: published_buffer, + crdt_snapshot: vec![1, 2, 3], + }; + + publish_buffer_snapshot_to_replicas( + &editor, + published_buffer, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + + let delivered: InstanceMessage = + read_message(&mut live_grid_client).expect("live grid snapshot"); + assert_eq!(delivered, message); + semantic_client + .set_read_timeout(Some(Duration::from_millis(50))) + .expect("semantic timeout"); + assert!( + read_message::(&mut semantic_client).is_err(), + "a semantic peer displaying another buffer must receive no snapshot" + ); + } + #[test] fn build_identity_includes_version_and_uptime() { let s = DaemonState::new(Some("research".into())); diff --git a/src/editor_core.rs b/src/editor_core.rs index fbcf3ae..47a7597 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -548,6 +548,9 @@ impl EditorCore { /// closing a window left others intact). pub fn unregister_frontend_view(&mut self, fid: FrontendId) { self.views.remove(&fid); + if self.active_frontend == fid { + self.active_frontend = FrontendId::LOCAL; + } } /// [`BufferId`] of the active window's buffer. @@ -3214,7 +3217,7 @@ fn normalize_buffer_path(path: PathBuf) -> PathBuf { /// `~` becomes `$HOME`; `~/x` becomes `$HOME/x`. `~user` is left /// untouched (no passwd lookup). Returns the input unchanged if it /// has no leading `~`, isn't valid UTF-8, or `$HOME` is unset. -fn expand_tilde(path: PathBuf) -> PathBuf { +pub fn expand_tilde(path: PathBuf) -> PathBuf { let Some(s) = path.to_str() else { return path; }; @@ -3713,13 +3716,16 @@ mod tests { // we don't need a fresh window allocation in this test. let local_view = s.views[&FrontendId::LOCAL].clone(); s.register_frontend_view(fid, local_view); + s.active_frontend = fid; assert!(s.active_window_for(fid).is_some()); // Unregister drops the entry; explicit lookup returns None. s.unregister_frontend_view(fid); assert!(s.active_window_for(fid).is_none()); - // LOCAL invariant survives unrelated register/unregister. + // Removing the selected frontend restores the always-registered + // LOCAL view as the ambient fallback. + assert_eq!(s.active_frontend, FrontendId::LOCAL); assert!(s.views.contains_key(&FrontendId::LOCAL)); } diff --git a/src/main.rs b/src/main.rs index aba1e32..cdb0b8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -315,21 +315,6 @@ fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, Pa (PathBuf::from("pmacs-gpu"), sibling) } -fn expand_launcher_tilde(path: &Path) -> PathBuf { - let Some(path_text) = path.to_str() else { - return path.to_owned(); - }; - if path_text == "~" { - return std::env::var_os("HOME").map_or_else(|| path.to_owned(), PathBuf::from); - } - if let Some(rest) = path_text.strip_prefix("~/") - && let Some(home) = std::env::var_os("HOME") - { - return Path::new(&home).join(rest); - } - path.to_owned() -} - fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode { if !cfg!(feature = "crdt") { eprintln!("pmacs: --gpu requires pmacs built with --features crdt"); @@ -353,7 +338,7 @@ fn run_gpu(socket: Option<&str>, file: Option<&Path>) -> ExitCode { return ExitCode::FAILURE; } }; - Some((cwd, expand_launcher_tilde(path))) + Some((cwd, pmacs::editor_core::expand_tilde(path.to_owned()))) } None => None, }; @@ -909,13 +894,16 @@ mod tests { } let home = std::env::var_os("HOME").expect("test HOME"); - assert_eq!(expand_launcher_tilde(Path::new("~")), PathBuf::from(&home)); assert_eq!( - expand_launcher_tilde(Path::new("~/notes")), + pmacs::editor_core::expand_tilde(PathBuf::from("~")), + PathBuf::from(&home) + ); + assert_eq!( + pmacs::editor_core::expand_tilde(PathBuf::from("~/notes")), PathBuf::from(home).join("notes") ); assert_eq!( - expand_launcher_tilde(Path::new("~other/notes")), + pmacs::editor_core::expand_tilde(PathBuf::from("~other/notes")), PathBuf::from("~other/notes") ); }