diff --git a/Cargo.toml b/Cargo.toml index 8bfb286..051d109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ unicode-width = "0.2" [package] name = "pmacs" +default-run = "pmacs" version = "1.0.0" edition = "2024" rust-version = "1.95" diff --git a/README.md b/README.md index e7abc45..e1c2960 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,33 @@ Single-process TUI: pmacs [FILE] # TUI; -nw reserved for when a GUI default lands ``` -Daemon + attached frontends (build with `--features crdt` for -multi-frontend editing and the GPU frontend): +GPU frontend (one command; the root binary starts or reuses the daemon): ```sh -pmacs --daemon --socket NAME # foreground daemon; bare NAME → - # /pmacs/NAME.sock -pmacs --attach --socket NAME # TUI frontend; F12 detaches -pmacs --attach user@host # remote TUI over SSH -pmacs-gpu --attach /run/user/$UID/pmacs/NAME.sock # GPU frontend +pmacs --gpu # default instance +pmacs --gpu --socket NAME # named instance; bare NAME → + # /pmacs/NAME.sock +``` + +`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. + +Daemon + attached TUI frontends: + +```sh +pmacs --daemon --socket NAME # foreground daemon +pmacs --attach --socket NAME # TUI frontend; F12 detaches +pmacs --attach user@host # remote TUI over SSH +``` + +For debugging an already-running daemon, the low-level GPU command stays +available and never auto-starts or replaces anything: + +```sh +pmacs-gpu --attach /absolute/path/to/pmacs.sock ``` `pmacs --attach` also understands `ssh:user@host/instance`, @@ -126,11 +144,13 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust `1.95.0`, edition 2024); rustup selects it automatically. ```sh -cargo build --release # target/release/pmacs (LuaJIT flavor) -cargo build --release --features crdt # + CRDT buffers (daemon use) -cargo build --release -p pmacs-gpu # the GPU frontend binary -cargo run --release -- # build and run on a file -cargo test --workspace # unit + integration tests (all crates) +# Coherent root + GPU release build. The package-qualified feature keeps +# 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 +cargo run --release -- --version # default-run selects the pmacs binary +cargo test --workspace # unit + integration tests (all crates) cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings # incl. pmacs-gpu ``` diff --git a/docs/gpu-invocation-framing.md b/docs/gpu-invocation-framing.md index 425369e..799ad09 100644 --- a/docs/gpu-invocation-framing.md +++ b/docs/gpu-invocation-framing.md @@ -1,7 +1,7 @@ # GPU invocation — one-command broker framing -**Revision 3 — pre-implementation. Ground truth: canonical `main` @ -`96d0bae`, protocol v19, 2026-07-23.** +**Revision 4 — implemented on `gpu-invocation`. Ground truth: canonical +`main` @ `96d0bae`, protocol v19, 2026-07-23.** The GPU editor works, but reaching it is still a development-session ritual: build two packages with different feature requirements, keep a foreground @@ -30,6 +30,11 @@ keeps `Interrupted` / `WouldBlock` transient inside the post-spawn retry window, states the process-group signal simulation in CI-executable terms, and distinguishes the socket type check from liveness inference. +Revision 4 records the as-built cutover: the root broker, strict GPU CLI, +managed connector, process-group isolation, named child reaper, deterministic +managed probe, acceptance suite, coherent workspace build, and one-command +visible smoke are implemented and verified. + ## Ground truth ### Current user path @@ -611,3 +616,40 @@ Vterm probe continues to cover offscreen wgpu. build succeeds; `cargo run --release -- --version` selects `pmacs` through `default-run`; no documented command requires users to spell the resolved socket pathname for managed GPU startup. + +## As built + +- `Cargo.toml` sets `default-run = "pmacs"`. The documented coherent build is + `cargo build --release --workspace --features pmacs/crdt`. +- `src/main.rs` owns `pmacs --gpu [--socket NAME|PATH]`, the non-CRDT gate, + socket resolution, test override, sibling-first GPU discovery with PATH + fallback, child argv, and exit-status propagation. +- `pmacs-gpu/src/main.rs` accepts only explicit direct, managed, and headless + modes. Managed windowed attach completes before winit creates a window. + Bare invocation is an exit-2 usage error pointing users to `pmacs --gpu`. +- `pmacs-gpu/src/attach.rs` owns connect-or-start policy, the five-second / + 50-ms retry window, socket-type protection, daemon process-group isolation, + and the named child-reaper thread. The first successful protocol connection + wins; protocol/capability failures never authorize replacement. +- `--headless-managed-probe SOCKET REPORT DAEMON_EXE` drives the production + managed connector, writes atomic `phase=ready` / `phase=complete` reports, + holds on stdin, and exposes disconnect plus daemon-reaper observations. +- `tests/gpu_invocation_acceptance.rs` covers the root broker, non-CRDT gate, + existing/missing/stale/racing daemon paths, process-group SIGINT isolation, + capability and protocol mismatches, bounded startup failure, child reaping, + outcome propagation, and strict headless CLI behavior. + +Verification on 2026-07-23: + +- root CLI unit suite: 33 passed; +- required GPU suite: 145 passed; +- managed invocation acceptance: 1 default + 9 CRDT passed; +- Vterm Stage 3: 7 passed with `PMACS_REQUIRE_GPU=1`; +- default / CRDT libraries: 1,768 / 1,944 passed; +- M4 acceptance: 121 passed, 3 ignored, 1 requested skip; +- strict workspace Clippy and formatting passed; +- the documented release workspace build and `cargo run --release -- + --version` passed; +- two real `target/release/pmacs --gpu` launches on Wayland/Vulkan attached at + protocol v19. The first auto-started daemon remained alive after the GPU + process closed; the second reused that same daemon and created no replacement. diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 7377ddb..2b8a0e9 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -17,10 +17,16 @@ //! redraw. use std::collections::VecDeque; +use std::fs; +use std::io; +use std::os::unix::fs::FileTypeExt; use std::os::unix::net::UnixStream; -use std::path::Path; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities, @@ -89,6 +95,172 @@ impl std::fmt::Display for AttachClientError { impl std::error::Error for AttachClientError {} +/// Failure while connecting the managed GPU path. +#[derive(Debug)] +pub enum ManagedAttachError { + /// The daemon connection reached the normal attach client and failed. + Attach(AttachClientError), + /// A refused socket path exists but is not a Unix socket. + NonSocketPath(PathBuf), + /// Inspecting a refused socket path failed. + InspectSocket { + /// Path whose entry type could not be inspected. + path: PathBuf, + /// Filesystem error from `metadata`. + source: io::Error, + }, + /// The requested daemon executable could not be started. + SpawnDaemon { + /// Executable supplied by the root broker. + executable: PathBuf, + /// Process-spawn failure. + source: io::Error, + }, + /// The daemon process could not be queried for an early exit. + ObserveDaemon(io::Error), + /// No attachable daemon appeared before the bounded deadline. + StartupTimeout { + /// Socket path that remained unreachable. + socket: PathBuf, + /// Most recent connect error. + connect: io::Error, + /// Observed daemon process outcome, when it exited early. + daemon_status: Option, + }, +} + +impl std::fmt::Display for ManagedAttachError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Attach(error) => error.fmt(f), + Self::NonSocketPath(path) => write!( + f, + "refusing to start a daemon: socket path {} exists and is not a Unix socket", + path.display() + ), + Self::InspectSocket { path, source } => write!( + f, + "cannot inspect refused socket path {}: {source}", + path.display() + ), + Self::SpawnDaemon { executable, source } => write!( + f, + "could not start daemon executable {}: {source}", + executable.display() + ), + Self::ObserveDaemon(source) => { + write!(f, "could not inspect the managed daemon process: {source}") + } + Self::StartupTimeout { + socket, + connect, + daemon_status, + } => { + write!( + f, + "daemon did not become attachable on {} within 5 seconds: {connect}", + socket.display() + )?; + if let Some(status) = daemon_status { + write!(f, " (spawned daemon {status})")?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for ManagedAttachError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Attach(error) => Some(error), + Self::InspectSocket { source, .. } + | Self::SpawnDaemon { source, .. } + | Self::ObserveDaemon(source) + | Self::StartupTimeout { + connect: source, .. + } => Some(source), + Self::NonSocketPath(_) => None, + } + } +} + +impl From for ManagedAttachError { + fn from(error: AttachClientError) -> Self { + Self::Attach(error) + } +} + +#[derive(Debug, Default)] +struct DaemonProcessState { + reaped: bool, + wait_result: Option, +} + +/// Observable process facts for a daemon started by managed attach. +#[derive(Clone, Debug)] +pub struct ManagedDaemonFacts { + spawned: bool, + pid: Option, + state: Arc>, +} + +impl ManagedDaemonFacts { + fn existing() -> Self { + Self { + spawned: false, + pid: None, + state: Arc::new(Mutex::new(DaemonProcessState::default())), + } + } + + fn spawned(pid: u32) -> Self { + Self { + spawned: true, + pid: Some(pid), + state: Arc::new(Mutex::new(DaemonProcessState::default())), + } + } + + fn record_wait(&self, result: String) { + let mut state = self.state.lock().expect("managed daemon state lock"); + state.reaped = true; + state.wait_result = Some(result); + } + + /// Whether this invocation started a daemon process. + pub fn spawned_daemon(&self) -> bool { + self.spawned + } + + /// Process ID of the daemon this invocation started. + pub fn daemon_pid(&self) -> Option { + self.pid + } + + /// Whether the started child has been observed with `wait`. + pub fn daemon_reaped(&self) -> bool { + self.state.lock().expect("managed daemon state lock").reaped + } + + /// Recorded `wait` result for a completed child. + pub fn daemon_wait_result(&self) -> Option { + self.state + .lock() + .expect("managed daemon state lock") + .wait_result + .clone() + } +} + +/// A successful attach plus lifecycle facts for any daemon it started. +pub struct ManagedAttach { + /// Connected semantic attach client. + pub client: AttachClient, + /// Shared facts updated by the daemon child reaper. + pub daemon: ManagedDaemonFacts, +} + /// The capabilities a semantic `pmacs-gpu` frontend requires the daemon to /// advertise in `Hello.instance_capabilities`, and which of them this /// daemon is missing (audit F-003). Empty ⇒ the attach can proceed. @@ -249,7 +421,13 @@ 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) +} +fn connect_stream_with_sink( + stream: UnixStream, + sink: impl Fn(AttachEvent) -> bool + Send + 'static, +) -> Result { // Hello round-trip. let mut handshake_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let hello: Hello = read_message(&mut handshake_stream).map_err(AttachClientError::Handshake)?; @@ -391,6 +569,172 @@ pub fn connect_with_sink( }) } +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( + socket_path: &Path, + daemon_executable: &Path, + proxy: EventLoopProxy, +) -> Result { + connect_managed_with_sink(socket_path, daemon_executable, 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( + socket_path: &Path, + daemon_executable: &Path, + sink: impl Fn(AttachEvent) -> bool + Send + 'static, +) -> Result { + connect_managed_inner( + socket_path, + daemon_executable, + |path| UnixStream::connect(path), + spawn_daemon, + MANAGED_STARTUP_TIMEOUT, + MANAGED_RETRY_INTERVAL, + sink, + ) +} + +fn spawn_daemon(daemon_executable: &Path, socket_path: &Path) -> io::Result { + let mut command = Command::new(daemon_executable); + command + .arg("--daemon") + .arg("--socket") + .arg(socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + command.process_group(0); + command.spawn() +} + +fn initial_startup_authorized( + socket_path: &Path, + error: &io::Error, +) -> Result { + match error.kind() { + io::ErrorKind::NotFound => Ok(true), + io::ErrorKind::ConnectionRefused => match fs::metadata(socket_path) { + Ok(metadata) if metadata.file_type().is_socket() => Ok(true), + Ok(_) => Err(ManagedAttachError::NonSocketPath(socket_path.to_owned())), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(true), + Err(source) => Err(ManagedAttachError::InspectSocket { + path: socket_path.to_owned(), + source, + }), + }, + _ => Ok(false), + } +} + +fn post_spawn_retryable(socket_path: &Path, error: &io::Error) -> Result { + match error.kind() { + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock => Ok(true), + _ => initial_startup_authorized(socket_path, error), + } +} + +fn start_daemon_reaper(mut child: Child, facts: ManagedDaemonFacts) { + thread::Builder::new() + .name("pmacs-gpu daemon reaper".into()) + .spawn(move || { + let result = match child.wait() { + Ok(status) => status.to_string(), + Err(error) => format!("wait failed: {error}"), + }; + facts.record_wait(result); + }) + .expect("spawn managed daemon reaper thread"); +} + +#[allow(clippy::too_many_arguments)] +fn connect_managed_inner( + socket_path: &Path, + daemon_executable: &Path, + mut connector: C, + spawner: S, + timeout: Duration, + retry_interval: Duration, + sink: F, +) -> Result +where + C: FnMut(&Path) -> io::Result, + S: FnOnce(&Path, &Path) -> io::Result, + F: Fn(AttachEvent) -> bool + Send + 'static, +{ + match connector(socket_path) { + Ok(stream) => { + let client = connect_stream_with_sink(stream, sink)?; + return Ok(ManagedAttach { + client, + daemon: ManagedDaemonFacts::existing(), + }); + } + Err(error) => { + if !initial_startup_authorized(socket_path, &error)? { + return Err(AttachClientError::Connect(error).into()); + } + } + } + + let mut child = spawner(daemon_executable, socket_path).map_err(|source| { + ManagedAttachError::SpawnDaemon { + executable: daemon_executable.to_owned(), + source, + } + })?; + let daemon = ManagedDaemonFacts::spawned(child.id()); + let deadline = Instant::now() + timeout; + + loop { + match connector(socket_path) { + Ok(stream) => { + let attached = connect_stream_with_sink(stream, sink); + start_daemon_reaper(child, daemon.clone()); + return Ok(ManagedAttach { + client: attached?, + daemon, + }); + } + Err(error) => { + if !post_spawn_retryable(socket_path, &error)? { + return Err(AttachClientError::Connect(error).into()); + } + if let Some(status) = child + .try_wait() + .map_err(ManagedAttachError::ObserveDaemon)? + { + daemon.record_wait(status.to_string()); + } + if daemon.daemon_reaped() { + let status = daemon.daemon_wait_result(); + if Instant::now() >= deadline { + return Err(ManagedAttachError::StartupTimeout { + socket: socket_path.to_owned(), + connect: error, + daemon_status: status, + }); + } + } else if Instant::now() >= deadline { + return Err(ManagedAttachError::StartupTimeout { + socket: socket_path.to_owned(), + connect: error, + daemon_status: None, + }); + } + thread::sleep( + retry_interval.min(deadline.saturating_duration_since(Instant::now())), + ); + } + } + } +} + /// Handle the main loop keeps after `connect` returns. It queues /// `FrontendEvent`s for the attach writer thread. pub struct AttachClient { @@ -851,4 +1195,95 @@ mod tests { "peer should see EOF after the shutdown" ); } + #[test] + fn managed_attach_starts_only_for_absent_or_refused_sockets() { + let socket = Path::new("/tmp/pmacs-managed-test.sock"); + assert!( + initial_startup_authorized(socket, &io::Error::new(io::ErrorKind::NotFound, "absent")) + .expect("classify absent socket") + ); + assert!( + initial_startup_authorized( + socket, + &io::Error::new(io::ErrorKind::ConnectionRefused, "refused") + ) + .expect("classify vanished socket") + ); + for kind in [ + io::ErrorKind::PermissionDenied, + io::ErrorKind::Interrupted, + io::ErrorKind::WouldBlock, + io::ErrorKind::InvalidInput, + ] { + assert!( + !initial_startup_authorized(socket, &io::Error::new(kind, "final")) + .expect("classify final connect error"), + "{kind:?} must not authorize daemon startup" + ); + } + } + + #[test] + fn managed_retry_adds_only_interrupted_and_would_block() { + let socket = Path::new("/tmp/pmacs-managed-test.sock"); + for kind in [io::ErrorKind::Interrupted, io::ErrorKind::WouldBlock] { + assert!( + post_spawn_retryable(socket, &io::Error::new(kind, "transient")) + .expect("classify transient retry") + ); + } + assert!( + !post_spawn_retryable( + socket, + &io::Error::new(io::ErrorKind::PermissionDenied, "final") + ) + .expect("classify final retry error") + ); + } + + #[test] + fn managed_attach_refuses_a_non_socket_path_without_spawning() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let result = connect_managed_inner( + &path, + Path::new("/unused/pmacs"), + |_| { + Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + "synthetic refused connect", + )) + }, + |_, _| panic!("non-socket path must not spawn"), + Duration::from_millis(1), + Duration::from_millis(1), + |_| false, + ); + assert!(matches!( + result, + Err(ManagedAttachError::NonSocketPath(rejected)) if rejected == path + )); + } + + #[test] + fn managed_attach_fails_closed_on_non_retryable_connect_errors() { + let result = connect_managed_inner( + Path::new("/tmp/pmacs-managed-test.sock"), + Path::new("/unused/pmacs"), + |_| { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "synthetic permission failure", + )) + }, + |_, _| panic!("permission failure must not spawn"), + Duration::from_millis(1), + Duration::from_millis(1), + |_| false, + ); + assert!(matches!( + result, + Err(ManagedAttachError::Attach(AttachClientError::Connect(error))) + if error.kind() == io::ErrorKind::PermissionDenied + )); + } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 464fe7b..a312108 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1,19 +1,19 @@ //! pmacs-gpu — GPU/GUI frontend for pmacs. //! -//! Two run modes: +//! User-facing invocation is strict: //! -//! - **Hello-world** (no `--attach` argument; session 2 default). -//! Opens a window and renders "hello, pmacs" in the bundled -//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack -//! without depending on a daemon. -//! - **Attach** (`--attach `; session 3+). Connects -//! to a running pmacs daemon, negotiates `semantic_render + -//! crdt_replica`, imports the daemon's `BufferSnapshot` into a -//! local loro replica, sends a `Viewport` back to request scoped -//! styling, and consumes the `StyleSpans` stream — rendering the -//! rope with per-span colors via cosmic-text's `set_rich_text`. -//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans` -//! frames re-style. +//! - `pmacs-gpu --attach ` directly attaches to an +//! already-running daemon and never starts or replaces it. +//! - The root `pmacs --gpu` broker invokes a hidden managed mode that connects +//! first, starts the supplied daemon only for an absent/refused socket, and +//! creates the window only after protocol and capability negotiation. +//! - Headless probe modes exercise the same direct and managed production +//! connectors for acceptance without requiring a display. +//! +//! An attached frontend imports the daemon's `BufferSnapshot` into a local +//! loro replica, sends a `Viewport` back to request scoped styling, and +//! consumes the `StyleSpans` stream. Live `CrdtOp` updates apply to the +//! replica; subsequent `StyleSpans` frames re-style it. //! //! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's //! adversarial-verification framing applies from session 4 forward; @@ -524,10 +524,7 @@ const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32; const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4]; -/// Text the hello-world (and attach-pre-snapshot / attach-failed) -/// modes render. Once the daemon's `BufferSnapshot` arrives the -/// rendered text becomes the rope contents instead. -const HELLO_TEXT: &str = "hello, pmacs"; +const CONNECTING_TEXT: &str = "(connecting...)"; /// Container id the daemon uses on its loro `LoroDoc` for the /// buffer's text. Must match `pmacs::crdt::CrdtState`'s container @@ -546,13 +543,20 @@ pub enum AppEvent { } /// CLI mode derived from argv. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum Mode { - /// `pmacs-gpu` (no args): inert hello-world. - HelloWorld, - /// `pmacs-gpu --attach `: connect + render the daemon's - /// rope. + /// Print CLI help without initializing winit or wgpu. + Help, + /// Print package and protocol versions without initializing winit or wgpu. + Version, + /// `pmacs-gpu --attach `: strict direct attach to an existing daemon. Attach { socket: PathBuf }, + /// Hidden root-broker entry: connect or start the supplied daemon before + /// creating the window. + ManagedAttach { + socket: PathBuf, + daemon_executable: PathBuf, + }, /// `pmacs-gpu --headless-probe `: attach through /// the real client, render real frames offscreen, and write a /// machine-readable report. @@ -563,6 +567,12 @@ enum Mode { /// `apply_attach_message`, and the same `render_to_view` the windowed /// mode does — only winit is absent, because CI has no display. HeadlessProbe { socket: PathBuf, report: PathBuf }, + /// Hidden display-less acceptance seam for managed daemon lifecycle. + HeadlessManagedProbe { + socket: PathBuf, + report: PathBuf, + daemon_executable: PathBuf, + }, } /// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes @@ -580,19 +590,67 @@ fn decimal_digits(mut n: usize) -> u32 { fn main() { env_logger::init(); - let mode = parse_args(std::env::args().skip(1).collect()); - if let Mode::HeadlessProbe { socket, report } = &mode { - std::process::exit(run_headless_probe(socket, report)); + let mode = match parse_args(&std::env::args().skip(1).collect::>()) { + Ok(mode) => mode, + Err(error) => { + eprintln!("pmacs-gpu: {error}\n\n{GPU_USAGE}"); + std::process::exit(2); + } + }; + match &mode { + Mode::Help => { + println!("{GPU_USAGE}"); + return; + } + Mode::Version => { + println!( + "pmacs-gpu {} (protocol v{})", + env!("CARGO_PKG_VERSION"), + pmacs_protocol::PROTOCOL_VERSION + ); + return; + } + Mode::HeadlessProbe { socket, report } => { + std::process::exit(run_headless_probe(socket, report)); + } + Mode::HeadlessManagedProbe { + socket, + report, + daemon_executable, + } => { + std::process::exit(run_headless_managed_probe( + socket, + report, + daemon_executable, + )); + } + Mode::Attach { .. } | Mode::ManagedAttach { .. } => {} } + let event_loop = EventLoop::::with_user_event() .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); + let attach_client = if let Mode::ManagedAttach { + socket, + daemon_executable, + } = &mode + { + match attach::connect_managed(socket, daemon_executable, proxy.clone()) { + Ok(managed) => Some(managed.client), + Err(error) => { + eprintln!("pmacs-gpu: managed attach failed: {error}"); + std::process::exit(1); + } + } + } else { + None + }; let mut app = App { mode, proxy: Some(proxy), state: None, - attach_client: None, + attach_client, modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop @@ -765,6 +823,162 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { 0 } +/// Exercise the real managed connector without creating a display. +/// +/// After the first real `BufferSnapshot`, the probe writes `phase=ready` and +/// holds the session open until stdin reaches EOF. Lifecycle observations +/// refresh the report while held; EOF writes `phase=complete`. +#[allow( + 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 { + 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() + }) { + Ok(managed) => managed, + Err(error) => { + let contents = format!("phase=error\nerror={error}\n"); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: attach failed: {error}"); + return 4; + } + }; + let client = managed.client; + let daemon = managed.daemon; + let protocol = client.server_protocol_version(); + + let (stdin_tx, stdin_rx) = mpsc::channel(); + std::thread::Builder::new() + .name("pmacs-gpu managed probe stdin".into()) + .spawn(move || { + let mut bytes = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut bytes); + let _ = stdin_tx.send(()); + }) + .expect("spawn managed probe stdin reader"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut ready = false; + let mut stdin_closed = false; + let mut disconnect = String::new(); + let mut last_reaped = false; + let mut last_wait_result = None; + let mut last_disconnect = String::new(); + loop { + if stdin_rx.try_recv().is_ok() { + stdin_closed = true; + } + match event_rx.recv_timeout(Duration::from_millis(50)) { + Ok(AttachEvent::Message(message)) => { + if matches!(*message, InstanceMessage::BufferSnapshot { .. }) && !ready { + ready = true; + if let Err(error) = + write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + { + eprintln!( + "pmacs-gpu managed probe: writing {} failed: {error}", + report.display() + ); + return 5; + } + } + } + Ok(AttachEvent::Disconnected(reason)) => disconnect = reason, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + if disconnect.is_empty() { + "attach event channel closed".clone_into(&mut disconnect); + } + } + } + + let reaped = daemon.daemon_reaped(); + let wait_result = daemon.daemon_wait_result(); + if ready + && (reaped != last_reaped + || wait_result != last_wait_result + || disconnect != last_disconnect) + { + if let Err(error) = + write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + { + eprintln!( + "pmacs-gpu managed probe: writing {} failed: {error}", + report.display() + ); + return 5; + } + last_reaped = reaped; + last_wait_result = wait_result; + last_disconnect.clone_from(&disconnect); + } + + if ready && stdin_closed { + if let Err(error) = + write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect) + { + eprintln!( + "pmacs-gpu managed probe: writing {} failed: {error}", + report.display() + ); + return 5; + } + return 0; + } + if !ready && Instant::now() >= deadline { + let contents = format!( + "phase=error\nerror=timed out waiting for BufferSnapshot\ndisconnect={disconnect}\n" + ); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: timed out waiting for BufferSnapshot"); + return 6; + } + } +} + +fn write_managed_probe_report( + report: &Path, + phase: &str, + protocol: u32, + daemon: &attach::ManagedDaemonFacts, + disconnect: &str, +) -> std::io::Result<()> { + use std::fmt::Write as _; + + let mut out = String::new(); + let _ = writeln!(out, "phase={phase}"); + let _ = writeln!(out, "server_protocol_version={protocol}"); + let _ = writeln!(out, "buffer_snapshot=true"); + let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon()); + let _ = writeln!( + out, + "daemon_pid={}", + daemon.daemon_pid().unwrap_or_default() + ); + let _ = writeln!(out, "daemon_reaped={}", daemon.daemon_reaped()); + let _ = writeln!( + out, + "daemon_wait_result={}", + daemon.daemon_wait_result().unwrap_or_default() + ); + let _ = writeln!(out, "disconnect={disconnect}"); + write_probe_report(report, &out) +} + +fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> { + let mut temporary = report.as_os_str().to_os_string(); + temporary.push(".tmp"); + let temporary = PathBuf::from(temporary); + std::fs::write(&temporary, contents)?; + std::fs::rename(temporary, report) +} + /// Named observations the headless probe reports back to the acceptance. #[derive(Default)] struct ProbeFacts { @@ -799,51 +1013,49 @@ fn frame_probe_text(frame: &TerminalFrame) -> String { text } -/// Tiny argv parser. No `clap` because the surface is genuinely two -/// shapes; full CLI parsing arrives when there's more to parse. The -/// `for` ranges over a small set: at most one `--attach ` or -/// `--help` arrives, plus any stray unrecognized flag. -fn parse_args(args: Vec) -> Mode { - let mut iter = args.into_iter(); - let Some(first) = iter.next() else { - return Mode::HelloWorld; - }; - match first.as_str() { - "--attach" => { - let socket = iter.next().unwrap_or_else(|| { - eprintln!("pmacs-gpu: --attach requires a socket path"); - std::process::exit(2); - }); - Mode::Attach { +const GPU_USAGE: &str = "\ +pmacs-gpu — GPU frontend for pmacs + +USAGE: + pmacs-gpu --attach attach to an existing daemon + pmacs-gpu --help print this help + pmacs-gpu --version print package and protocol versions"; + +/// Strict parser for direct, managed, and headless GPU entry points. +fn parse_args(args: &[String]) -> Result { + 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, daemon_executable] if flag == "--managed-attach" => { + Ok(Mode::ManagedAttach { socket: PathBuf::from(socket), - } + daemon_executable: PathBuf::from(daemon_executable), + }) } - "--headless-probe" => { - let socket = iter.next().unwrap_or_else(|| { - eprintln!("pmacs-gpu: --headless-probe requires a socket path"); - std::process::exit(2); - }); - let report = iter.next().unwrap_or_else(|| { - eprintln!("pmacs-gpu: --headless-probe requires a report path"); - std::process::exit(2); - }); - Mode::HeadlessProbe { + [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" => { + Ok(Mode::HeadlessManagedProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), - } + daemon_executable: PathBuf::from(daemon_executable), + }) } - "--help" | "-h" => { - eprintln!( - "pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \ - hello-world (renders \"hello, pmacs\")\n pmacs-gpu --attach \ - connect to a daemon's Unix socket and render its rope\n" - ); - std::process::exit(0); - } - other => { - eprintln!("pmacs-gpu: unrecognized argument: {other}"); - std::process::exit(2); + [] => Err("an explicit mode is required; use --attach ".to_owned()), + [flag, ..] + if matches!( + flag.as_str(), + "--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe" + ) => + { + Err(format!("{flag} received the wrong number of operands")) } + [other, ..] => Err(format!("unrecognized argument: {other}")), } } @@ -1554,11 +1766,12 @@ impl ApplicationHandler for App { if self.state.is_some() { return; } - let initial_text = match &self.mode { - Mode::HelloWorld => HELLO_TEXT, - Mode::Attach { .. } | Mode::HeadlessProbe { .. } => "(connecting...)", - }; - self.state = Some(State::new(event_loop, initial_text)); + self.state = Some(State::new(event_loop, CONNECTING_TEXT)); + if let Some(client) = self.attach_client.as_ref() + && let Some(state) = self.state.as_mut() + { + state.set_frontend_id(client.frontend_id()); + } // In attach mode, kick off the connection now that the event // loop is running and a proxy is available. Failure logs and @@ -13995,4 +14208,83 @@ mod tests { hostile.title = Some("\u{1b}]0;pwned\u{7}".into()); assert!(hostile.validate().is_err()); } + #[test] + fn gpu_cli_accepts_only_explicit_exact_modes() { + let args = |values: &[&str]| { + values + .iter() + .map(|value| (*value).to_owned()) + .collect::>() + }; + assert_eq!( + parse_args(&args(&["--attach", "/tmp/pmacs.sock"])), + Ok(Mode::Attach { + socket: PathBuf::from("/tmp/pmacs.sock"), + }) + ); + assert_eq!( + parse_args(&args(&[ + "--managed-attach", + "/tmp/pmacs.sock", + "/bin/pmacs" + ])), + Ok(Mode::ManagedAttach { + socket: PathBuf::from("/tmp/pmacs.sock"), + daemon_executable: PathBuf::from("/bin/pmacs"), + }) + ); + assert_eq!( + parse_args(&args(&[ + "--headless-probe", + "/tmp/pmacs.sock", + "/tmp/report" + ])), + Ok(Mode::HeadlessProbe { + socket: PathBuf::from("/tmp/pmacs.sock"), + report: PathBuf::from("/tmp/report"), + }) + ); + assert_eq!( + parse_args(&args(&[ + "--headless-managed-probe", + "/tmp/pmacs.sock", + "/tmp/report", + "/bin/pmacs" + ])), + Ok(Mode::HeadlessManagedProbe { + socket: PathBuf::from("/tmp/pmacs.sock"), + report: PathBuf::from("/tmp/report"), + daemon_executable: PathBuf::from("/bin/pmacs"), + }) + ); + } + + #[test] + fn gpu_cli_rejects_bare_missing_and_trailing_arguments() { + let invalid = [ + vec![], + vec!["--attach"], + vec!["--attach", "/tmp/pmacs.sock", "ignored"], + vec!["--headless-probe", "/tmp/pmacs.sock"], + vec![ + "--headless-probe", + "/tmp/pmacs.sock", + "/tmp/report", + "ignored", + ], + vec!["--managed-attach", "/tmp/pmacs.sock"], + vec!["--headless-managed-probe", "/tmp/pmacs.sock", "/tmp/report"], + vec!["research"], + ]; + for values in invalid { + let args = values + .iter() + .map(|value| (*value).to_owned()) + .collect::>(); + assert!( + parse_args(&args).is_err(), + "accepted invalid argv: {values:?}" + ); + } + } } diff --git a/src/main.rs b/src/main.rs index 47025e7..167ed63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,59 +2,36 @@ //! Pmacs binary entry point. //! -//! Parses command-line arguments and dispatches to [`pmacs::editor::run`]. +//! Parses command-line arguments and dispatches local TUI, daemon, attach, +//! remote bridge, and managed GPU modes. //! //! # Command-line surface //! //! ```text //! pmacs [-nw|--no-window] [--help] [--version] [FILE] +//! pmacs --gpu [--socket NAME|PATH] +//! pmacs --daemon [--socket NAME|PATH] +//! pmacs --attach [--socket NAME|PATH] +//! pmacs --attach +//! pmacs --daemon-attach [--socket NAME|PATH] //! ``` //! -//! * `FILE` (positional, optional): file to open. Without one, the editor -//! opens an empty `*scratch*` buffer. -//! * `-nw` / `--no-window`: select the terminal (TUI) frontend explicitly. -//! This is the *only* frontend pmacs ships in v0.1, so the flag is -//! currently a no-op marker — but it's parsed and recorded now so that -//! when a GUI frontend lands in M4 ("The Service Layer"), `pmacs` with -//! no flags will default to the GUI and `pmacs -nw` will keep launching -//! the TUI exactly as it does today. This mirrors GNU Emacs's -//! `emacs -nw` and Doom's behavior, and lets users wire `pmacs -nw` into -//! `EDITOR=` / git hooks today without their config breaking when the -//! GUI ships. -//! * `--help` / `-h`, `--version` / `-V`: standard. +//! `--gpu` is additive: bare `pmacs [FILE]` remains the local TUI. The root +//! broker resolves the socket, requires a CRDT-capable build, discovers the +//! separate `pmacs-gpu` executable, and waits for that frontend's outcome. +//! The GPU child owns connect-or-start orchestration for the supplied daemon +//! executable. Direct TUI and GPU attach modes remain available for debugging. //! //! Anything else is a usage error and exits 2. -//! -//! # Frontend selection (planning note for M4) -//! -//! When the GUI lands, the entry-point split looks like this: -//! -//! ```ignore -//! match selected_frontend(&args) { -//! Frontend::Tui => editor::run_tui(file), -//! Frontend::Gui => editor::run_gui(file), -//! } -//! ``` -//! -//! Selection precedence (high to low): -//! 1. Explicit `-nw` / `--no-window` → TUI. -//! 2. Explicit `--gui` (future) → GUI. -//! 3. `PMACS_FRONTEND=tui|gui` env var. -//! 4. `$DISPLAY` / `$WAYLAND_DISPLAY` present and a GUI build was linked -//! in → GUI; otherwise → TUI. -//! 5. Fallback: TUI. -//! -//! `editor::run` stays as the canonical TUI entry point. The split -//! happens in `main`, not deeper, so the rest of the codebase stays -//! frontend-agnostic at the [`pmacs::frontend`] trait surface. -use std::path::PathBuf; -use std::process::ExitCode; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; use pmacs::protocol::{AttachTarget, AttachTargetError}; const USAGE: &str = "\ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] + pmacs --gpu [--socket NAME|PATH] pmacs --daemon [--socket NAME|PATH] pmacs --attach [--socket NAME|PATH] pmacs --attach @@ -64,6 +41,8 @@ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE] (currently the only frontend; reserved for the M4 GUI rollout, where `pmacs` will default to the GUI and `-nw` will keep launching the TUI) + --gpu start or reuse a CRDT daemon, then launch the + separate pmacs-gpu frontend --daemon run as a foreground daemon listening on a Unix socket; supervised by the user (systemd, tmux, `nohup &`, etc.) @@ -116,6 +95,9 @@ 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 --daemon [--socket ...]`: run a foreground daemon on a /// Unix socket, supervised by the user. Daemon { socket: Option }, @@ -199,10 +181,15 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result CliResult { let mut file: Option = None; let mut frontend = FrontendChoice::Auto; let mut daemon = false; + let mut gpu = false; let mut attach = false; let mut daemon_attach = false; let mut socket: Option = None; @@ -210,6 +197,7 @@ fn parse_args(args: &[String]) -> CliResult { 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, @@ -242,12 +230,25 @@ fn parse_args(args: &[String]) -> CliResult { } } } - let mode_flags = u8::from(daemon) + u8::from(attach) + u8::from(daemon_attach); + let mode_flags = u8::from(gpu) + u8::from(daemon) + u8::from(attach) + u8::from(daemon_attach); if mode_flags > 1 { return CliResult::Error( - "--daemon, --attach, and --daemon-attach are mutually exclusive".into(), + "--gpu, --daemon, --attach, and --daemon-attach are mutually exclusive".into(), ); } + 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 }, + }); + } if daemon { if file.is_some() { return CliResult::Error("--daemon does not take a file argument".into()); @@ -280,11 +281,82 @@ fn parse_args(args: &[String]) -> CliResult { mode: Mode::DaemonAttach { socket }, }); } + if socket.is_some() { + return CliResult::Error( + "--socket requires --gpu, --daemon, --attach, or --daemon-attach".into(), + ); + } CliResult::Run(CliArgs { mode: Mode::Local { file, frontend }, }) } +const PMACS_TEST_GPU_BIN: &str = "PMACS_TEST_GPU_BIN"; + +fn gpu_binary(current_exe: &Path, override_bin: Option) -> (PathBuf, PathBuf) { + let sibling = current_exe + .parent() + .unwrap_or_else(|| Path::new("")) + .join("pmacs-gpu"); + if let Some(override_bin) = override_bin { + return (override_bin, sibling); + } + if sibling.exists() { + return (sibling.clone(), sibling); + } + (PathBuf::from("pmacs-gpu"), sibling) +} + +fn run_gpu(socket: Option<&str>) -> ExitCode { + if !cfg!(feature = "crdt") { + eprintln!("pmacs: --gpu requires pmacs built with --features crdt"); + return ExitCode::FAILURE; + } + + let socket_path = pmacs::socket_path::resolve_socket_path(socket); + let current_exe = match std::env::current_exe() { + Ok(path) => path, + Err(error) => { + eprintln!("pmacs: cannot locate the running pmacs executable: {error}"); + return ExitCode::FAILURE; + } + }; + let (gpu, sibling) = gpu_binary( + ¤t_exe, + std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from), + ); + let status = Command::new(&gpu) + .arg("--managed-attach") + .arg(&socket_path) + .arg(¤t_exe) + .status(); + match status { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => { + eprintln!("pmacs: GPU frontend {} exited with {status}", gpu.display()); + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .map_or(ExitCode::FAILURE, ExitCode::from) + } + Err(error) => { + if gpu == Path::new("pmacs-gpu") { + eprintln!( + "pmacs: could not launch GPU frontend: sibling {} is absent and PATH lookup \ + for pmacs-gpu failed: {error}", + sibling.display() + ); + } else { + eprintln!( + "pmacs: could not launch GPU frontend {}: {error}", + gpu.display() + ); + } + ExitCode::FAILURE + } + } +} + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match parse_args(&args) { @@ -316,6 +388,7 @@ fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::Gpu { socket } => run_gpu(socket.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 @@ -736,4 +809,63 @@ 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")), + ] { + let argv = args(&argv); + match parse_args(&argv) { + CliResult::Run(CliArgs { + mode: Mode::Gpu { socket }, + }) => assert_eq!(socket.as_deref(), expected), + other => panic!("expected GPU mode; got {other:?}"), + } + } + } + + #[test] + fn gpu_flag_rejects_files_tui_and_other_modes() { + for argv in [ + vec!["--gpu", "README.md"], + vec!["--gpu", "-nw"], + vec!["--gpu", "--daemon"], + vec!["--gpu", "--attach"], + vec!["--gpu", "--daemon-attach"], + ] { + assert!( + matches!(parse_args(&args(&argv)), CliResult::Error(_)), + "accepted conflicting argv: {argv:?}" + ); + } + } + + #[test] + fn bare_socket_is_never_silently_ignored() { + match parse_args(&args(&["--socket", "research"])) { + CliResult::Error(message) => assert!(message.contains("--socket requires")), + other => panic!("expected bare --socket error; got {other:?}"), + } + } + #[test] + fn gpu_binary_discovery_prefers_override_then_sibling_then_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("pmacs"); + let sibling = temp.path().join("pmacs-gpu"); + let override_bin = temp.path().join("override-gpu"); + + let (selected, reported_sibling) = gpu_binary(&root, Some(override_bin.clone())); + assert_eq!(selected, override_bin); + assert_eq!(reported_sibling, sibling); + + std::fs::write(&sibling, b"gpu").expect("create sibling"); + let (selected, _) = gpu_binary(&root, None); + assert_eq!(selected, sibling); + + std::fs::remove_file(&sibling).expect("remove sibling"); + let (selected, reported_sibling) = gpu_binary(&root, None); + assert_eq!(selected, PathBuf::from("pmacs-gpu")); + assert_eq!(reported_sibling, sibling); + } } diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs new file mode 100644 index 0000000..57db913 --- /dev/null +++ b/tests/gpu_invocation_acceptance.rs @@ -0,0 +1,552 @@ +//! End-to-end acceptance for one-command GPU invocation and managed daemon lifecycle. + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +const TEST_GPU_OVERRIDE: &str = "PMACS_TEST_GPU_BIN"; + +fn secure_tempdir() -> TempDir { + let temp = tempfile::tempdir().expect("tempdir"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("chmod tempdir 0700"); + temp +} + +fn write_script(path: &Path, body: &str) { + fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).expect("write script"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod script"); +} + +#[cfg(not(feature = "crdt"))] +#[test] +fn non_crdt_root_rejects_gpu_before_discovery_or_spawn() { + let temp = secure_tempdir(); + let fake_gpu = temp.path().join("fake-gpu"); + let marker = temp.path().join("spawned"); + write_script(&fake_gpu, "touch \"$PMACS_TEST_MARKER\""); + + let output = Command::new(env!("CARGO_BIN_EXE_pmacs")) + .arg("--gpu") + .env(TEST_GPU_OVERRIDE, &fake_gpu) + .env("PMACS_TEST_MARKER", &marker) + .output() + .expect("run non-CRDT pmacs --gpu"); + assert!(!output.status.success()); + assert!( + !marker.exists(), + "GPU executable must not be discovered or spawned" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--features crdt"), + "unexpected stderr: {stderr}" + ); +} + +#[cfg(feature = "crdt")] +mod crdt { + use std::collections::HashMap; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::os::unix::process::CommandExt; + use std::path::PathBuf; + use std::process::Stdio; + use std::process::{Child, ChildStdin}; + use std::thread; + use std::time::{Duration, Instant}; + + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + use pmacs::protocol::{ + FrontendId, Hello, InstanceCapabilities, InstanceIdentity, PROTOCOL_VERSION, + }; + use pmacs::transport::{read_message, write_message}; + + use super::*; + + fn pmacs_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_pmacs")) + } + + fn gpu_binary() -> PathBuf { + pmacs_binary() + .parent() + .expect("test binary directory") + .join("pmacs-gpu") + } + + fn parse_report(report: &Path) -> HashMap { + fs::read_to_string(report) + .expect("read probe report") + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() + } + + fn wait_for_fact( + report: &Path, + key: &str, + expected: &str, + timeout: Duration, + ) -> HashMap { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if report.exists() { + let facts = parse_report(report); + if facts.get(key).is_some_and(|value| value == expected) { + return facts; + } + } + thread::sleep(Duration::from_millis(20)); + } + panic!( + "report {} did not reach {key}={expected}: {}", + report.display(), + fs::read_to_string(report).unwrap_or_default() + ); + } + + fn signal_pid(pid: u32, signal: Signal) { + let _ = kill(Pid::from_raw(pid.cast_signed()), signal); + } + + fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().expect("inspect child") { + return status; + } + assert!( + Instant::now() < deadline, + "child did not exit within {timeout:?}" + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn wait_for_daemon(socket: &Path, child: &mut Child) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(mut stream) = UnixStream::connect(socket) { + let _: Hello = read_message(&mut stream).expect("read daemon Hello"); + return; + } + if let Some(status) = child.try_wait().expect("inspect daemon") { + panic!("daemon exited before listening: {status}"); + } + thread::sleep(Duration::from_millis(20)); + } + panic!("daemon did not listen on {}", socket.display()); + } + + fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child { + let home = socket.parent().expect("socket parent"); + let mut command = Command::new(pmacs_binary()); + command + .args(["--daemon", "--socket"]) + .arg(socket) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command.spawn().expect("spawn daemon"); + wait_for_daemon(socket, &mut child); + child + } + + struct ManagedProbe { + child: Child, + stdin: Option, + report: PathBuf, + daemon_pid: Option, + } + + impl ManagedProbe { + fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self { + assert!( + gpu_binary().is_file(), + "build pmacs-gpu before this acceptance suite" + ); + let mut child = Command::new(gpu_binary()) + .args(["--headless-managed-probe"]) + .arg(socket) + .arg(report) + .arg(daemon_executable) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn managed probe"); + let stdin = child.stdin.take().expect("probe stdin"); + Self { + child, + stdin: Some(stdin), + report: report.to_owned(), + daemon_pid: None, + } + } + + fn wait_ready(&mut self) -> HashMap { + let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10)); + if facts + .get("spawned_daemon") + .is_some_and(|value| value == "true") + { + self.daemon_pid = facts.get("daemon_pid").and_then(|value| value.parse().ok()); + } + facts + } + + fn close(mut self) -> std::process::ExitStatus { + self.stdin.take(); + wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5)); + wait_for_exit(&mut self.child, Duration::from_secs(5)) + } + } + + impl Drop for ManagedProbe { + fn drop(&mut self) { + self.stdin.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(pid) = self.daemon_pid { + signal_pid(pid, Signal::SIGTERM); + } + } + } + + #[test] + fn root_broker_forwards_resolved_arguments_and_gpu_outcome() { + let temp = secure_tempdir(); + let fake_gpu = temp.path().join("fake-gpu"); + let record = temp.path().join("argv"); + let socket = temp.path().join("broker.sock"); + write_script( + &fake_gpu, + "printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"", + ); + + let success = Command::new(pmacs_binary()) + .args(["--gpu", "--socket"]) + .arg(&socket) + .env(TEST_GPU_OVERRIDE, &fake_gpu) + .env("PMACS_TEST_RECORD", &record) + .env("PMACS_TEST_EXIT", "0") + .output() + .expect("run root broker success"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + let argv = fs::read_to_string(&record).expect("read forwarded argv"); + let args = argv.lines().collect::>(); + assert_eq!(args[0], "--managed-attach"); + assert_eq!(Path::new(args[1]), socket); + assert_eq!(Path::new(args[2]), pmacs_binary()); + + let failure = Command::new(pmacs_binary()) + .arg("--gpu") + .env(TEST_GPU_OVERRIDE, &fake_gpu) + .env("PMACS_TEST_RECORD", &record) + .env("PMACS_TEST_EXIT", "23") + .output() + .expect("run root broker failure"); + assert_eq!(failure.status.code(), Some(23)); + + let missing = temp.path().join("missing-gpu"); + let spawn_failure = Command::new(pmacs_binary()) + .arg("--gpu") + .env(TEST_GPU_OVERRIDE, &missing) + .output() + .expect("run root broker spawn failure"); + assert!(!spawn_failure.status.success()); + assert!( + String::from_utf8_lossy(&spawn_failure.stderr).contains(&*missing.to_string_lossy()) + ); + } + + #[test] + fn managed_attach_reuses_a_capable_daemon_without_spawning() { + let temp = secure_tempdir(); + let socket = temp.path().join("existing.sock"); + let report = temp.path().join("report"); + let marker = temp.path().join("spawned"); + let fake_daemon = temp.path().join("fake-daemon"); + write_script(&fake_daemon, "touch \"$PMACS_TEST_MARKER\""); + let mut daemon = spawn_daemon(&socket, &[]); + + let mut probe = ManagedProbe::spawn(&socket, &report, &fake_daemon, temp.path()); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("false") + ); + assert!(!marker.exists()); + assert!(probe.close().success()); + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + } + + #[test] + fn missing_and_stale_sockets_start_real_daemons() { + for stale in [false, true] { + let temp = secure_tempdir(); + let socket = temp.path().join("managed.sock"); + if stale { + let listener = UnixListener::bind(&socket).expect("bind stale socket"); + drop(listener); + assert!(socket.exists()); + } + let report = temp.path().join("report"); + let mut probe = ManagedProbe::spawn(&socket, &report, &pmacs_binary(), temp.path()); + let facts = probe.wait_ready(); + assert_eq!( + facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + assert!(UnixStream::connect(&socket).is_ok()); + let pid = probe.daemon_pid.expect("spawned daemon pid"); + assert!(probe.close().success()); + signal_pid(pid, Signal::SIGTERM); + } + } + + #[test] + fn concurrent_managed_launches_converge_on_one_socket_owner() { + let temp = secure_tempdir(); + let socket = temp.path().join("race.sock"); + let mut first = ManagedProbe::spawn( + &socket, + &temp.path().join("first-report"), + &pmacs_binary(), + temp.path(), + ); + let mut second = ManagedProbe::spawn( + &socket, + &temp.path().join("second-report"), + &pmacs_binary(), + temp.path(), + ); + let first_facts = first.wait_ready(); + let second_facts = second.wait_ready(); + assert_eq!( + first_facts.get("buffer_snapshot").map(String::as_str), + Some("true") + ); + assert_eq!( + second_facts.get("buffer_snapshot").map(String::as_str), + Some("true") + ); + assert!(UnixStream::connect(&socket).is_ok()); + let pids = [first.daemon_pid, second.daemon_pid]; + assert!(first.close().success()); + assert!(second.close().success()); + for pid in pids.into_iter().flatten() { + signal_pid(pid, Signal::SIGTERM); + } + } + + #[test] + fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() { + let temp = secure_tempdir(); + let socket = temp.path().join("signal.sock"); + let report = temp.path().join("signal-report"); + let wrapper = temp.path().join("headless-gpu-wrapper"); + write_script( + &wrapper, + "exec \"$PMACS_REAL_GPU\" --headless-managed-probe \"$2\" \"$PMACS_REPORT\" \"$3\"", + ); + + let mut command = Command::new(pmacs_binary()); + command + .args(["--gpu", "--socket"]) + .arg(&socket) + .env(TEST_GPU_OVERRIDE, &wrapper) + .env("PMACS_REAL_GPU", gpu_binary()) + .env("PMACS_REPORT", &report) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.process_group(0); + let mut launcher = command.spawn().expect("spawn launcher process group"); + let facts = wait_for_fact(&report, "phase", "ready", Duration::from_secs(10)); + let daemon_pid = facts["daemon_pid"].parse::().expect("daemon pid"); + + kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT) + .expect("signal launcher group"); + let _ = wait_for_exit(&mut launcher, Duration::from_secs(5)); + let mut stream = UnixStream::connect(&socket).expect("daemon survived launcher Ctrl-C"); + let hello: Hello = read_message(&mut stream).expect("surviving daemon Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + signal_pid(daemon_pid, Signal::SIGTERM); + } + + #[test] + fn capability_and_protocol_mismatches_never_spawn_replacements() { + let temp = secure_tempdir(); + let marker = temp.path().join("spawned"); + let fake_daemon = temp.path().join("fake-daemon"); + write_script(&fake_daemon, "touch \"$PMACS_TEST_MARKER\""); + + let capability_socket = temp.path().join("capability.sock"); + let mut daemon = spawn_daemon( + &capability_socket, + &[ + ("PMACS_INSTANCE_CRDT_REPLICA", "0"), + ("PMACS_INSTANCE_SEMANTIC_RENDER", "0"), + ], + ); + let capability_report = temp.path().join("capability-report"); + let output = Command::new(gpu_binary()) + .args(["--headless-managed-probe"]) + .arg(&capability_socket) + .arg(&capability_report) + .arg(&fake_daemon) + .env("PMACS_TEST_MARKER", &marker) + .output() + .expect("run capability mismatch probe"); + assert!(!output.status.success()); + assert!( + fs::read_to_string(&capability_report) + .unwrap() + .contains("required capabilities") + ); + assert!(!marker.exists()); + assert!(daemon.try_wait().expect("inspect daemon").is_none()); + signal_pid(daemon.id(), Signal::SIGTERM); + let _ = daemon.wait(); + + let protocol_socket = temp.path().join("protocol.sock"); + let listener = UnixListener::bind(&protocol_socket).expect("bind protocol fixture"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept protocol fixture"); + let hello = Hello { + protocol_version: PROTOCOL_VERSION + 100, + assigned_frontend_id: FrontendId::LOCAL, + instance_identity: InstanceIdentity { + pmacs_version: "protocol-fixture".to_owned(), + build_hash: None, + instance_name: None, + uptime_secs: 0, + working_directory: "/tmp".to_owned(), + }, + instance_capabilities: InstanceCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }, + }; + write_message(&mut stream, &hello).expect("write mismatched Hello"); + }); + let protocol_report = temp.path().join("protocol-report"); + let output = Command::new(gpu_binary()) + .args(["--headless-managed-probe"]) + .arg(&protocol_socket) + .arg(&protocol_report) + .arg(&fake_daemon) + .env("PMACS_TEST_MARKER", &marker) + .output() + .expect("run protocol mismatch probe"); + server.join().expect("protocol fixture"); + assert!(!output.status.success()); + assert!( + fs::read_to_string(&protocol_report) + .unwrap() + .contains("protocol version") + ); + assert!(!marker.exists()); + } + + #[test] + fn bounded_startup_failure_reports_child_status() { + let temp = secure_tempdir(); + let socket = temp.path().join("never.sock"); + let report = temp.path().join("failure-report"); + let failing_daemon = temp.path().join("failing-daemon"); + write_script(&failing_daemon, "exit 17"); + let start = Instant::now(); + let output = Command::new(gpu_binary()) + .args(["--headless-managed-probe"]) + .arg(&socket) + .arg(&report) + .arg(&failing_daemon) + .output() + .expect("run bounded failure probe"); + assert!(!output.status.success()); + assert!(start.elapsed() >= Duration::from_secs(4)); + assert!(start.elapsed() < Duration::from_secs(8)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("exit status: 17"), + "unexpected stderr: {stderr}" + ); + } + + #[test] + fn managed_probe_observes_disconnect_and_reaps_daemon_child() { + let temp = secure_tempdir(); + let socket = temp.path().join("reap.sock"); + let report = temp.path().join("reap-report"); + let mut probe = ManagedProbe::spawn(&socket, &report, &pmacs_binary(), temp.path()); + probe.wait_ready(); + let daemon_pid = probe.daemon_pid.expect("daemon pid"); + signal_pid(daemon_pid, Signal::SIGTERM); + let facts = wait_for_fact(&report, "daemon_reaped", "true", Duration::from_secs(5)); + assert!(!facts["disconnect"].is_empty()); + assert!(probe.close().success()); + let final_facts = parse_report(&report); + assert_eq!( + final_facts.get("phase").map(String::as_str), + Some("complete") + ); + assert_eq!( + final_facts.get("daemon_reaped").map(String::as_str), + Some("true") + ); + } + + #[test] + fn gpu_cli_help_version_and_invalid_argv_are_headless_and_strict() { + let help = Command::new(gpu_binary()) + .arg("--help") + .output() + .expect("GPU help"); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("--attach ")); + + let version = Command::new(gpu_binary()) + .arg("--version") + .output() + .expect("GPU version"); + assert!(version.status.success()); + assert!(String::from_utf8_lossy(&version.stdout).contains("protocol v")); + + for argv in [ + vec![], + vec!["--attach"], + vec!["--attach", "/tmp/x.sock", "ignored"], + vec!["unexpected"], + ] { + let output = Command::new(gpu_binary()) + .args(&argv) + .output() + .expect("invalid GPU CLI"); + assert_eq!(output.status.code(), Some(2), "accepted argv {argv:?}"); + } + } +}