From 2dd30ec730f8d2de60e9f7283cac3cffec2ff909 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 19:03:25 -0400 Subject: [PATCH] Implement session-scoped GPU initial targets Add protocol-v20 semantic bootstrap and readiness result framing so `pmacs --gpu FILE` opens the requested path before the GPU window becomes ready. Keep target identity scoped to the authenticated frontend, preserve legacy/no-target attach behavior, and publish fresh buffers coherently to existing replicas. Carry Unix path bytes and launcher cwd through the root broker, resolve paths lexically in the daemon, reuse or create buffers without ambient-view state, and preserve the managed daemon lifecycle from #141. Add focused parser, wire, lifecycle, hook, isolation, and real-connector acceptance coverage. --- README.md | 20 +- docs/active-work.md | 19 +- docs/agent-handoff.md | 41 ++- docs/gpu-initial-target-framing.md | 31 +- pmacs-gpu/src/attach.rs | 252 +++++++++++++- pmacs-gpu/src/main.rs | 238 +++++++++++--- pmacs-protocol/src/lib.rs | 13 +- pmacs-protocol/src/message.rs | 64 +++- src/daemon.rs | 339 ++++++++++++++++--- src/editor_core.rs | 2 +- src/frontend.rs | 3 + src/main.rs | 175 +++++++--- src/protocol.rs | 57 +++- tests/gpu_initial_target_acceptance.rs | 9 + tests/gpu_invocation_acceptance.rs | 418 +++++++++++++++++++++++- tests/m11_5_semantic_acceptance.rs | 3 +- tests/statusline_segments_acceptance.rs | 16 +- tests/vterm_stage3_acceptance.rs | 14 +- 18 files changed, 1494 insertions(+), 220 deletions(-) create mode 100644 tests/gpu_initial_target_acceptance.rs diff --git a/README.md b/README.md index e1c2960..bf72a53 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ coroutine-based async surface are core primitives, not bolt-ons. The editor is partitioned into a long-lived **instance** (the daemon that owns buffers, processes, and language services) and thin -**frontends** that attach over a typed protocol (currently v19). Two +**frontends** that attach over a typed protocol (currently v20). Two frontends ship today: - a **TUI** (crossterm cell grid), attachable locally over a Unix @@ -32,7 +32,7 @@ the same buffers concurrently with live cursor/selection presence. **v1.0.0 --- stable core, active development.** The v1.0 gate (the instance/frontend partition, the Lua surface, and a REPL package audited to use zero direct Rust core access) shipped some time ago. Development -since has expanded the semantic frontend protocol from v6 through v19, +since has expanded the semantic frontend protocol from v6 through v20, brought the GPU frontend near input/render parity with the TUI, and completed the LSP, editing, persistence, themes, and terminal arcs. Recent work added major modes and modeline detection, a typed configuration @@ -102,16 +102,18 @@ pmacs [FILE] # TUI; -nw reserved for when a GUI default lands GPU frontend (one command; the root binary starts or reuses the daemon): ```sh -pmacs --gpu # default instance -pmacs --gpu --socket NAME # named instance; bare NAME → +pmacs --gpu # default instance; no initial file +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 -falls back to `pmacs-gpu` on `PATH`. Closing the window detaches only -that frontend; the daemon remains available for later GPU or TUI -attaches. +falls back to `pmacs-gpu` on `PATH`. When `FILE` is present, the daemon +loads or creates it and completes startup hooks before the GPU window +appears. Closing the window detaches only that frontend; the daemon +remains available for later GPU or TUI attaches. Daemon + attached TUI frontends: @@ -148,7 +150,7 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust # the separate pmacs-gpu package feature-free while enabling CRDT in pmacs. cargo build --release --workspace --features pmacs/crdt -target/release/pmacs --gpu # one-command managed GPU launch +target/release/pmacs --gpu README.md # one-command managed GPU file launch cargo run --release -- --version # default-run selects the pmacs binary cargo test --workspace # unit + integration tests (all crates) cargo fmt --check diff --git a/docs/active-work.md b/docs/active-work.md index 70df3e5..758ab3d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -61,14 +61,19 @@ If it does not, stop and repair the remote/fetch configuration. Revision 2 checkpoint `71039d1`. - Implementation base: canonical `githubsucks/main` @ `c49a8c7` (folding Stage 1 #142 merged after the framing base); protocol v19 before this work. -- State: framing approved 2026-07-23; implementation started; no PR. -- Scope: one session-scoped `pmacs --gpu [--socket …] FILE` target, +- State: implementation complete and smoke-tested on 2026-07-23; protocol v20; + no PR yet. The first portable checkpoint and integration with current + canonical `main` are next. +- 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. -- Next: implement and smoke-test the approved contract. Automatic GUI - selection, multiple files, general live-open commands, packaging, and - remote GPU paths remain deferred. + Unix path transport, pre-window target readiness, replica coherence, and the + approved behavioral acceptance matrix. +- Verification: formatting and strict Clippy; 1,792 default + 1,968 CRDT + library tests; target gate 1 default + 13 CRDT; M4 121; required GPU 152; + Vterm Stage 3 5 default + 7 CRDT; workspace sweep 3,260 across 87 suites. + A coherent release launch displayed `README.md` first at protocol v20. +- Deferred unchanged: automatic GUI selection, multiple files, general + live-open commands, packaging, and remote GPU paths. Recovery worktree after the first push: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bc8163c..bb7335b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,11 +1,11 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-23, after one-command GPU invocation (#141) landed, -following the documentation refresh (#140), Vterm Stage 3 (#135, protocol v19 -and native GPU terminal), 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 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).** 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,6 +22,20 @@ commands, read `docs/active-work.md` immediately after this file. - `main` @ `63fbc66` (one-command GPU invocation #141 atop documentation refresh #140), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events). +- **GPU INITIAL TARGET IMPLEMENTED — PR pending** + (`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 + semantic-session `SessionBootstrapRequest` after `AttachRequest` and an + 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. - **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]`; @@ -536,13 +550,14 @@ and `range` are three INDEPENDENT capabilities — gate each. buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. -**Protocol** — encoding-breaking bumps are deliberate and versioned -(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` + -`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = -`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒ -bump + both-frontends support + acceptance. An APPENDED variant must be -guarded by a byte pin on the PREVIOUS final variant — its own round-trip -cannot detect a discriminant shift. +**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical +`main` remains `[6..=19]`; the active GPU initial-target branch is +`[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = +`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm +terminal family; v20 = semantic `SessionBootstrapRequest` plus appended +`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support + +acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS +final variant — its own round-trip cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index 9029bd5..713a374 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -1,12 +1,14 @@ # GPU initial target — session-scoped file opening framing -**Revision 2 — user-review findings resolved. Ground truth: canonical `main` -@ `4daa1b8`, protocol v19, 2026-07-23. No implementation yet.** +**Revision 3 — implemented as built on branch `gpu-initial-target`, protocol +v20, 2026-07-23. Revision 2 was approved against canonical `main` @ `4daa1b8`; +the implementation base includes folding Stage 1 through `c49a8c7`.** -Revision 2 pins launcher-owned tilde expansion, requires `after-switch` even -when dedup selects the view's existing buffer, fails bootstrap when a hook -kills the target, and records the deliberate stderr-only wait during slow -pre-window bootstrap. It also sharpens the observed argv panic and negotiated +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 protocol-version echo. One-command GPU startup landed in #141: @@ -465,7 +467,7 @@ pins the one initial target needed by `pmacs --gpu FILE`. ## Scope and touch map -Expected implementation surface: +As-built implementation surface: - `src/main.rs` - `args_os` parser, `Mode::Gpu { file, socket }`, help/grammar, exact private @@ -614,6 +616,21 @@ 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: + +- `cargo fmt --check` and strict workspace Clippy passed. +- Library gates passed 1,792 default and 1,968 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,260 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. + ## Deferred (named) - **Multiple initial files.** Needs result ordering, active choice, and partial diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 013aa15..d28994b 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -19,6 +19,7 @@ use std::collections::VecDeque; use std::fs; use std::io; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::FileTypeExt; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; @@ -30,14 +31,25 @@ use std::time::{Duration, Instant}; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities, - FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, - PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError, - is_supported_protocol_version, read_message, write_message, + FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key, + KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, + SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message, + write_message, }; use winit::event_loop::EventLoopProxy; use crate::AppEvent; +/// Private root-broker target operands, kept as exact Unix paths until the +/// protocol-v20 bootstrap frame is serialized. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitialTargetPaths { + /// Absolute launcher working directory. + pub cwd: PathBuf, + /// Launcher-expanded target path. + pub path: PathBuf, +} + /// Errors the attach client surfaces. Kept narrow on purpose: the /// hello-world fallback is the right recovery for any of these in /// session 3, so the caller's only job is to log + drop back to the @@ -57,6 +69,12 @@ pub enum AttachClientError { /// `BufferSnapshot` ever arrives and the window sits on /// `(connecting...)` forever. We reject up front instead. CapabilityMismatch { missing: Vec<&'static str> }, + /// The requested target requires the protocol-v20 bootstrap envelope. + InitialTargetUnsupported { server: u32 }, + /// The daemon rejected the target before a window was created. + InitialTargetFailed { path: PathBuf, message: String }, + /// The daemon violated the target bootstrap ordering contract. + InitialTargetProtocol(String), } impl AttachClientError { @@ -89,6 +107,20 @@ impl std::fmt::Display for AttachClientError { built with the `crdt` feature (it advertises `crdt_replica` / `semantic_render` \ only on CRDT builds; without them no BufferSnapshot is ever sent)" ), + Self::InitialTargetUnsupported { server } => write!( + f, + "initial target requires daemon protocol v20, but the live daemon speaks v{server}" + ), + Self::InitialTargetFailed { path, message } => { + write!( + f, + "could not open initial target {}: {message}", + path.display() + ) + } + Self::InitialTargetProtocol(message) => { + write!(f, "invalid initial-target bootstrap: {message}") + } } } } @@ -395,6 +427,67 @@ impl Outbox { /// classified under rule (iii) as deferred — a structural answer /// belongs with Q#2's minimap variant or its own protocol thread, /// not session 3's attach loop. +fn read_initial_target_bootstrap( + stream: &mut UnixStream, + display_path: PathBuf, +) -> Result { + let mut snapshot = None; + loop { + let message: InstanceMessage = + read_message(stream).map_err(AttachClientError::Handshake)?; + match message { + candidate @ InstanceMessage::BufferSnapshot { .. } if snapshot.is_none() => { + snapshot = Some(candidate); + } + InstanceMessage::BufferSnapshot { .. } => { + return Err(AttachClientError::InitialTargetProtocol( + "received more than one target snapshot".to_owned(), + )); + } + InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) => { + let Some(snapshot) = snapshot else { + return Err(AttachClientError::InitialTargetProtocol( + "Opened arrived before BufferSnapshot".to_owned(), + )); + }; + let InstanceMessage::BufferSnapshot { + buffer_id: snapshot_buffer, + .. + } = snapshot + else { + unreachable!("bootstrap snapshot variant checked above"); + }; + if snapshot_buffer != buffer_id { + return Err(AttachClientError::InitialTargetProtocol(format!( + "Opened named {buffer_id:?}, snapshot named {snapshot_buffer:?}" + ))); + } + return Ok(snapshot); + } + InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { + return Err(AttachClientError::InitialTargetFailed { + path: display_path, + message, + }); + } + InstanceMessage::Goodbye(reason) => { + return Err(AttachClientError::InitialTargetProtocol(format!( + "daemon closed bootstrap: {reason:?}" + ))); + } + other => { + return Err(AttachClientError::InitialTargetProtocol(format!( + "unexpected {} before target readiness", + match other { + InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + _ => "instance message", + } + ))); + } + } + } +} + pub fn connect( socket_path: &Path, proxy: EventLoopProxy, @@ -418,11 +511,16 @@ pub fn connect_with_sink( sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; - connect_stream_with_sink(stream, sink) + connect_stream_with_sink(stream, None, sink) } +#[allow( + clippy::too_many_lines, + reason = "the synchronous handshake and thread startup remain one ordered transport transaction" +)] fn connect_stream_with_sink( stream: UnixStream, + initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { // Hello round-trip. @@ -453,6 +551,12 @@ fn connect_stream_with_sink( return Err(AttachClientError::CapabilityMismatch { missing }); } + if initial_target.is_some() && hello.protocol_version < 20 { + return Err(AttachClientError::InitialTargetUnsupported { + server: hello.protocol_version, + }); + } + // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). @@ -477,6 +581,21 @@ fn connect_stream_with_sink( }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; + let target_display_path = initial_target.as_ref().map(|target| target.path.clone()); + if hello.protocol_version >= 20 { + let bootstrap = SessionBootstrapRequest { + initial_target: initial_target.map(|target| InitialTarget { + cwd: target.cwd.as_os_str().as_bytes().to_vec(), + path: target.path.as_os_str().as_bytes().to_vec(), + }), + }; + write_message(&mut handshake_stream, &bootstrap).map_err(AttachClientError::Handshake)?; + } + let initial_message = match target_display_path { + Some(path) => Some(read_initial_target_bootstrap(&mut handshake_stream, path)?), + None => None, + }; + // Split read/write halves for the reader thread + writer thread. // UnixStream clones share the underlying FD with independent // buffer state — safe to read on one clone while the other writes @@ -563,27 +682,33 @@ fn connect_stream_with_sink( shutdown_handle, frontend_id: hello.assigned_frontend_id, server_protocol_version: hello.protocol_version, + initial_message, }) } const MANAGED_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); const MANAGED_RETRY_INTERVAL: Duration = Duration::from_millis(50); -/// Connect to an existing semantic daemon or start the supplied daemon first. -pub fn connect_managed( +/// Managed attach carrying an optional pre-window initial target. +pub fn connect_managed_with_target( socket_path: &Path, daemon_executable: &Path, + initial_target: Option, proxy: EventLoopProxy, ) -> Result { - connect_managed_with_sink(socket_path, daemon_executable, move |event| { - proxy.send_event(AppEvent::Attach(event)).is_ok() - }) + connect_managed_with_target_and_sink( + socket_path, + daemon_executable, + initial_target, + move |event| proxy.send_event(AppEvent::Attach(event)).is_ok(), + ) } -/// Managed attach with a caller-provided decoded-event sink. -pub fn connect_managed_with_sink( +/// Managed attach with both a target and caller-provided event sink. +pub fn connect_managed_with_target_and_sink( socket_path: &Path, daemon_executable: &Path, + initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { connect_managed_inner( @@ -593,6 +718,7 @@ pub fn connect_managed_with_sink( spawn_daemon, MANAGED_STARTUP_TIMEOUT, MANAGED_RETRY_INTERVAL, + initial_target, sink, ) } @@ -660,6 +786,7 @@ fn connect_managed_inner( spawner: S, timeout: Duration, retry_interval: Duration, + initial_target: Option, sink: F, ) -> Result where @@ -669,7 +796,7 @@ where { match connector(socket_path) { Ok(stream) => { - let client = connect_stream_with_sink(stream, sink)?; + let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon: ManagedDaemonFacts::existing(), @@ -695,7 +822,7 @@ where loop { match connector(socket_path) { Ok(stream) => { - let client = connect_stream_with_sink(stream, sink)?; + let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon }); } Err(error) => { @@ -739,6 +866,8 @@ pub struct AttachClient { /// than the daemon (e.g. `Pointer`, v5) must be gated on this — /// an older daemon hard-errors decoding an unknown variant. server_protocol_version: u32, + /// Target snapshot retained across the pre-window readiness barrier. + initial_message: Option, } impl AttachClient { @@ -747,6 +876,11 @@ impl AttachClient { self.frontend_id } + /// Take the target snapshot that must be applied before first redraw. + pub fn take_initial_message(&mut self) -> Option { + self.initial_message.take() + } + /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the @@ -912,6 +1046,9 @@ impl AttachClient { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use pmacs_protocol::{InstanceCapabilities, InstanceIdentity, MouseButton}; fn caps( @@ -926,6 +1063,91 @@ mod tests { } } + fn hello(protocol_version: u32) -> Hello { + Hello { + protocol_version, + assigned_frontend_id: FrontendId(7), + instance_identity: InstanceIdentity { + pmacs_version: "test".to_owned(), + build_hash: None, + instance_name: None, + uptime_secs: 0, + working_directory: "/tmp".to_owned(), + }, + instance_capabilities: caps(true, true, true), + } + } + + #[test] + fn initial_target_bootstrap_is_synchronous_byte_exact_and_snapshot_first() { + let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); + let raw_path = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]); + let paths = InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from(&raw_path), + }; + let expected = paths.clone(); + let server = thread::spawn(move || { + write_message(&mut server_stream, &hello(PROTOCOL_VERSION)).expect("write Hello"); + let _: AttachRequest = read_message(&mut server_stream).expect("read AttachRequest"); + let bootstrap: SessionBootstrapRequest = + read_message(&mut server_stream).expect("read bootstrap"); + let target = bootstrap.initial_target.expect("initial target"); + assert_eq!(target.cwd, expected.cwd.as_os_str().as_bytes()); + assert_eq!(target.path, expected.path.as_os_str().as_bytes()); + + let buffer_id = BufferId::from_raw(41); + write_message( + &mut server_stream, + &InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot: vec![1, 2, 3], + }, + ) + .expect("write target snapshot"); + write_message( + &mut server_stream, + &InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }), + ) + .expect("write target result"); + }); + + let mut client = connect_stream_with_sink(client_stream, Some(paths), |_| true) + .expect("target bootstrap"); + assert!(matches!( + client.take_initial_message(), + Some(InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + }) if buffer_id == BufferId::from_raw(41) && crdt_snapshot == [1, 2, 3] + )); + assert!(client.take_initial_message().is_none()); + server.join().expect("bootstrap server"); + } + + #[test] + fn initial_target_fails_before_attach_on_legacy_protocol() { + let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); + let server = thread::spawn(move || { + write_message(&mut server_stream, &hello(19)).expect("write legacy Hello"); + }); + let Err(error) = connect_stream_with_sink( + client_stream, + Some(InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from("note"), + }), + |_| true, + ) else { + panic!("legacy target must fail"); + }; + assert!(matches!( + error, + AttachClientError::InitialTargetUnsupported { server: 19 } + )); + server.join().expect("legacy server"); + } + #[test] fn full_crdt_daemon_advertises_everything_required() { // A daemon built with `--features crdt` advertises all three — the @@ -1167,6 +1389,7 @@ mod tests { shutdown_handle: b, frontend_id: FrontendId::LOCAL, server_protocol_version: PROTOCOL_VERSION, + initial_message: None, }; // A send against the closed outbox fails *and* shuts the socket // down (F-008 fail-fast is now a real teardown, not just a flag). @@ -1245,6 +1468,7 @@ mod tests { |_, _| panic!("non-socket path must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1269,6 +1493,7 @@ mod tests { |_, _| panic!("permission failure must not spawn"), Duration::from_millis(1), Duration::from_millis(1), + None, |_| false, ); assert!(matches!( @@ -1318,6 +1543,7 @@ mod tests { |_, _| Command::new("/bin/sh").args(["-c", "exit 0"]).spawn(), Duration::from_secs(1), Duration::ZERO, + None, |_| true, ) .expect("transient sequence must attach"); diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 98f08d5..a640947 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -26,6 +26,8 @@ mod attach; mod terminal; use std::collections::HashMap; +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -54,7 +56,7 @@ use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; -use crate::attach::{AttachClient, AttachEvent}; +use crate::attach::{AttachClient, AttachEvent, InitialTargetPaths}; use crate::terminal::{TerminalPaintPlan, TerminalPalette}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). @@ -556,6 +558,7 @@ enum Mode { ManagedAttach { socket: PathBuf, daemon_executable: PathBuf, + initial_target: Option, }, /// `pmacs-gpu --headless-probe `: attach through /// the real client, render real frames offscreen, and write a @@ -572,6 +575,7 @@ enum Mode { socket: PathBuf, report: PathBuf, daemon_executable: PathBuf, + initial_target: Option, }, } @@ -589,8 +593,7 @@ fn decimal_digits(mut n: usize) -> u32 { } fn main() { - env_logger::init(); - let mode = match parse_args(&std::env::args().skip(1).collect::>()) { + let mode = match parse_args(&std::env::args_os().skip(1).collect::>()) { Ok(mode) => mode, Err(error) => { eprintln!("pmacs-gpu: {error}\n\n{GPU_USAGE}"); @@ -617,11 +620,13 @@ fn main() { socket, report, daemon_executable, + initial_target, } => { std::process::exit(run_headless_managed_probe( socket, report, daemon_executable, + initial_target.clone(), )); } Mode::Attach { .. } | Mode::ManagedAttach { .. } => {} @@ -631,27 +636,40 @@ fn main() { .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); - let attach_client = if let Mode::ManagedAttach { + let (attach_client, pending_events) = if let Mode::ManagedAttach { socket, daemon_executable, + initial_target, } = &mode { - match attach::connect_managed(socket, daemon_executable, proxy.clone()) { - Ok(managed) => Some(managed.client), + match attach::connect_managed_with_target( + socket, + daemon_executable, + initial_target.clone(), + proxy.clone(), + ) { + Ok(mut managed) => { + let pending = managed + .client + .take_initial_message() + .map(|message| vec![AppEvent::Attach(AttachEvent::Message(Box::new(message)))]) + .unwrap_or_default(); + (Some(managed.client), pending) + } Err(error) => { eprintln!("pmacs-gpu: managed attach failed: {error}"); std::process::exit(1); } } } else { - None + (None, Vec::new()) }; let mut app = App { mode, proxy: Some(proxy), state: None, attach_client, - pending_events: Vec::new(), + pending_events, modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop @@ -833,15 +851,24 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { clippy::too_many_lines, reason = "one linear managed-connect and lifecycle observation probe" )] -fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &Path) -> i32 { +fn run_headless_managed_probe( + socket: &Path, + report: &Path, + daemon_executable: &Path, + initial_target: Option, +) -> i32 { use std::io::Read as _; use std::sync::mpsc; use std::time::{Duration, Instant}; let (event_tx, event_rx) = mpsc::channel::(); - let managed = match attach::connect_managed_with_sink(socket, daemon_executable, move |event| { - event_tx.send(event).is_ok() - }) { + let connector_tx = event_tx.clone(); + let managed = match attach::connect_managed_with_target_and_sink( + socket, + daemon_executable, + initial_target, + move |event| connector_tx.send(event).is_ok(), + ) { Ok(managed) => managed, Err(error) => { let contents = format!("phase=error\nerror={error}\n"); @@ -850,7 +877,10 @@ fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: & return 4; } }; - let client = managed.client; + let mut client = managed.client; + if let Some(message) = client.take_initial_message() { + let _ = event_tx.send(AttachEvent::Message(Box::new(message))); + } let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -1019,7 +1049,7 @@ const GPU_USAGE: &str = "\ pmacs-gpu — GPU frontend for pmacs NORMAL STARTUP: - pmacs --gpu [--socket NAME|PATH] start or reuse a managed daemon + pmacs --gpu [--socket NAME|PATH] [FILE] start/reuse a daemon and open FILE ADVANCED DIRECT ATTACH: pmacs-gpu --attach attach to an existing daemon only @@ -1029,58 +1059,124 @@ OPTIONS: pmacs-gpu --version print package and protocol versions"; /// Strict parser for direct, managed, and headless GPU entry points. -fn parse_args(args: &[String]) -> Result { - if let [flag, operands @ ..] = args - && matches!( - flag.as_str(), - "--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe" - ) - && let Some(operand) = operands.iter().find(|operand| operand.starts_with('-')) +#[allow( + clippy::too_many_lines, + reason = "one exact-arity parser keeps private GPU entry points visibly fail-closed" +)] +fn parse_args(args: &[OsString]) -> Result { + fn option_like(value: &OsString) -> bool { + value.as_os_str().as_bytes().starts_with(b"-") + } + + fn reject_option_like(command: &str, operands: &[&OsString]) -> Result<(), String> { + if let Some(operand) = operands.iter().find(|operand| option_like(operand)) { + return Err(format!( + "{command} received option-like path operand {}; prefix it with ./ if it is a path", + operand.to_string_lossy() + )); + } + Ok(()) + } + + fn target(cwd: &OsString, path: &OsString) -> InitialTargetPaths { + InitialTargetPaths { + cwd: PathBuf::from(cwd), + path: PathBuf::from(path), + } + } + + if let Some(flag) = args.first() + && option_like(flag) + && flag.to_str().is_none() { - return Err(format!( - "{flag} received option-like path operand {operand}; prefix it with ./ if it is a path" - )); + return Err("option names must be valid UTF-8".to_owned()); } match args { [flag] if flag == "--help" || flag == "-h" => Ok(Mode::Help), [flag] if flag == "--version" || flag == "-V" => Ok(Mode::Version), - [flag, socket] if flag == "--attach" => Ok(Mode::Attach { - socket: PathBuf::from(socket), - }), + [flag, socket] if flag == "--attach" => { + reject_option_like("--attach", &[socket])?; + Ok(Mode::Attach { + socket: PathBuf::from(socket), + }) + } [flag, socket, daemon_executable] if flag == "--managed-attach" => { + reject_option_like("--managed-attach", &[socket, daemon_executable])?; Ok(Mode::ManagedAttach { socket: PathBuf::from(socket), daemon_executable: PathBuf::from(daemon_executable), + initial_target: None, + }) + } + [flag, socket, daemon_executable, marker, cwd, path] + if flag == "--managed-attach" && marker == "--initial-target" => + { + reject_option_like("--managed-attach", &[socket, daemon_executable, cwd])?; + Ok(Mode::ManagedAttach { + socket: PathBuf::from(socket), + daemon_executable: PathBuf::from(daemon_executable), + initial_target: Some(target(cwd, path)), + }) + } + [flag, socket, report] if flag == "--headless-probe" => { + reject_option_like("--headless-probe", &[socket, report])?; + Ok(Mode::HeadlessProbe { + socket: PathBuf::from(socket), + report: PathBuf::from(report), }) } - [flag, socket, report] if flag == "--headless-probe" => Ok(Mode::HeadlessProbe { - socket: PathBuf::from(socket), - report: PathBuf::from(report), - }), [flag, socket, report, daemon_executable] if flag == "--headless-managed-probe" => { + reject_option_like( + "--headless-managed-probe", + &[socket, report, daemon_executable], + )?; Ok(Mode::HeadlessManagedProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), daemon_executable: PathBuf::from(daemon_executable), + initial_target: None, + }) + } + [flag, socket, report, daemon_executable, marker, cwd, path] + if flag == "--headless-managed-probe" && marker == "--initial-target" => + { + reject_option_like( + "--headless-managed-probe", + &[socket, report, daemon_executable, cwd], + )?; + Ok(Mode::HeadlessManagedProbe { + socket: PathBuf::from(socket), + report: PathBuf::from(report), + daemon_executable: PathBuf::from(daemon_executable), + initial_target: Some(target(cwd, path)), }) } [] => Err( "managed startup is provided by `pmacs --gpu`; direct use requires --attach " .to_owned(), ), - [flag, ..] if matches!(flag.as_str(), "--help" | "-h" | "--version" | "-V") => { - Err(format!("{flag} does not accept operands")) + [flag, ..] if flag == "--help" || flag == "-h" || flag == "--version" || flag == "-V" => { + Err(format!( + "{} does not accept operands", + flag.to_string_lossy() + )) } [flag, ..] - if matches!( - flag.as_str(), - "--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe" - ) => + if flag == "--attach" + || flag == "--managed-attach" + || flag == "--headless-probe" + || flag == "--headless-managed-probe" => { - Err(format!("{flag} received the wrong number of operands")) + Err(format!( + "{} received the wrong number of operands", + flag.to_string_lossy() + )) } - [other, ..] => Err(format!("unrecognized argument: {other}")), + [other, ..] => Err(format!( + "unrecognized argument: {}", + other.to_string_lossy() + )), } } @@ -8123,6 +8219,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::FontFacts { .. } => "FontFacts", InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", + InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", } } @@ -14263,12 +14360,7 @@ mod tests { } #[test] fn gpu_cli_accepts_only_explicit_exact_modes() { - let args = |values: &[&str]| { - values - .iter() - .map(|value| (*value).to_owned()) - .collect::>() - }; + let args = |values: &[&str]| values.iter().map(OsString::from).collect::>(); assert_eq!( parse_args(&args(&["--attach", "/tmp/pmacs.sock"])), Ok(Mode::Attach { @@ -14284,6 +14376,7 @@ mod tests { Ok(Mode::ManagedAttach { socket: PathBuf::from("/tmp/pmacs.sock"), daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: None, }) ); assert_eq!( @@ -14308,10 +14401,56 @@ mod tests { socket: PathBuf::from("/tmp/pmacs.sock"), report: PathBuf::from("/tmp/report"), daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: None, }) ); } + #[test] + fn gpu_private_target_marker_preserves_raw_file_bytes() { + use std::os::unix::ffi::OsStringExt; + + let raw_path = OsString::from_vec(vec![b'-', b'n', b'o', b't', b'e', 0xff]); + let argv = vec![ + OsString::from("--managed-attach"), + OsString::from("/tmp/pmacs.sock"), + OsString::from("/bin/pmacs"), + OsString::from("--initial-target"), + OsString::from("/launcher"), + raw_path.clone(), + ]; + assert_eq!( + parse_args(&argv), + Ok(Mode::ManagedAttach { + socket: PathBuf::from("/tmp/pmacs.sock"), + daemon_executable: PathBuf::from("/bin/pmacs"), + initial_target: Some(InitialTargetPaths { + cwd: PathBuf::from("/launcher"), + path: PathBuf::from(&raw_path), + }), + }) + ); + + let bad_cwd = [ + OsString::from("--managed-attach"), + OsString::from("/tmp/pmacs.sock"), + OsString::from("/bin/pmacs"), + OsString::from("--initial-target"), + OsString::from("--cwd"), + OsString::from("note"), + ]; + assert!( + parse_args(&bad_cwd) + .expect_err("option-like cwd must fail") + .contains("option-like") + ); + let bad_option = [OsString::from_vec(vec![b'-', 0xff])]; + assert_eq!( + parse_args(&bad_option).expect_err("non-UTF-8 option must fail"), + "option names must be valid UTF-8" + ); + } + #[test] fn gpu_cli_rejects_bare_missing_and_trailing_arguments() { let invalid = [ @@ -14339,16 +14478,13 @@ mod tests { vec!["research"], ]; for values in invalid { - let args = values - .iter() - .map(|value| (*value).to_owned()) - .collect::>(); + let args = values.iter().map(OsString::from).collect::>(); assert!( parse_args(&args).is_err(), "accepted invalid argv: {values:?}" ); } - let error = parse_args(&["--attach".to_owned(), "--help".to_owned()]) + let error = parse_args(&[OsString::from("--attach"), OsString::from("--help")]) .expect_err("option-like socket operand must fail"); assert!(error.contains("option-like path operand --help")); } @@ -14384,7 +14520,7 @@ mod tests { assert!(GPU_USAGE.contains("NORMAL STARTUP")); assert!(GPU_USAGE.contains("ADVANCED DIRECT ATTACH")); - let extra = ["--help", "extra"].map(str::to_owned); + let extra = ["--help", "extra"].map(OsString::from); let error = parse_args(&extra).expect_err("help operands must fail"); assert_eq!(error, "--help does not accept operands"); } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index e7d6026..ce2d2c5 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -57,12 +57,13 @@ pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, - FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, - InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, - LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, - MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, - MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, - PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, + InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, + KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, + MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, + MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, + MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, + ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, }; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 79db7e7..971a78e 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1137,6 +1137,12 @@ pub enum InstanceMessage { /// Appended after [`Self::StatuslineSegments`], the final v18 /// variant, so no existing postcard discriminant moves. TerminalFrame(crate::terminal::TerminalFrame), + /// GPU initial-target bootstrap result (protocol v20). Sent only to a + /// semantic session that supplied [`SessionBootstrapRequest::initial_target`]. + /// + /// Appended after [`Self::TerminalFrame`], the final v19 variant, so no + /// legacy postcard discriminant moves. + InitialTargetResult(InitialTargetResult), } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1553,7 +1559,13 @@ pub enum ResourceBody { /// with no terminal surface at all. This is the first bump to gate in /// BOTH directions at once, which is why criterion 28 pins the two /// send filters independently. -pub const PROTOCOL_VERSION: u32 = 19; +/// +/// GPU initial target (Q#GT4): bumped 19 → 20 for the semantic-session +/// bootstrap envelope and [`InstanceMessage::InitialTargetResult`]. The +/// handshake extension is read only from v20 semantic sessions; the result is +/// sent only when such a session requested a target. v6–v19 handshakes and +/// message discriminants remain unchanged. +pub const PROTOCOL_VERSION: u32 = 20; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1627,8 +1639,12 @@ pub const PROTOCOL_VERSION: u32 = 19; /// additive in both directions — `TerminalFrame` is daemon-gated, /// `TerminalResize` / `TerminalPointer` are frontend-gated — so v18 and /// v19 binaries interoperate with terminal traffic simply absent. +/// +/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic +/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy +/// and non-semantic sessions retain their existing handshake shape. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; + &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. @@ -2038,3 +2054,47 @@ pub struct AttachRequest { /// instance uses this for the initial full-grid render. pub initial_size: CellSize, } + +/// Maximum byte length of either raw Unix path in an initial-target request. +pub const MAX_INITIAL_TARGET_PATH_BYTES: usize = 32 * 1024; + +/// Maximum UTF-8 byte length of a daemon-produced initial-target error. +pub const MAX_INITIAL_TARGET_ERROR_BYTES: usize = 4 * 1024; + +/// Raw local paths for a semantic session's pre-window initial target. +/// +/// Both fields are Unix path bytes rather than display text. The daemon +/// validates the byte bounds, absolute `cwd`, nonempty fields, and embedded +/// NULs before constructing paths. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct InitialTarget { + /// Absolute launcher working-directory bytes. + pub cwd: Vec, + /// Launcher-expanded target path bytes, absolute or relative to `cwd`. + pub path: Vec, +} + +/// Protocol-v20 semantic-session bootstrap extension. +/// +/// A v20 semantic frontend sends this immediately after [`AttachRequest`]. +/// `None` preserves ordinary attach behavior. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SessionBootstrapRequest { + /// Optional file that must be ready before the frontend creates a window. + pub initial_target: Option, +} + +/// Pre-window outcome for a requested initial target. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum InitialTargetResult { + /// The target snapshot was written and the session is ready. + Opened { + /// Buffer identified by the immediately preceding target snapshot. + buffer_id: crate::BufferId, + }, + /// Bootstrap failed; the provisional session has been removed. + Failed { + /// Bounded user-facing daemon detail. + message: String, + }, +} diff --git a/src/daemon.rs b/src/daemon.rs index fe28112..baf1e35 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -49,7 +49,9 @@ //! - Write fails (broken pipe) → return; ungraceful disconnect. use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; use std::io::ErrorKind; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -64,9 +66,10 @@ use crate::lockfile::{self, LockError, LockHandle}; use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ - AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InstanceCapabilities, - InstanceIdentity, InstanceMessage, InstanceSignal, PROTOCOL_VERSION, PointerKind, - SelectionSnapshot, + AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, + InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, + MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind, + SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -114,6 +117,8 @@ enum DispatcherEvent { frontend_id: FrontendId, session_state: crate::presence::SessionState, initial_size: CellSize, + /// Validated protocol-v20 semantic bootstrap target, if requested. + initial_target: Option, /// Write-half of the per-attach stream. The dispatcher owns /// this end; the per-attach reader thread keeps the /// read-half via `try_clone`. @@ -633,6 +638,47 @@ fn install_signal_handlers(shutdown: &Arc) -> Result<(), DaemonError Ok(()) } +fn bounded_initial_target_error(mut message: String) -> String { + if message.len() <= MAX_INITIAL_TARGET_ERROR_BYTES { + return message; + } + let mut end = MAX_INITIAL_TARGET_ERROR_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message +} + +fn send_initial_target_failure(stream: &mut UnixStream, message: impl Into) { + let result = InitialTargetResult::Failed { + message: bounded_initial_target_error(message.into()), + }; + let _ = write_message(stream, &InstanceMessage::InitialTargetResult(result)); +} + +fn validate_initial_target(target: &InitialTarget) -> Result<(), String> { + if target.cwd.is_empty() { + return Err("initial target cwd is empty".to_owned()); + } + if target.path.is_empty() { + return Err("initial target path is empty".to_owned()); + } + if target.cwd.len() > MAX_INITIAL_TARGET_PATH_BYTES { + return Err("initial target cwd exceeds 32 KiB".to_owned()); + } + if target.path.len() > MAX_INITIAL_TARGET_PATH_BYTES { + return Err("initial target path exceeds 32 KiB".to_owned()); + } + if target.cwd.contains(&0) || target.path.contains(&0) { + return Err("initial target path contains an embedded NUL".to_owned()); + } + if !Path::new(OsStr::from_bytes(&target.cwd)).is_absolute() { + return Err("initial target cwd is not absolute".to_owned()); + } + Ok(()) +} + /// T M10.8 — per-attach thread. Runs handshake on a fresh thread for /// each accepted connection; on success, sends `SessionEstablished` /// to the dispatcher and transitions to reader behavior on the same @@ -644,7 +690,11 @@ fn install_signal_handlers(shutdown: &Arc) -> Result<(), DaemonError /// error) the thread writes a `Goodbye` variant and exits without /// notifying the dispatcher. The dispatcher never learns about /// failed handshakes. -#[allow(clippy::needless_pass_by_value)] +#[allow( + clippy::needless_pass_by_value, + clippy::too_many_lines, + reason = "the ordered handshake and bootstrap read stay on one per-connection thread" +)] fn per_attach_thread( mut stream: UnixStream, daemon_state: Arc, @@ -712,6 +762,28 @@ fn per_attach_thread( } }; + // Q#GT4 — v20 semantic sessions send one bootstrap envelope after + // AttachRequest. Legacy and non-semantic sessions retain the exact + // two-message handshake and therefore must not be read here. + let initial_target = if req.protocol_version >= 20 && negotiated_caps.semantic_render { + let bootstrap: SessionBootstrapRequest = match read_message(&mut stream) { + Ok(bootstrap) => bootstrap, + Err(e) => { + eprintln!("pmacs: read SessionBootstrapRequest failed: {e}"); + return; + } + }; + if let Some(target) = bootstrap.initial_target.as_ref() + && let Err(message) = validate_initial_target(target) + { + send_initial_target_failure(&mut stream, message); + return; + } + bootstrap.initial_target + } else { + None + }; + // T M10.8 Day 4 — Q5 non-multi-session admission control. let _non_multi_guard = if negotiated_caps.multi_frontend { None @@ -760,6 +832,7 @@ fn per_attach_thread( frontend_id, session_state, initial_size: req.initial_size, + initial_target, write_stream, }) .is_err() @@ -1526,8 +1599,134 @@ fn take_pending_terminal_bell( } } +struct OpenedInitialTarget { + buffer_id: crate::buffer::BufferId, + publish_to_replicas: 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)); + let absolute = if path.is_absolute() { + path + } else { + cwd.join(path) + }; + crate::editor_core::lexical_normalize(&absolute) +} + +fn open_initial_target( + editor: &mut EditorState, + frontend_id: FrontendId, + target: InitialTarget, +) -> Result { + let path = resolve_initial_target(target); + let display_path = path.display().to_string(); + let (buffer_id, newly_loaded, newly_created) = { + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) { + Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false), + Err(error) if error.kind() == ErrorKind::NotFound => { + let buffer_id = core.registry.borrow_mut().create(display_path.clone()); + core.set_buffer_path(buffer_id, Some(path.clone())); + "[new file]".clone_into(&mut core.status); + (buffer_id, false, true) + } + Err(error) => { + return Err(format!("cannot open {}: {error}", path.display())); + } + }; + core.switch_active_buffer_for(frontend_id, buffer_id) + .map_err(|error| format!("cannot select {}: {error}", path.display()))?; + (buffer_id, newly_loaded, newly_created) + }; + + if newly_loaded { + editor + .lua_host + .run_hook("buffer.after-load", mlua::MultiValue::new()); + } else if !newly_created { + // Dedup is a logical switch even when the fresh view already shares + // this BufferId; configuration must observe it exactly once. + editor + .lua_host + .run_hook("buffer.after-switch", mlua::MultiValue::new()); + } + + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + if !core.registry.borrow().contains(buffer_id) { + return Err(format!( + "initial target {} was removed by a startup hook", + path.display() + )); + } + core.switch_active_buffer_for(frontend_id, buffer_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))?; + Ok(OpenedInitialTarget { + buffer_id, + publish_to_replicas: newly_loaded || newly_created, + }) +} + +#[cfg(feature = "crdt")] +fn initial_target_snapshot( + editor: &EditorState, + buffer_id: crate::buffer::BufferId, +) -> Result, 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 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 + .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:?}")) +} + +#[cfg(not(feature = "crdt"))] +fn initial_target_snapshot( + _editor: &EditorState, + _buffer_id: crate::buffer::BufferId, +) -> Result, String> { + Err("initial target requires a CRDT-enabled daemon".to_owned()) +} + +#[allow(clippy::too_many_arguments)] +fn cleanup_provisional_session( + editor: &mut EditorState, + render_states: &mut HashMap, + semantic_states: &mut HashMap, + streams: &mut HashMap, + term_sizes: &mut HashMap, + last_active_buffer_sent: &mut HashMap, + session_registry: &mut SessionRegistry, + frontend_id: FrontendId, +) { + render_states.remove(&frontend_id); + semantic_states.remove(&frontend_id); + streams.remove(&frontend_id); + term_sizes.remove(&frontend_id); + last_active_buffer_sent.remove(&frontend_id); + session_registry.unregister_session(frontend_id); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); +} + #[allow( clippy::too_many_arguments, + clippy::too_many_lines, reason = "one session bootstrap transaction" )] fn handle_session_established( @@ -1536,55 +1735,93 @@ fn handle_session_established( semantic_states: &mut HashMap, streams: &mut HashMap, term_sizes: &mut HashMap, + last_active_buffer_sent: &mut HashMap, session_registry: &mut SessionRegistry, frontend_id: FrontendId, session_state: crate::presence::SessionState, initial_size: CellSize, + initial_target: Option, mut write_stream: UnixStream, ) { - // Register the frontend's view (M10.8 Day 3: fresh scratch - // buffer view; future milestones may clone LOCAL's view or - // take an explicit initial-buffer argument). - let scratch_view = build_fresh_frontend_view(editor); - editor - .core - .borrow_mut() - .register_frontend_view(frontend_id, scratch_view); + let fresh_view = build_fresh_frontend_view(editor); + { + let mut core = editor.core.borrow_mut(); + core.register_frontend_view(frontend_id, fresh_view); + core.active_frontend = frontend_id; + } + + let opened_target = match initial_target { + Some(target) => match open_initial_target(editor, frontend_id, target) { + Ok(opened) => Some(opened), + Err(message) => { + send_initial_target_failure(&mut write_stream, message); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + }, + None => None, + }; - // T M10.10: bootstrap the new frontend's `BufferMirror` by - // sending one `BufferSnapshot` per CRDT-backed buffer. Gated on - // the negotiated `crdt_replica` capability — v0.1 / non-replica - // frontends never receive the variant (postcard would hard-error - // on the unknown variant; see M10.10-FRAMING.md Refinement 3). - // Ordering: snapshots are sent BEFORE any CellDelta flows (the - // next per-tick render is the first CellDelta source), so the - // mirror is initialized before any local-edit path can reference - // it. let crdt_replica = session_state.negotiated_capabilities.crdt_replica; - // T M11.2 — a semantic session is always a text replica (the - // negotiation dependency rule guarantees `semantic_render ⇒ - // crdt_replica`), so the `BufferSnapshot` bootstrap below still - // fires: the semantic frontend holds the rope locally and the - // semantic frame ships no text. let semantic_render = session_state.negotiated_capabilities.semantic_render; - // Captured before `register_session` consumes the state: the - // semantic producer needs the peer's version (finding 3 below). let negotiated_protocol_version = session_state.negotiated_protocol_version; - if crdt_replica { + + if let Some(opened) = opened_target.as_ref() { + let snapshot = match initial_target_snapshot(editor, opened.buffer_id) { + Ok(snapshot) => snapshot, + Err(message) => { + send_initial_target_failure(&mut write_stream, message); + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + }; + let snapshot_message = InstanceMessage::BufferSnapshot { + buffer_id: opened.buffer_id, + 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); + } + } + } + if write_message(&mut write_stream, &snapshot_message).is_err() { + editor + .core + .borrow_mut() + .unregister_frontend_view(frontend_id); + return; + } + } else if crdt_replica { + // Legacy no-target attach remains an all-buffer replica bootstrap. send_buffer_snapshots(editor, &mut write_stream); } - // Register the session in the registry (presence + capability - // filters). session_registry.register_session(frontend_id, session_state); - if semantic_render { semantic_states.insert( frontend_id, - // for_peer, not new (PR #120 round 1 finding 3): a v15 - // peer's producer must not resolve faces into the - // FileStyleSummary marks — that channel predates the v16 - // gate. crate::semantic_render::SemanticRenderState::for_peer( frontend_id, negotiated_protocol_version, @@ -1598,9 +1835,30 @@ fn handle_session_established( streams.insert(frontend_id, write_stream); term_sizes.insert(frontend_id, initial_size); - // Stamp active_frontend so the initial render's Lua statusline - // code sees the right fid. - editor.core.borrow_mut().active_frontend = frontend_id; + if let Some(opened) = opened_target { + last_active_buffer_sent.insert(frontend_id, opened.buffer_id); + let result = InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { + buffer_id: opened.buffer_id, + }); + let write_result = { + let stream = streams + .get_mut(&frontend_id) + .expect("new session stream installed"); + write_message(stream, &result) + }; + if write_result.is_err() { + cleanup_provisional_session( + editor, + render_states, + semantic_states, + streams, + term_sizes, + last_active_buffer_sent, + session_registry, + frontend_id, + ); + } + } } #[allow(clippy::too_many_arguments)] @@ -1622,6 +1880,7 @@ fn handle_dispatcher_event( frontend_id, session_state, initial_size, + initial_target, write_stream, } => { handle_session_established( @@ -1630,10 +1889,12 @@ fn handle_dispatcher_event( semantic_states, streams, term_sizes, + last_active_buffer_sent, session_registry, frontend_id, session_state, initial_size, + initial_target, write_stream, ); } diff --git a/src/editor_core.rs b/src/editor_core.rs index 5afbe90..fbcf3ae 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3232,7 +3232,7 @@ fn expand_tilde(path: PathBuf) -> PathBuf { /// Fold `.` and `..` components without touching the filesystem. /// `..` pops a preceding normal segment; against the root (or a /// Windows prefix) it is dropped, since you cannot ascend past it. -fn lexical_normalize(path: &Path) -> PathBuf { +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { use std::path::Component; let mut stack: Vec = Vec::new(); for comp in path.components() { diff --git a/src/frontend.rs b/src/frontend.rs index 81e5a0f..8fcfb47 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -436,6 +436,9 @@ impl Frontend { // unexpected copy drops silently like the rest of the // family rather than being re-interpreted as cells. | InstanceMessage::TerminalFrame(_) + // Q#GT4 — this pre-window semantic bootstrap result cannot + // legitimately reach the grid TUI. + | InstanceMessage::InitialTargetResult(_) | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/src/main.rs b/src/main.rs index 881b3da..aba1e32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ //! //! ```text //! pmacs [-nw|--no-window] [--help] [--version] [FILE] -//! pmacs --gpu [--socket NAME|PATH] +//! pmacs --gpu [--socket NAME|PATH] [--] [FILE] //! pmacs --daemon [--socket NAME|PATH] //! pmacs --attach [--socket NAME|PATH] //! pmacs --attach @@ -24,6 +24,8 @@ //! //! Anything else is a usage error and exits 2. +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -31,7 +33,7 @@ use pmacs::protocol::{AttachTarget, AttachTargetError}; const USAGE: &str = "\ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] - pmacs --gpu [--socket NAME|PATH] + pmacs --gpu [--socket NAME|PATH] [--] [FILE] pmacs --daemon [--socket NAME|PATH] pmacs --attach [--socket NAME|PATH] pmacs --attach @@ -43,6 +45,7 @@ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] the GUI and `-nw` will keep launching the TUI) --gpu start or reuse a CRDT daemon, then launch the separate pmacs-gpu frontend + When FILE is present, open it before the GPU window appears. --daemon run as a foreground daemon listening on a Unix socket; supervised by the user (systemd, tmux, `nohup &`, etc.) @@ -95,9 +98,12 @@ enum Mode { file: Option, frontend: FrontendChoice, }, - /// `pmacs --gpu [--socket ...]`: launch the separate GPU frontend, - /// starting a CRDT daemon on the resolved socket when absent. - Gpu { socket: Option }, + /// `pmacs --gpu [--socket ...] [FILE]`: launch the separate GPU + /// frontend, starting a CRDT daemon on the resolved socket when absent. + Gpu { + socket: Option, + file: Option, + }, /// `pmacs --daemon [--socket ...]`: run a foreground daemon on a /// Unix socket, supervised by the user. Daemon { socket: Option }, @@ -185,7 +191,7 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result CliResult { +fn parse_args(args: &[OsString]) -> CliResult { let mut file: Option = None; let mut frontend = FrontendChoice::Auto; let mut daemon = false; @@ -195,38 +201,45 @@ fn parse_args(args: &[String]) -> CliResult { let mut socket: Option = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { - match arg.as_str() { - "-nw" | "--no-window" => frontend = FrontendChoice::Tui, - "--gpu" => gpu = true, - "--daemon" => daemon = true, - "--attach" => attach = true, - "--daemon-attach" => daemon_attach = true, - "--socket" => match iter.next() { - Some(s) => socket = Some(s.clone()), + if arg.as_os_str().as_bytes().starts_with(b"-") && arg.to_str().is_none() { + return CliResult::Error("option names must be valid UTF-8".into()); + } + match arg.to_str() { + Some("-nw" | "--no-window") => frontend = FrontendChoice::Tui, + Some("--gpu") => gpu = true, + Some("--daemon") => daemon = true, + Some("--attach") => attach = true, + Some("--daemon-attach") => daemon_attach = true, + Some("--socket") => match iter.next() { + Some(value) => match value.to_str() { + Some(value) => socket = Some(value.to_owned()), + None => { + return CliResult::Error("--socket value must be valid UTF-8".into()); + } + }, None => return CliResult::Error("--socket requires a value".into()), }, - "-h" | "--help" => return CliResult::Help, - "-V" | "--version" => return CliResult::Version, - "--" => { - // Treat the rest as positional, even if they look like flags. - if let Some(p) = iter.next() { + Some("-h" | "--help") => return CliResult::Help, + Some("-V" | "--version") => return CliResult::Version, + Some("--") => { + if let Some(path) = iter.next() { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } - file = Some(PathBuf::from(p)); + file = Some(PathBuf::from(path)); } if iter.next().is_some() { return CliResult::Error("multiple files not yet supported".into()); } } - flag if flag.starts_with('-') => { + Some(flag) if flag.starts_with('-') => { return CliResult::Error(format!("unknown option: {flag}")); } - path => { + Some(_) | None => { if file.is_some() { return CliResult::Error("multiple files not yet supported".into()); } - file = Some(PathBuf::from(path)); + file = Some(PathBuf::from(arg)); } } } @@ -237,16 +250,11 @@ fn parse_args(args: &[String]) -> CliResult { ); } if gpu { - if file.is_some() { - return CliResult::Error( - "--gpu does not yet accept FILE; open it from the GPU with C-x C-f".into(), - ); - } if frontend == FrontendChoice::Tui { return CliResult::Error("--gpu and --no-window are mutually exclusive".into()); } return CliResult::Run(CliArgs { - mode: Mode::Gpu { socket }, + mode: Mode::Gpu { socket, file }, }); } if daemon { @@ -307,7 +315,22 @@ fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, Pa (PathBuf::from("pmacs-gpu"), sibling) } -fn run_gpu(socket: Option<&str>) -> ExitCode { +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"); return ExitCode::FAILURE; @@ -321,15 +344,32 @@ fn run_gpu(socket: Option<&str>) -> ExitCode { return ExitCode::FAILURE; } }; + let initial_target = match file { + Some(path) => { + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(error) => { + eprintln!("pmacs: cannot determine launcher working directory: {error}"); + return ExitCode::FAILURE; + } + }; + Some((cwd, expand_launcher_tilde(path))) + } + None => None, + }; let (gpu, sibling) = gpu_binary( ¤t_exe, std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from), ); - let status = Command::new(&gpu) + let mut command = Command::new(&gpu); + command .arg("--managed-attach") .arg(&socket_path) - .arg(¤t_exe) - .status(); + .arg(¤t_exe); + if let Some((cwd, path)) = initial_target { + command.arg("--initial-target").arg(cwd).arg(path); + } + let status = command.status(); match status { Ok(status) if status.success() => ExitCode::SUCCESS, Ok(status) => { @@ -358,7 +398,7 @@ fn run_gpu(socket: Option<&str>) -> ExitCode { } fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + let args: Vec = std::env::args_os().skip(1).collect(); match parse_args(&args) { CliResult::Help => { print!("{USAGE}"); @@ -388,7 +428,7 @@ fn main() -> ExitCode { ExitCode::FAILURE } }, - Mode::Gpu { socket } => run_gpu(socket.as_deref()), + Mode::Gpu { socket, file } => run_gpu(socket.as_deref(), file.as_deref()), Mode::Daemon { socket } => { let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref()); // The user-provided NAME (no slashes) becomes the @@ -471,9 +511,10 @@ fn main() -> ExitCode { #[cfg(test)] mod tests { use super::*; + use std::os::unix::ffi::OsStringExt; - fn args(slice: &[&str]) -> Vec { - slice.iter().map(|s| (*s).to_string()).collect() + fn args(slice: &[&str]) -> Vec { + slice.iter().map(OsString::from).collect() } fn local_mode(parsed: CliArgs) -> (Option, FrontendChoice) { @@ -799,7 +840,7 @@ mod tests { vec!["--attach", "--daemon-attach"], vec!["--daemon", "--attach", "--daemon-attach"], ] { - let v: Vec = combo.iter().map(|s| (*s).to_string()).collect(); + let v: Vec = combo.iter().map(OsString::from).collect(); match parse_args(&v) { CliResult::Error(m) => assert!( m.contains("mutually exclusive"), @@ -810,29 +851,42 @@ mod tests { } } #[test] - fn gpu_flag_selects_managed_gpu_with_optional_socket() { - for (argv, expected) in [ - (vec!["--gpu"], None), - (vec!["--gpu", "--socket", "research"], Some("research")), + fn gpu_flag_accepts_one_optional_file_and_socket() { + for (argv, expected_socket, expected_file) in [ + (vec!["--gpu"], None, None), + ( + vec!["--gpu", "--socket", "research"], + Some("research"), + None, + ), + (vec!["--gpu", "README.md"], None, Some("README.md")), + ( + vec!["--gpu", "--socket", "research", "README.md"], + Some("research"), + Some("README.md"), + ), + (vec!["--gpu", "--", "-notes"], None, Some("-notes")), ] { - let argv = args(&argv); - match parse_args(&argv) { + match parse_args(&args(&argv)) { CliResult::Run(CliArgs { - mode: Mode::Gpu { socket }, - }) => assert_eq!(socket.as_deref(), expected), + mode: Mode::Gpu { socket, file }, + }) => { + assert_eq!(socket.as_deref(), expected_socket); + assert_eq!(file.as_deref(), expected_file.map(Path::new)); + } other => panic!("expected GPU mode; got {other:?}"), } } } #[test] - fn gpu_flag_rejects_files_tui_and_other_modes() { + fn gpu_flag_rejects_tui_other_modes_and_multiple_files() { for argv in [ - vec!["--gpu", "README.md"], vec!["--gpu", "-nw"], vec!["--gpu", "--daemon"], vec!["--gpu", "--attach"], vec!["--gpu", "--daemon-attach"], + vec!["--gpu", "one", "two"], ] { assert!( matches!(parse_args(&args(&argv)), CliResult::Error(_)), @@ -841,6 +895,31 @@ mod tests { } } + #[test] + fn gpu_file_keeps_non_utf8_bytes_and_launcher_tilde_expansion_is_exact() { + let raw = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]); + let parsed = parse_args(&[OsString::from("--gpu"), raw.clone()]); + match parsed { + CliResult::Run(CliArgs { + mode: Mode::Gpu { + file: Some(file), .. + }, + }) => assert_eq!(file.as_os_str().as_bytes(), raw.as_bytes()), + other => panic!("expected raw GPU file; got {other:?}"), + } + + 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")), + PathBuf::from(home).join("notes") + ); + assert_eq!( + expand_launcher_tilde(Path::new("~other/notes")), + PathBuf::from("~other/notes") + ); + } + #[test] fn bare_socket_is_never_silently_ignored() { match parse_args(&args(&["--socket", "research"])) { diff --git a/src/protocol.rs b/src/protocol.rs index a86a9d1..df65863 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_nineteen_for_the_terminal_family() { + fn protocol_version_is_twenty_for_gpu_initial_targets() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1720,7 +1720,9 @@ mod tests { // frontend-gated — the first bump that gates in BOTH // directions; all three appended after their enum's final v18 // variant, see the placement pins). - assert_eq!(PROTOCOL_VERSION, 19); + // GPU initial targets bump 19→20 with a semantic-only + // SessionBootstrapRequest and appended InitialTargetResult. + assert_eq!(PROTOCOL_VERSION, 20); } #[test] @@ -1795,18 +1797,18 @@ mod tests { // regex/invalid), v11 (the context menu), v12 (the GUI // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), - // v18 (`StatuslineSegments`), and v19 (the vterm terminal - // family) all interoperate. - for accepted in 6..=19 { + // v18 (`StatuslineSegments`), v19 (the vterm terminal family), + // and v20 (semantic initial-target bootstrap) all interoperate. + for accepted in 6..=20 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 20, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v19 binary" + "v{rejected} must be rejected by a v20 binary" ); } } @@ -2015,6 +2017,47 @@ mod tests { } } + #[test] + fn initial_target_bootstrap_round_trips_and_appends_after_the_v19_terminal_frame() { + let request = SessionBootstrapRequest { + initial_target: Some(InitialTarget { + cwd: b"/launcher".to_vec(), + path: vec![b'n', b'o', b't', b'e', 0xff], + }), + }; + let request_bytes = postcard::to_allocvec(&request).expect("encode bootstrap"); + let decoded: SessionBootstrapRequest = + postcard::from_bytes(&request_bytes).expect("decode bootstrap"); + assert_eq!(decoded, request); + let none = SessionBootstrapRequest::default(); + assert_eq!( + postcard::from_bytes::( + &postcard::to_allocvec(&none).expect("encode empty bootstrap") + ) + .expect("decode empty bootstrap"), + none + ); + + for result in [ + InitialTargetResult::Opened { + buffer_id: pmacs_protocol::BufferId::from_raw(9), + }, + InitialTargetResult::Failed { + message: "cannot load target".to_owned(), + }, + ] { + let message = InstanceMessage::InitialTargetResult(result); + let bytes = postcard::to_allocvec(&message).expect("encode target result"); + assert_eq!( + bytes.first(), + Some(&27), + "InitialTargetResult must be appended after v19 TerminalFrame" + ); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode result"); + assert_eq!(decoded, message); + } + } + #[test] fn font_facts_encoding_is_unchanged_by_the_v18_build() { let msg = InstanceMessage::FontFacts { diff --git a/tests/gpu_initial_target_acceptance.rs b/tests/gpu_initial_target_acceptance.rs new file mode 100644 index 0000000..a94af59 --- /dev/null +++ b/tests/gpu_initial_target_acceptance.rs @@ -0,0 +1,9 @@ +//! Named gate for the approved GPU initial-target framing. +//! +//! The target cases share the existing managed-lifecycle fixtures so every run +//! also proves the #141 reuse, spawn, isolation, and child-reaping invariants. + +#![cfg(unix)] + +#[path = "gpu_invocation_acceptance.rs"] +mod gpu_invocation_acceptance; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 4fa9196..267c330 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -78,6 +78,8 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { #[cfg(feature = "crdt")] mod crdt { use std::collections::HashMap; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::process::CommandExt; use std::path::PathBuf; @@ -89,9 +91,11 @@ mod crdt { use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use pmacs::cell::CellSize; + use pmacs::crdt::CrdtState; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceCapabilities, InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, + AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, + InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, + PROTOCOL_VERSION, SessionBootstrapRequest, }; use pmacs::transport::{read_message, write_message}; @@ -212,6 +216,110 @@ mod crdt { (hello.assigned_frontend_id, stream) } + struct TargetSession { + frontend_id: FrontendId, + buffer_id: pmacs::buffer::BufferId, + replica: CrdtState, + stream: UnixStream, + } + + fn attach_target(socket: &Path, cwd: &Path, path: &Path) -> TargetSession { + use std::os::unix::ffi::OsStrExt; + + let mut stream = UnixStream::connect(socket).expect("connect target frontend"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set target frontend timeout"); + let hello: Hello = read_message(&mut stream).expect("target frontend Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..FrontendCapabilities::default() + }, + initial_size: CellSize::new(24, 80), + }, + ) + .expect("attach target frontend"); + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: Some(InitialTarget { + cwd: cwd.as_os_str().as_bytes().to_vec(), + path: path.as_os_str().as_bytes().to_vec(), + }), + }, + ) + .expect("send initial target"); + + let (buffer_id, snapshot) = + match read_message::(&mut stream).expect("target snapshot") { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected target snapshot first, got {other:?}"), + }; + assert_eq!( + read_message::(&mut stream).expect("target result"), + InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) + ); + let replica = CrdtState::new(hello.assigned_frontend_id.0).expect("target replica"); + replica + .import_snapshot(&snapshot) + .expect("import target snapshot"); + TargetSession { + frontend_id: hello.assigned_frontend_id, + buffer_id, + replica, + stream, + } + } + + fn request_raw_target(socket: &Path, cwd: Vec, path: Vec) -> Vec { + let mut stream = UnixStream::connect(socket).expect("connect raw target frontend"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set raw target timeout"); + let hello: Hello = read_message(&mut stream).expect("raw target Hello"); + write_message( + &mut stream, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + ..FrontendCapabilities::default() + }, + initial_size: CellSize::new(24, 80), + }, + ) + .expect("attach raw target frontend"); + write_message( + &mut stream, + &SessionBootstrapRequest { + initial_target: Some(InitialTarget { cwd, path }), + }, + ) + .expect("send raw target"); + + let first = read_message::(&mut stream).expect("raw target result"); + if matches!(first, InstanceMessage::BufferSnapshot { .. }) { + vec![ + first, + read_message::(&mut stream).expect("raw opened result"), + ] + } else { + vec![first] + } + } + fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child { let home = socket.parent().expect("socket parent"); let mut command = Command::new(pmacs_binary()); @@ -243,12 +351,41 @@ mod crdt { Self::spawn_with_env(socket, report, daemon_executable, home, &[]) } + fn spawn_target( + socket: &Path, + report: &Path, + daemon_executable: &Path, + home: &Path, + cwd: &Path, + target: &Path, + ) -> Self { + Self::spawn_with_env_and_target( + socket, + report, + daemon_executable, + home, + &[], + Some((cwd, target)), + ) + } + fn spawn_with_env( socket: &Path, report: &Path, daemon_executable: &Path, home: &Path, envs: &[(&str, &Path)], + ) -> Self { + Self::spawn_with_env_and_target(socket, report, daemon_executable, home, envs, None) + } + + fn spawn_with_env_and_target( + socket: &Path, + report: &Path, + daemon_executable: &Path, + home: &Path, + envs: &[(&str, &Path)], + initial_target: Option<(&Path, &Path)>, ) -> Self { assert!( gpu_binary().is_file(), @@ -259,7 +396,11 @@ mod crdt { .args(["--headless-managed-probe"]) .arg(socket) .arg(report) - .arg(daemon_executable) + .arg(daemon_executable); + if let Some((cwd, path)) = initial_target { + command.arg("--initial-target").arg(cwd).arg(path); + } + command .env("HOME", home) .env("XDG_CONFIG_HOME", home) .stdin(Stdio::piped()) @@ -316,6 +457,10 @@ mod crdt { let fake_gpu = temp.path().join("fake-gpu"); let record = temp.path().join("argv"); let socket = temp.path().join("broker.sock"); + let launch_cwd = temp.path().join("launch"); + let launcher_home = temp.path().join("home"); + fs::create_dir(&launch_cwd).expect("create launcher cwd"); + fs::create_dir(&launcher_home).expect("create launcher home"); write_script( &fake_gpu, "printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"", @@ -324,6 +469,9 @@ mod crdt { let success = Command::new(pmacs_binary()) .args(["--gpu", "--socket"]) .arg(&socket) + .arg("~/notes.txt") + .current_dir(&launch_cwd) + .env("HOME", &launcher_home) .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_RECORD", &record) .env("PMACS_TEST_EXIT", "0") @@ -339,6 +487,9 @@ mod crdt { assert_eq!(args[0], "--managed-attach"); assert_eq!(Path::new(args[1]), socket); assert_eq!(Path::new(args[2]), pmacs_binary()); + assert_eq!(args[3], "--initial-target"); + assert_eq!(Path::new(args[4]), launch_cwd); + assert_eq!(Path::new(args[5]), launcher_home.join("notes.txt")); let failure = Command::new(pmacs_binary()) .arg("--gpu") @@ -361,6 +512,267 @@ mod crdt { ); } + #[test] + fn one_command_root_broker_reaches_target_ready_through_the_real_gpu_connector() { + let temp = secure_tempdir(); + let cwd = temp.path().join("workspace"); + fs::create_dir(&cwd).expect("create workspace"); + fs::write(cwd.join("opened.txt"), "opened by root\n").expect("write target"); + let socket = temp.path().join("one-command.sock"); + let report = temp.path().join("one-command-report"); + let wrapper = temp.path().join("headless-gpu"); + write_script( + &wrapper, + "test \"$1\" = \"--managed-attach\"\n\ + socket=$2\n\ + daemon=$3\n\ + shift 3\n\ + exec \"$PMACS_REAL_GPU\" --headless-managed-probe \ + \"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"", + ); + + let output = Command::new(pmacs_binary()) + .args(["--gpu", "--socket"]) + .arg(&socket) + .arg("opened.txt") + .current_dir(&cwd) + .env(TEST_GPU_OVERRIDE, &wrapper) + .env("PMACS_REAL_GPU", gpu_binary()) + .env("PMACS_TEST_REPORT", &report) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .output() + .expect("run one-command target flow"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let facts = parse_report(&report); + assert_eq!(facts.get("phase").map(String::as_str), Some("complete")); + assert_eq!( + facts + .get("server_protocol_version") + .and_then(|value| value.parse::().ok()), + Some(PROTOCOL_VERSION) + ); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + } + + #[test] + fn target_bootstrap_is_snapshot_first_deduplicated_and_identical_for_daemon_reuse_or_spawn() { + let temp = secure_tempdir(); + let cwd = temp.path().join("workspace"); + fs::create_dir(&cwd).expect("create workspace"); + fs::write(cwd.join("alpha.txt"), "alpha\n").expect("write alpha"); + fs::write(cwd.join("beta.txt"), "beta\n").expect("write beta"); + let raw_name = OsString::from_vec(vec![b'r', b'a', b'w', 0xff]); + fs::write(cwd.join(&raw_name), "raw\n").expect("write non-UTF-8 target"); + + let existing_socket = temp.path().join("existing-target.sock"); + let mut daemon = spawn_daemon(&existing_socket, &[]); + let mut alpha = attach_target(&existing_socket, &cwd, Path::new("alpha.txt")); + let mut same_alpha = attach_target(&existing_socket, &cwd, Path::new("./alpha.txt")); + let beta = attach_target(&existing_socket, &cwd, Path::new("nested/../beta.txt")); + assert_eq!(alpha.replica.materialize_string(), "alpha\n"); + assert_eq!(same_alpha.buffer_id, alpha.buffer_id); + assert_eq!(beta.replica.materialize_string(), "beta\n"); + assert_ne!(beta.buffer_id, alpha.buffer_id); + let raw = attach_target(&existing_socket, &cwd, Path::new(raw_name.as_os_str())); + assert_eq!(raw.replica.materialize_string(), "raw\n"); + let missing_path = Path::new("new-draft.txt"); + let missing = attach_target(&existing_socket, &cwd, missing_path); + assert_eq!(missing.replica.materialize_string(), ""); + assert!(!cwd.join(missing_path).exists()); + let same_missing = attach_target(&existing_socket, &cwd, Path::new("./new-draft.txt")); + assert_eq!(same_missing.buffer_id, missing.buffer_id); + + let version = alpha.replica.version(); + let alpha_len = alpha.replica.len_utf8(); + alpha + .replica + .insert(alpha_len, "unsaved") + .expect("optimistic alpha edit"); + let op_bytes = alpha + .replica + .export_updates_since(&version) + .expect("export alpha edit"); + write_message( + &mut alpha.stream, + &FrontendEvent::CrdtOp { + frontend_id: alpha.frontend_id, + buffer_id: alpha.buffer_id, + op: pmacs::rope::CrdtOp { + peer_id: alpha.frontend_id.0, + bytes: op_bytes, + }, + }, + ) + .expect("send alpha edit"); + loop { + match read_message::(&mut same_alpha.stream) + .expect("read alpha broadcast") + { + InstanceMessage::CrdtOp { buffer_id, op } if buffer_id == alpha.buffer_id => { + same_alpha + .replica + .import_updates(&op.bytes) + .expect("import alpha broadcast"); + break; + } + _ => {} + } + } + assert_eq!(same_alpha.replica.materialize_string(), "alpha\nunsaved"); + + let reopened = attach_target(&existing_socket, &cwd, &cwd.join("alpha.txt")); + assert_eq!(reopened.buffer_id, alpha.buffer_id); + assert_eq!(reopened.replica.materialize_string(), "alpha\nunsaved"); + + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + + let spawned_socket = temp.path().join("spawned-target.sock"); + let report = temp.path().join("target-report"); + let mut probe = ManagedProbe::spawn_target( + &spawned_socket, + &report, + &pmacs_binary(), + temp.path(), + &cwd, + Path::new("beta.txt"), + ); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + assert!(probe.close().success()); + } + + #[test] + fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() { + let temp = secure_tempdir(); + let socket = temp.path().join("target-failure.sock"); + let mut daemon = spawn_daemon(&socket, &[]); + let cwd = temp.path().as_os_str().as_encoded_bytes().to_vec(); + let invalid = [ + (b"relative".to_vec(), b"note".to_vec()), + (cwd.clone(), Vec::new()), + (cwd.clone(), b"bad\0name".to_vec()), + (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); + assert_eq!(messages.len(), 1, "failure must send no snapshot"); + match &messages[0] { + InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { + assert!(!message.is_empty()); + assert!(message.len() <= 4 * 1024); + } + other => panic!("expected bounded target failure, got {other:?}"), + } + } + + 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"); + 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(); + + let kill_root = temp.path().join("kill-hook"); + fs::create_dir(&kill_root).expect("create kill hook root"); + fs::set_permissions(&kill_root, fs::Permissions::from_mode(0o700)) + .expect("chmod kill hook root"); + fs::create_dir(kill_root.join("pmacs")).expect("create kill config"); + fs::write( + kill_root.join("pmacs/init.lua"), + "pmacs.hook.add('buffer.after-load', function()\n\ + pmacs.buffer.kill(pmacs.window.buffer())\n\ + end)\n", + ) + .expect("write kill hook"); + let kill_target = kill_root.join("victim.txt"); + fs::write(&kill_target, "victim\n").expect("write victim"); + let kill_socket = kill_root.join("daemon.sock"); + let mut kill_daemon = spawn_daemon(&kill_socket, &[]); + let failed = request_raw_target( + &kill_socket, + kill_root.as_os_str().as_encoded_bytes().to_vec(), + b"victim.txt".to_vec(), + ); + assert!(matches!( + failed.as_slice(), + [InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message })] + if message.contains("removed by a startup hook") + )); + let _ = attach_surviving_frontend(&kill_socket); + signal_pid(kill_daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut kill_daemon, Duration::from_secs(5)).success()); + + let slow_root = temp.path().join("slow-hook"); + fs::create_dir(&slow_root).expect("create slow hook root"); + fs::set_permissions(&slow_root, fs::Permissions::from_mode(0o700)) + .expect("chmod slow hook root"); + fs::create_dir(slow_root.join("pmacs")).expect("create slow config"); + let marker = slow_root.join("hook-started"); + fs::write( + slow_root.join("pmacs/init.lua"), + format!( + "pmacs.hook.add('buffer.after-load', function()\n\ + local f = assert(io.open({marker:?}, 'w')); f:write('started'); f:close()\n\ + os.execute('sleep 1')\n\ + end)\n" + ), + ) + .expect("write slow hook"); + fs::write(slow_root.join("slow.txt"), "slow\n").expect("write slow target"); + let slow_socket = slow_root.join("daemon.sock"); + let mut slow_daemon = spawn_daemon(&slow_socket, &[]); + let report = slow_root.join("report"); + let fake_daemon = slow_root.join("must-not-spawn"); + let mut probe = ManagedProbe::spawn_target( + &slow_socket, + &report, + &fake_daemon, + &slow_root, + &slow_root, + Path::new("slow.txt"), + ); + let marker_deadline = Instant::now() + Duration::from_secs(5); + while !marker.exists() { + assert!( + Instant::now() < marker_deadline, + "slow hook never reached marker" + ); + thread::sleep(Duration::from_millis(10)); + } + assert!( + !report.exists() + || parse_report(&report) + .get("phase") + .is_none_or(|phase| phase != "ready"), + "frontend reported ready while the startup hook was still blocked" + ); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("false") + ); + assert!(probe.close().success()); + signal_pid(slow_daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut slow_daemon, Duration::from_secs(5)).success()); + } + #[test] fn managed_attach_reuses_a_capable_daemon_without_spawning() { let temp = secure_tempdir(); diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs index cdda17f..09a4e7e 100644 --- a/tests/m11_5_semantic_acceptance.rs +++ b/tests/m11_5_semantic_acceptance.rs @@ -32,7 +32,7 @@ use pmacs::cell::CellSize; use pmacs::editor::EditorState; use pmacs::protocol::{ AttachRequest, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceMessage, + InstanceMessage, SessionBootstrapRequest, }; use pmacs::semantic_client::SemanticClient; use pmacs::semantic_render::SemanticRenderState; @@ -268,6 +268,7 @@ fn daemon_routes_semantic_family_to_semantic_session_only() { }, ) .expect("semantic write AttachRequest"); + write_message(&mut sem, &SessionBootstrapRequest::default()).expect("semantic write bootstrap"); // Learn a buffer id from the bootstrap snapshot, then declare a // viewport — the daemon emits nothing semantic until it does diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 8d6f1d2..58cd5f2 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -781,16 +781,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { // (the drop arm itself is pinned beside Frontend::apply_message). #[test] fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { - // Vterm Stage 3 appended the terminal family as v19. This - // acceptance owns the STATUSLINE variant's placement and gate, so - // it tracks the current wire version rather than pinning 18: the - // v18 floor it actually cares about is asserted below and in - // `peer_accepts_statusline_message`. - assert_eq!(PROTOCOL_VERSION, 19); - for version in 6..=19 { + // Vterm Stage 3 appended the terminal family as v19; GPU initial targets + // appended the semantic bootstrap family as v20. This acceptance owns the + // STATUSLINE variant's placement and gate, so it tracks the current wire + // version rather than pinning 18: the v18 floor it actually cares about is + // asserted below and in `peer_accepts_statusline_message`. + assert_eq!(PROTOCOL_VERSION, 20); + for version in 6..=20 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(20)); + assert!(!is_supported_protocol_version(21)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 4a170af..0efaa0e 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -712,8 +712,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("19"), - "the real daemon negotiated v19 with the real client: {text}" + Some("20"), + "the real daemon negotiated v20 with the real client: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), @@ -788,8 +788,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { #[test] fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION, read_message, - write_message, + AttachRequest, FrontendCapabilities, Hello, Key, KeyEvent, PROTOCOL_VERSION, + SessionBootstrapRequest, read_message, write_message, }; use std::os::unix::net::UnixStream; @@ -815,6 +815,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { initial_size: CellSize::new(24, 80), }; write_message(&mut stream, &req).expect("write AttachRequest"); + if semantic { + write_message(&mut stream, &SessionBootstrapRequest::default()) + .expect("write semantic bootstrap"); + } (hello, stream) } @@ -838,7 +842,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - assert_eq!(PROTOCOL_VERSION, 19); + assert_eq!(PROTOCOL_VERSION, 20); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),