diff --git a/Cargo.lock b/Cargo.lock index 489a87d..46b8cd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2583,6 +2583,7 @@ dependencies = [ "pmacs-protocol", "pollster", "sys-locale", + "tempfile", "unicode-width", "wgpu", "winit", 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/active-work.md b/docs/active-work.md index ef0ac15..a10dd16 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-22.** This file records volatile work that has not +**Snapshot: 2026-07-23.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -14,8 +14,8 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `cac4961` (Vterm Stage 3 #135 merged after tab-width - parity #137; protocol v19). + `githubsucks/main` @ `96d0bae` (documentation refresh #140 atop Vterm + Stage 3 #135 and tab-width parity #137; protocol v19). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -49,9 +49,53 @@ git worktree list git status --short --branch ``` -The first command must expose `cac4961` or a newer intentional main. +The first command must expose `96d0bae` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Active lane: one-command GPU invocation + +- Portable branch: `githubsucks/gpu-invocation` +- Pull request: **#141 OPEN — do not merge without explicit user approval.** +- Base: `githubsucks/main` @ `96d0bae`; protocol remains v19. +- Implementation checkpoints: `154cb9f` (second implementation-review fixes) + atop `69825d0` (exception-safe immediate reaper ownership), `82355ca` + (first implementation-review fixes), and `6fd5834`; approved framing began + at `821835b` and is now Revision 6. +- State: both implementation reviews are resolved, all 18 acceptance criteria + complete, and the PR awaits user review. Public path is additive + `pmacs --gpu [--socket NAME|PATH]`; bare `pmacs [FILE]` remains TUI and + `pmacs --gpu FILE` remains rejected. +- Review fixes: pre-state GPU events are buffered until winit state exists; + daemon stdio is detached; spawned-child ownership is handed off without a + leak window; direct CLI guidance, help/path operands, and sibling-file + discovery are strict; transient retry, timeout reporting, hermetic socket + paths, deterministic concurrent-loser reaping, pre-I/O non-CRDT gating, and + post-SIGINT use of a pre-attached frontend have behavioral coverage. Probe + disconnect polling remains bounded and cleanup skips already-reaped PIDs. +- Verification: strict workspace and CRDT acceptance Clippy; 1,768 default + + 1,944 CRDT library tests; root CLI 33; GPU 149 required; GPU invocation + acceptance 1 default + 9 CRDT; Vterm Stage 3 7; M4 121 with 3 ignored and + the requested `basedpyright` skip; full workspace 2,961 across 85 suites + with 19 ignored and 1 requested skip; formatting and diff check clean. + Unified release build passed. Two real Wayland/Vulkan launches attached at + protocol v19; the second reused the daemon retained after the first GPU + process exited. +- Distribution remains source-checkout/workspace oriented: `pmacs-gpu` is + still unpublished. No install/service packaging claim was added. + +Recovery: + +```sh +git fetch githubsucks --prune +git worktree add --track \ + -b gpu-invocation \ + ../pmacs-gpu-invocation \ + githubsucks/gpu-invocation +cd ../pmacs-gpu-invocation +cargo build -p pmacs-gpu +cargo test --features crdt --test gpu_invocation_acceptance +``` + ## Parked lane: kill-ring browser + persistence - Portable branch: `githubsucks/kill-ring-browser` diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 56a32b3..393805c 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,10 +1,11 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-22, after Vterm Stage 3 (#135, protocol v19 and -native GPU terminal) landed on `main`, following 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, with the approved one-command GPU invocation +broker implemented on open PR #141, after 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).** 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` @@ -16,11 +17,23 @@ reads it the way you just did. For volatile branches, checkpoints, verification, and recovery commands, read `docs/active-work.md` immediately after this file. -## 1. Where the project stands (2026-07-22) +## 1. Where the project stands (2026-07-23) -- `main` @ `cac4961` (Vterm Stage 3 #135 atop tab-width parity #137), - protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = - `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events). +- `main` @ `96d0bae` (documentation refresh #140 atop Vterm Stage 3 #135 + and tab-width parity #137), protocol **v19** (`SUPPORTED=[6..=19]`; v16 = + `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal + frames/events). +- **One-command GPU invocation is implemented on OPEN PR #141, not yet on + `main`** (`gpu-invocation`, second implementation-review checkpoint + `154cb9f`, framing Revision 6). The additive public path is + `pmacs --gpu [--socket NAME|PATH]`; bare `pmacs [FILE]` remains the TUI. + Root owns the CRDT gate, socket resolution, sibling-regular-file GPU + discovery/PATH fallback, and GPU outcome. The separate `pmacs-gpu` binary + owns connect-or-start, a five-second / 50-ms retry window, pre-winit event + buffering, daemon process-group/stdin/stdout/stderr isolation, and named + child reaping with explicit ownership handoff. Direct + `pmacs-gpu --attach RAW_PATH` remains strict, is documented as advanced, + and never auto-starts. No protocol change. - **Config registry LANDED — #127** (`docs/config-registry-framing.md` rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the typed, introspectable options registry the backlog ranked first, and diff --git a/docs/gpu-invocation-framing.md b/docs/gpu-invocation-framing.md new file mode 100644 index 0000000..01c8860 --- /dev/null +++ b/docs/gpu-invocation-framing.md @@ -0,0 +1,690 @@ +# GPU invocation — one-command broker framing + +**Revision 6 — second implementation review complete 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 +daemon alive in one terminal, reconstruct its resolved Unix-socket path, and +pass that raw path to a second binary in another terminal. This framing makes +the normal local GPU path one explicit command: + +```sh +pmacs --gpu +pmacs --gpu --socket research +``` + +This is an additive first stage. It does **not** yet change bare `pmacs` from +TUI to GUI, and it does not pretend that `pmacs --gpu FILE` works before the +daemon has a real per-frontend initial-file contract. It preserves the +separate `pmacs-gpu` binary and its independent dependency graph. + +Revision 2 closes the first review round: the spawned daemon is isolated from +the launcher's foreground process group; the required Vterm headless probe is +retained; retry/error/reaping behavior is complete; a real display-less +managed-attach seam is named; bare `--socket` stops being silently ignored; +and the non-CRDT gate is explicitly justified as default-socket protection. + +Revision 3 makes the managed probe deterministic for signal/reaper tests, +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. + +Revision 5 closes the first implementation review. Managed attach now buffers +messages that arrive before winit creates application state, spawned-daemon +ownership remains local until the named reaper accepts it, and the daemon +inherits no launcher stdio. Direct GPU help points normal users to the root +broker and labels raw socket attach as advanced. The retry, timeout, socket +type, and concurrent-loser contracts now have deterministic behavioral tests. + +Revision 6 closes the remaining non-blocking review findings. The managed +probe throttles after its event channel disconnects; option-like path operands +are rejected; cleanup never signals an already-reaped daemon PID; and the +acceptance suite now proves both pre-I/O non-CRDT gating and post-SIGINT use of +a frontend attached before the launcher exits. + +## Ground truth + +### Current user path + +The daemon is a mode of the root `pmacs` binary; there is no +`pmacs-daemon` executable (`Cargo.toml`, `src/main.rs`). `pmacs-gpu` is a +separate unpublished workspace package and binary +(`pmacs-gpu/Cargo.toml`). The current source-checkout path is: + +```sh +cargo build --release --workspace --features pmacs/crdt + +# terminal 1 +target/release/pmacs --daemon + +# terminal 2 +runtime="${XDG_RUNTIME_DIR:-/tmp/pmacs-$(id -u)}" +target/release/pmacs-gpu --attach "$runtime/pmacs/default.sock" +``` + +The unified workspace build above succeeds. The README currently documents +two separate build commands instead. Its `cargo run --release -- ` +example is not runnable as written: the root package has no `default-run`, +and Cargo reports that it cannot choose among `pmacs`, `pmacs-audit`, +`pmacs_fake_lsp`, and `pmacs_fake_mcp`. + +A live smoke on this base exposed the cost of independent builds: the first +`target/release/pmacs-gpu` was protocol v15 while the daemon was v19. The GPU +opened but rejected the attach. Rebuilding `pmacs-gpu` produced a successful +v19 attach. The handshake caught the mismatch correctly; the invocation path +made it easy to create. + +### Existing CLI and process boundaries + +- `pmacs [FILE]` runs the in-process TUI. `-nw` / `--no-window` is already + parsed as an explicit TUI choice, though it is currently equivalent to the + default (`src/main.rs:94-131,288-318`). +- The source comment at `src/main.rs:28-49` reserves the future shape: + explicit `-nw` wins, a future `--gui` can select GUI, then an environment / + display-based default may choose GUI. None of that GUI selection is + implemented today. +- `pmacs --daemon [--socket NAME|PATH]` runs in the foreground. Bare names + resolve to `/pmacs/NAME.sock`; omission means `default.sock`; a + value containing `/` is used as a path (`src/socket_path.rs`). +- `` is nonempty `$XDG_RUNTIME_DIR`, otherwise + `/tmp/pmacs-`. The daemon creates a private parent, locks a sibling + lockfile, removes a stale socket only after acquiring the lock, and binds + the socket owner-only (`src/socket_path.rs`, `src/lockfile.rs`, + `src/daemon.rs:437-530`). +- `pmacs-gpu --attach ` takes a raw pathname. It has no default/name + resolver and no file positional (`pmacs-gpu/src/main.rs:548-565,802-848`). +- `pmacs-gpu` has three current modes: bare hello-world, direct `--attach`, + and the test/acceptance seam `--headless-probe `. The + required Vterm Stage 3 acceptance invokes that third mode as a real + subprocess (`tests/vterm_stage3_acceptance.rs:679-691`). +- Bare `pmacs-gpu` still opens the inert Session-2 `hello, pmacs` window. It + is scaffolding, not an editor session. +- The GPU parser consumes the attach/probe operands but does not reject later + argv, so trailing values are silently ignored. +- The GPU package intentionally depends on `pmacs-protocol`, not the root + editor crate. The distribution decision in + `docs/pmacs-gpu-design.md:195-202` keeps wgpu, winit, font, and + window-system dependencies out of TUI-only installs. That boundary still + holds. + +### Capability and attach constraints + +A usable GPU daemon must advertise all of `multi_frontend`, `crdt_replica`, +and `semantic_render`. The root `crdt` feature enables those capabilities; +`pmacs-gpu` itself has no Cargo features. The GPU validates the daemon's +`Hello` before sending `AttachRequest` and produces an actionable capability +mismatch instead of waiting forever (`pmacs-gpu/src/attach.rs`). + +`pmacs-gpu` currently attempts one `UnixStream::connect` from +`ApplicationHandler::resumed`. A connect/handshake failure stays visible in +the window but is not retried. Later disconnect also requires a manual +relaunch. Automatic reconnect is an existing named deferral, separate from +startup (`docs/gpu-attach-robustness-framing.md:175-186`). + +The root already has daemon auto-start precedent in `src/daemon_attach.rs`: +try an existing socket, otherwise spawn `current_exe --daemon --socket PATH`, +wait up to five seconds, then enter its byte bridge. That helper must retain +the successful connection because a disposable connect probe makes the +daemon send `Hello` into a stream the probe drops, producing a broken pipe. +The GPU cannot directly reuse the helper: it lives in the root crate and +returns the stream to the stdio bridge, while `pmacs-gpu` owns its own direct +Unix transport. + +The SSH-side daemon auto-start precedent does not isolate the daemon into a +new process group: `daemon_attach.rs` relies on the SSH spawn having no +controlling terminal (`src/daemon_attach.rs:53-60`). A local `pmacs --gpu` +launcher does have one. Without an explicit process-group split, root, GPU, +and the auto-started daemon inherit the foreground group; terminal Ctrl-C +reaches all three, and the daemon deliberately treats SIGINT as graceful +shutdown (`src/daemon.rs:617-633`). SIGHUP is already ignored, so foreground +SIGINT is the specific lifecycle gap. + +### Initial-file constraint + +Every daemon attachment currently receives a fresh scratch view in +`handle_session_established` (`src/daemon.rs:1533-1552`). The code explicitly +names cloning or taking an initial-buffer argument as future work. Opening a +file in the daemon before attach would not put that file in the new GPU +frontend's view. There is no frontend `OpenPath` event and no initial target +in `AttachRequest` (`pmacs-protocol/src/message.rs:2023-2040`). + +Therefore a launcher that accepts `FILE` without new daemon/session work +would either ignore it, drive the minibuffer by synthetic keys, or open it in +the wrong view. All three are rejected. + +### Distribution gaps + +There is no editor installation recipe, desktop entry, user service, +Make/Just target, Cargo alias, or launcher wrapper. The only repository +script is the test-development helper `scripts/bite`. Cargo does not install +repository shell scripts, and `pmacs-gpu` has `publish = false`. + +## Decisions + +### Q#GI1 — Add the explicit root command `pmacs --gpu [--socket NAME|PATH]` + +The first-stage public surface is: + +```text +pmacs --gpu [--socket NAME|PATH] +``` + +It is additive. Bare `pmacs` and `pmacs FILE` continue to run the local TUI; +`pmacs -nw` remains the explicit TUI spelling. `--gpu` is mutually exclusive +with `-nw` / `--no-window`, `--daemon`, `--attach`, and `--daemon-attach`. +It rejects every positional argument with: + +```text +pmacs: --gpu does not yet accept FILE; open it from the GPU with C-x C-f +``` + +The parser also closes the adjacent existing hole: `--socket` without one of +`--daemon`, `--attach`, `--daemon-attach`, or `--gpu` exits 2 instead of being +silently discarded by `Mode::Local`. A flag is used rather than a `pmacs gpu` +subcommand because `gpu` is a valid existing positional filename. No +`PMACS_FRONTEND` environment selection and no display auto-detection land in +this stage. + +Rejected alternatives: + +- **Bare `pmacs` becomes GUI immediately** — this mixes launcher correctness, + daemon lifecycle, initial-file semantics, and a default-behavior change in + one cut. `-nw` reserves that eventual migration; it does not make an + incomplete migration safe. +- **A shell wrapper** — not installed by Cargo, duplicates readiness and path + policy, and makes version-skewed binaries easier to combine. +- **Link GPU into the root binary** — violates the deliberate independent + dependency graphs and adds wgpu/window dependencies to TUI-only builds. + +### Q#GI2 — Root owns policy and paths; `pmacs-gpu` owns the successful connection + +The root launcher owns: + +1. CLI validation; +2. the canonical `resolve_socket_path` call; +3. the CRDT-build gate; +4. discovery of the separate `pmacs-gpu` executable; +5. waiting for that executable and reflecting its outcome. + +The GPU child owns the actual connection that becomes the session. For the +managed launch, root passes two hidden/internal arguments: the resolved raw +socket path and `current_exe()` as the daemon executable. The child first +tries the socket itself; the stream that completes `Hello` / `AttachRequest` +is retained as its real `AttachClient`. There is no disposable readiness +connection and therefore no deliberate broken-pipe noise. + +The hidden argument shape is not a second user-facing launcher. Direct users +keep `pmacs-gpu --attach PATH`; the documented managed surface is +`pmacs --gpu`. + +### Q#GI3 — Managed GPU attach may start the supplied daemon, then retries boundedly + +Managed attach follows this state machine before creating the window: + +1. Try the resolved Unix socket once. +2. If connection reaches `Hello`, perform the normal version and capability + validation. Either failure is final and surfaced; never start a + replacement daemon over a live incompatible instance. +3. `NotFound` authorizes daemon startup. `ConnectionRefused` authorizes it + only when `metadata(socket)` says the existing entry is a Unix socket, or + the entry disappeared in the race between connect and metadata. An + existing non-socket file is a final error and is never handed to + `pmacs --daemon`, whose established stale-path transaction would otherwise + unlink it after acquiring the sibling lock. +4. Every other initial connect error (`PermissionDenied`, invalid path shape, + resource exhaustion, `Interrupted`, `WouldBlock`, and other errno classes) + is final, surfaced with the socket path, and invokes no daemon spawner. + During the post-spawn retry window, `NotFound`, authorized + `ConnectionRefused`, `Interrupted`, and `WouldBlock` / `EAGAIN` continue to + the deadline; all other errors remain final. This tolerates a signal- + interrupted `connect(2)` and the just-bound daemon's temporarily full + AF_UNIX accept backlog without broadening what may trigger daemon startup. +5. Spawn the supplied executable as + `pmacs --daemon --socket `, with the process-group isolation + in Q#GI13. +6. Retry the real GPU connect every 50 ms for up to five seconds, matching + `AUTO_START_POLL_INTERVAL` / `AUTO_START_TIMEOUT` in + `daemon_attach.rs:149-160`. The first successful stream becomes the real + attach; no probe is dropped. +7. If the spawned child exits during startup, reap it, retain its status, and + keep retrying until the deadline: another concurrent launcher may have won + the socket lock and be about to listen. +8. At the deadline, exit nonzero with the socket path, timeout, and spawned + child status when available. + +Connection/retry occurs before `run_app`, so the winit event thread never +freezes for five seconds behind startup polling. On success, events sent to +the already-created `EventLoopProxy` may queue until `run_app` starts; the +window then assembles with the connected frontend id. Existing direct +`--attach` behavior may retain its in-window failure banner. + +During startup the managed connector owns `Option` so every early exit +is reaped. After a successful attach, a still-running spawned daemon moves to +a named reaper thread whose only job is blocking `Child::wait`; this prevents +a daemon that later crashes or quits from remaining a zombie throughout a +long GPU session. Closing the GPU process still leaves a live daemon orphaned +and long-lived per Q#GI5. + +Do not extract a generic launcher crate for two constants, a short loop, and +one child reaper. + +### Q#GI4 — Auto-start is race-safe through the existing daemon lock + +Two simultaneous `pmacs --gpu` commands may both observe a missing socket and +spawn a daemon. The sibling lockfile is the arbiter: one daemon binds, the +other exits. Both GPU children continue their bounded connect loop and attach +to the winner. The loser child is reaped; its lock error is not treated as a +launch failure if the socket becomes usable. + +A stale socket follows the existing daemon transaction: acquire the lock, +unlink the stale socket, bind the replacement. The launcher may read +filesystem metadata solely to distinguish a socket entry from non-socket data +(Q#GI3); it neither deletes entries nor treats path existence as proof of +liveness. The daemon lock and successful protocol connection remain the +arbiters. + +### Q#GI5 — An auto-started daemon remains long-lived + +Closing the GPU window detaches that frontend but does not kill a daemon the +launcher started. This matches pmacs's long-lived-instance architecture and +the existing `--daemon-attach` auto-start behavior. A later `pmacs --gpu` +reuses the same default/named daemon and retains buffers, processes, and +language services. + +The startup child has null stdin/stdout/stderr, matching the existing +background auto-start precedent. Startup failure is reported through child +status + timeout; detailed daemon diagnostics remain available by running +`pmacs --daemon --socket ...` directly. Q#GI13, not the SSH precedent, +defines the local terminal's signal isolation. The launcher does not +daemonize, install a service, or invent idle shutdown in this stage. + +### Q#GI6 — Require a CRDT-capable root build before launching + +In a root binary compiled without `feature = "crdt"`, `pmacs --gpu` exits +before executable discovery or any socket connection with an actionable +message: + +```text +pmacs: --gpu requires pmacs built with --features crdt +``` + +This prevents default-socket poisoning: without the gate, a non-CRDT root +could auto-start an incapable daemon that successfully owns `default.sock`; +the GPU would then reject its capabilities, and Q#GI3's correct +never-replace-a-live-instance rule would make later managed launches keep +failing until the user manually stopped that daemon. + +The deliberate trade-off is that a non-CRDT root also refuses the managed +fast path when a separate capable daemon is already listening. The broker's +root executable is its daemon-start authority and must be capable for +deterministic behavior. Advanced users may still attach the independent GPU +directly to a known capable daemon with `pmacs-gpu --attach PATH`; that path +retains its own Hello capability validation. + +### Q#GI7 — Prefer the sibling GPU binary, then fall back to `PATH` + +Discovery order: + +1. test-only `PMACS_TEST_GPU_BIN` override; +2. `current_exe().parent()/pmacs-gpu` when that path exists; +3. `pmacs-gpu` through `PATH`. + +Sibling-first keeps ordinary source builds and side-by-side installations on +the same release/protocol build. The handshake remains authoritative: path +co-location is not proof of protocol compatibility. Failure names the sibling +candidate and PATH fallback rather than reporting a generic spawn error. + +There is no production `PMACS_GPU_BIN` configuration knob in v1. A permanent +override would become distribution policy; tests need substitution, users +need an installation that places the two shipped binaries coherently. + +### Q#GI8 — Root waits for the GPU child and reflects failure + +`pmacs --gpu` remains the terminal-visible parent while the window is open. +A successful GPU exit returns success; a nonzero exit returns failure and +prints which GPU executable failed. Spawn failure is immediate and +actionable. No shell, `nohup`, or detached launcher process sits between the +user and the frontend. + +The daemon is independent after startup (Q#GI5); root waits only for the GPU +child. + +### Q#GI9 — Retire bare `pmacs-gpu` hello-world and make argv strict + +The hello-world window was Session-2 dependency scaffolding and no longer +serves a user workflow. Bare `pmacs-gpu` exits with usage that points to +`pmacs --gpu` for managed startup and `pmacs-gpu --attach PATH` for direct +debugging. + +The existing test-only +`pmacs-gpu --headless-probe ` mode is retained byte-for-byte +as the Vterm Stage 3 real daemon + PTY + wgpu seam. Its parser becomes strict +about exactly those two operands; `tests/vterm_stage3_acceptance.rs` keeps its +current subprocess command and semantics. Q#GI14 adds a separate managed +headless probe rather than overloading this vterm contract. + +The GPU parser rejects: + +- trailing argv after direct-attach or probe operands; +- a missing attach/probe operand; +- user attempts to invoke the hidden managed modes without every broker + operand; +- unknown flags. + +Add `pmacs-gpu --version` so reports can name both package version and +protocol version without opening a window. `--help` labels direct attach as +an advanced/manual path and does not advertise internal broker/probe +arguments. + +### Q#GI10 — No protocol change and no initial file in this stage + +The broker changes process orchestration only. It sends the existing +`AttachRequest`, negotiates protocol v19, and uses the existing semantic/CRDT +session. `SUPPORTED` and every wire discriminant remain unchanged. + +`pmacs --gpu FILE` is rejected rather than accepted partially. The future +file feature must target the authenticated source frontend's view, preserve +non-UTF-8 local paths, define relative-path resolution, surface open failure, +and avoid a scratch-buffer flash. It receives its own framing and protocol +review. + +### Q#GI11 — Fix checkout build/run instructions with the broker + +Set root package `default-run = "pmacs"`, making the existing README +`cargo run --release -- ...` family unambiguous. Document one coherent build: + +```sh +cargo build --release --workspace --features pmacs/crdt +``` + +Then document: + +```sh +target/release/pmacs --gpu +# explicit TUI remains: +target/release/pmacs -nw [FILE] +``` + +The build line deliberately compiles both binaries together. Keep the +advanced two-process commands in a troubleshooting/manual-attach subsection, +including the canonical XDG fallback rather than Linux-only `$UID` prose. +Do not claim `cargo install` support until installation is exercised and the +unpublished GPU package has a deliberate distribution story. + +### Q#GI12 — Scope stays on invocation, not reconnect or packaging + +The managed startup retry ends when the first attach succeeds. A later daemon +disconnect retains today's `(daemon disconnected)` state and manual relaunch. +Desktop files, system services, release bundles, package managers, and remote +GPU transports are not smuggled into this feature. + +### Q#GI13 — Put the auto-started daemon in its own process group + +Before spawning the managed daemon, call the safe Unix +`std::os::unix::process::CommandExt::process_group(0)`. The daemon becomes the +leader of a new process group while the root broker and GPU child remain in +the terminal's foreground group. Terminal Ctrl-C therefore terminates the +foreground launcher/frontend without delivering SIGINT to the daemon or any +other attached frontend. + +This is process-group isolation, not a new session or full daemonization. +Direct `pmacs --daemon` remains foreground and keeps its established +SIGINT/SIGTERM graceful-shutdown contract. Terminal close remains safe through +the daemon's existing SIGHUP no-op. No `unsafe`, `setsid` helper process, or +platform-specific FFI enters the codebase. + +### Q#GI14 — Add a real display-less managed-attach acceptance seam + +Extract the pre-`run_app` production path as +`connect_managed_with_sink(socket, daemon_exe, sink)`. The normal managed +window calls it with the existing `EventLoopProxy` sink. A new hidden strict +subprocess mode: + +```text +pmacs-gpu --headless-managed-probe +``` + +calls the same function with a channel sink and keeps processing that channel +after the real `BufferSnapshot`. At the snapshot checkpoint it atomically +writes a complete report with `phase=ready` and initial named facts (protocol, +whether this invocation spawned, child status so far), then holds the live +session until stdin reaches EOF. A dedicated stdin-reader thread reports EOF +to the probe loop; the loop itself remains free to receive disconnects and +reaper observations. Each such lifecycle observation atomically refreshes the +`phase=ready` report, so a harness can wait for a named fact such as +`daemon_reaped=true` without sleeping. On EOF the probe atomically replaces +the report with `phase=complete` plus final child/reaping facts and exits +without winit or wgpu. + +Tests that need a hold spawn the probe with piped stdin, wait for +`phase=ready`, perform the signal/daemon action, wait for any required named +fact, then close stdin when they want normal probe completion. An ordinary +invocation with null stdin advances immediately from ready to complete. There +is no timing-based linger duration. + +This is the acceptance seam for Q#GI3–GI6 and Q#GI13: real binary, real Unix +socket, real Hello/capability negotiation, and real daemon subprocess. A +decoded-message fixture or a second test-only connect implementation is not +accepted evidence. + +The existing `--headless-probe ` remains separate and still +drives real offscreen wgpu for Vterm Stage 3 (Q#GI9). Narrow injected-spawner +unit tests pin rare errno/timeout branches, including post-spawn +`Interrupted` / `WouldBlock` followed by success, but they do not replace the +managed subprocess acceptance. + +## Categorical bets + +1. **An explicit broker is enough to validate lifecycle policy before changing + the default frontend.** Users gain a one-command GPU path without forcing + GUI startup into scripts, `$EDITOR`, SSH sessions, or terminals that rely + on today's bare `pmacs` TUI. +2. **Sibling-first discovery covers source and coherent installed layouts.** + A fallback to PATH handles split prefixes; the protocol handshake catches + stale or foreign binaries. +3. **The existing lock is the correct concurrency arbiter.** Launcher-side + PID files, path-existence checks, or socket deletion would duplicate and + weaken the daemon's established ownership transaction. +4. **The successful connect must be the session connect.** Disposable probes + are observably wrong because the daemon speaks first; retrying the actual + GPU connect avoids false BrokenPipe logs and frontend-id churn. +5. **A five-second pre-window startup bound is acceptable.** Existing remote + daemon auto-start uses the same bound. A missing/broken daemon fails before + creating a misleading inert window. +6. **Persistent auto-start matches user expectation.** The instance owns + buffers and services across frontend lifetime; killing it on window close + would turn the daemon split into implementation overhead with no persistence + benefit. +7. **Foreground job control must not own the daemon.** A local launcher's + terminal process group is not the SSH no-controlling-terminal precedent; + a safe process-group split preserves the daemon across Ctrl-C without full + daemonization. +8. **Rejecting FILE is better than synthetic input.** Driving `C-x C-f` or the + minibuffer from a launcher is timing-dependent, configuration-dependent, + and cannot provide an atomic initial view. +9. **Non-socket paths are data, not stale sockets.** Managed auto-start never + feeds a regular file to the daemon's existing unlink-and-bind transaction. +10. **No new shared crate is warranted.** Root already resolves paths and + hands the result to the GPU; the GPU adds only bounded connect-or-spawn + behavior around its existing transport. + +## Deferred (named) + +- **GUI as the automatic default.** Complete the reserved + `FrontendChoice::Auto` plan after the broker is proven: display detection, + `PMACS_FRONTEND`, and `pmacs -nw` precedence. This is a user-default change, + not part of additive startup. +- **Initial file(s) for an attached frontend.** A real per-session target that + opens/switches in the authenticated source's view, reports errors, handles + path bytes and relative cwd, and avoids scratch flash. This is prerequisite + to honest `pmacs --gpu FILE` and eventual bare `pmacs FILE` GUI startup. +- **Automatic reconnect/resync.** Startup retry does not reconcile an + optimistic replica after a live connection drops; retain the attach- + robustness deferral. +- **Direct `pmacs-gpu --socket NAME`.** Managed users go through root, which + already owns canonical resolution. Revisit only if direct frontend use is a + supported standalone workflow. +- **Remote GPU attach.** The TUI owns SSH/daemon-attach transport today; + generic GPU stream transports need separate latency, clipboard, and + frontend-resource semantics. +- **Daemon service management.** systemd/launchd units, socket activation, + idle shutdown, logs, restart policy, and a `pmacs --stop-daemon` command. +- **Distribution/install bundles.** Cargo install, release archives, desktop + entries, icons, and ensuring both binaries land together. +- **Multiple files and client/server open commands.** Follow the initial-file + contract rather than widening this stage's rejected positional grammar. +- **GPU executable override for users.** Keep only the test override until a + real packaging use case establishes precedence and diagnostics. + +## Acceptance + +CLI and launcher cases run against the real built binaries where process +behavior is the contract. Pure parsing/discovery helpers receive unit tests; +no source-text assertions substitute for subprocess behavior. Managed +connection cases use Q#GI14's real display-less binary seam; the existing +Vterm probe continues to cover offscreen wgpu. + +1. **Root CLI grammar:** `pmacs --gpu` and `pmacs --gpu --socket research` + select managed GPU mode. Combinations with `-nw`, `--daemon`, `--attach`, + `--daemon-attach`, or a positional file exit 2 with the conflicting + argument named. Bare `pmacs --socket research` (with or without a local + file / `-nw`) exits 2 and says which owning mode is required. +2. **Non-CRDT build fails before socket or spawn:** a default-feature + `pmacs --gpu` names `--features crdt`, invokes no GPU executable, and leaves + the default socket absent under a private runtime directory. Repeating the + command against an already-listening Unix socket neither invokes the GPU + nor disturbs that socket, proving the gate precedes socket I/O. +3. **Sibling discovery wins:** with executable fixtures at the current-exe + sibling and on PATH, the sibling regular file receives the managed + arguments. A directory at the sibling pathname is ignored in favor of + PATH. With neither executable available, the error names both lookup + attempts. +4. **Existing daemon fast path:** start a real CRDT daemon on a private socket, + run `--headless-managed-probe`, and assert a v19 session establishes and a + real `BufferSnapshot` arrives without invoking the supplied daemon spawner. +5. **Missing daemon auto-start:** from no socket/lock, the managed probe starts + the supplied real CRDT daemon, completes the real Hello/capability + handshake, receives the first `BufferSnapshot`, and leaves the daemon + connectable after the probe exits. +6. **Ctrl-C does not kill the daemon:** spawn the managed probe/broker in its + own process group, wait for the probe's `phase=ready`, and complete a second + real frontend's handshake and initial snapshot/grid sync before simulating + terminal Ctrl-C with `kill(-pgid, SIGINT)`. After the launcher/frontend + exits, resize the pre-existing second frontend and require its full-grid + response; the separately grouped daemon and existing session remain usable. + This does not require a controlling terminal or a foreground-process-group + claim in CI. Direct foreground `pmacs --daemon` still exits cleanly on + SIGINT. +7. **Concurrent launchers converge:** hold two daemon wrappers behind a shared + barrier so both managed probes authorize and spawn before either daemon + binds the absent named socket. Exactly one daemon owns the lock; both + clients establish sessions; exactly one probe reports its losing daemon + child reaped rather than leaving a zombie or aborting its frontend. +8. **Stale socket recovery stays daemon-owned:** leave a stale Unix socket + with no lock owner, launch managed GPU, and assert the daemon replaces it + and the frontend attaches. The launcher itself performs no unlink. +9. **Other connect errors fail closed; retry transients survive:** a + permission-denied initial socket path invokes no daemon spawner and reports + the path/error immediately. A regular file at the socket path survives + unchanged, invokes no daemon, and reports that managed startup refuses to + replace a non-socket entry. A unit connector injects post-spawn + `Interrupted`, `WouldBlock`, then a successful real `UnixStream::connect` + inside a private temporary directory; it stays within the deadline, + establishes the protocol session, and hands the spawned child to the + reaper. +10. **Live capability mismatch is not replaced:** against a non-CRDT daemon, + managed launch reports the existing capability mismatch, invokes no + second daemon, and leaves the live daemon untouched. +11. **Live protocol mismatch is not replaced:** a real/fake-Hello listener + advertising an unsupported protocol produces the version-mismatch error, + invokes no daemon spawner, and leaves the listener/path untouched. +12. **Bounded startup failure:** substitute a daemon executable that exits + nonzero without binding. Managed GPU exits after five seconds (50 ms + polls) and reports socket + child status. Unit tests with a one-millisecond + deadline assert the configured duration appears in `StartupTimeout`. + A concurrent-winner fixture proves an early losing-child exit does not + abort while another process binds before the deadline. +13. **Post-attach daemon exit is reaped:** let the spawned daemon establish a + managed session, wait for the probe's `phase=ready`, then terminate the + daemon while keeping the probe's stdin open. Poll the atomically replaced + ready report until it records both disconnect and named reaper completion, + close stdin, and assert the `phase=complete` report retains the wait + outcome. The daemon never remains a zombie until frontend exit. +14. **Root reflects the GPU outcome:** fake GPU success makes `pmacs --gpu` + succeed; fake nonzero and spawn failure make it fail with the executable + named. +15. **GPU argv is strict without breaking probes:** bare invocation points to + `pmacs --gpu`; `--help` leads with normal root-broker usage and labels + direct `--attach PATH` as advanced. Existing `--headless-probe SOCKET + REPORT` and hidden `--headless-managed-probe SOCKET REPORT DAEMON_EXE` + accept exactly their operands. Missing/trailing/unknown/incomplete args + exit 2; trailing help/version operands say those flags accept no operands; + option-like path operands are rejected and require an explicit `./` + prefix when they name a real relative path. `--version` prints package and + protocol versions without initializing winit/wgpu. +16. **Existing direct and Vterm paths remain intact:** rebuilt + `pmacs-gpu --attach RAW_PATH` still renders an existing CRDT daemon, and + `tests/vterm_stage3_acceptance.rs` still invokes its unchanged + `--headless-probe` command and passes the real daemon + PTY + wgpu + criterion. +17. **One-command visible smoke:** on a Vulkan/display-capable machine, build + the workspace once, run only `target/release/pmacs --gpu`, observe the GPU + scratch buffer attach at protocol v19, close the window, then invoke the + same command again and observe reuse of the still-running daemon. +18. **Documentation commands are executable:** the README's unified release + 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; + decoded messages arriving first are buffered and drained in order once + application state exists. Bare invocation is an exit-2 usage error pointing + users to `pmacs --gpu`; help labels raw-socket attach as advanced. +- `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. Every successful spawn is handed to the + reaper before any later connection or handshake operation can fail, and + daemon stdio is null. 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, exposes disconnect plus daemon-reaper observations, and + retains its 50-ms cadence after the event channel closes. +- `tests/gpu_invocation_acceptance.rs` covers the root broker, pre-I/O + non-CRDT gate, existing/missing/stale/racing daemon paths, process-group + SIGINT isolation with a pre-attached surviving frontend, capability and + protocol mismatches, bounded startup failure, deterministic losing-child + reaping without signaling freed PIDs, outcome propagation, and strict + headless CLI behavior. + +Verification on 2026-07-23 after the second implementation review: + +- root CLI unit suite: 33 passed; +- required GPU suite: 149 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; +- full workspace sweep: 2,961 passed across 85 suites, 19 ignored, 1 requested + skip; +- strict workspace Clippy, CRDT invocation-acceptance Clippy, formatting, and + `git diff --check` 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/Cargo.toml b/pmacs-gpu/Cargo.toml index c00923d..40bc3ee 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -62,3 +62,6 @@ pollster = "0.4.0" wgpu = "29.0.3" winit = "0.30.13" unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 7377ddb..013aa15 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,169 @@ 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, + }, + /// 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, + /// Startup deadline used for this attempt. + timeout: Duration, + }, +} + +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::StartupTimeout { + socket, + connect, + daemon_status, + timeout, + } => { + write!( + f, + "daemon did not become attachable on {} within {timeout:?}: {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::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 +418,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 +566,160 @@ 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::null()); + 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, + reason = "connector, spawner, timing, and sink stay injectable for deterministic lifecycle tests" +)] +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 child = spawner(daemon_executable, socket_path).map_err(|source| { + ManagedAttachError::SpawnDaemon { + executable: daemon_executable.to_owned(), + source, + } + })?; + let daemon = ManagedDaemonFacts::spawned(child.id()); + start_daemon_reaper(child, daemon.clone()); + let deadline = Instant::now() + timeout; + + loop { + match connector(socket_path) { + Ok(stream) => { + let client = connect_stream_with_sink(stream, sink)?; + return Ok(ManagedAttach { client, daemon }); + } + Err(error) => { + let retryable = post_spawn_retryable(socket_path, &error)?; + if !retryable { + return Err(AttachClientError::Connect(error).into()); + } + if Instant::now() >= deadline { + let daemon_status = daemon.daemon_wait_result(); + return Err(ManagedAttachError::StartupTimeout { + socket: socket_path.to_owned(), + connect: error, + daemon_status, + timeout, + }); + } + 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 { @@ -563,8 +892,8 @@ impl AttachClient { cvar.notify_one(); Ok(()) } else { - // Refused because the outbox is closed — a lossless overflow - // against a stalled daemon (or an earlier writer failure). + // Refusing a lossless event prevents replica divergence against + // a stalled daemon (or an earlier writer failure). // Tear the session down actively (F-008): shut the socket so // the reader wakes with EOF and fires `Disconnected`, giving a // visible "(daemon disconnected)" instead of a GPU that keeps @@ -583,7 +912,7 @@ impl AttachClient { #[cfg(test)] mod tests { use super::*; - use pmacs_protocol::{InstanceCapabilities, MouseButton}; + use pmacs_protocol::{InstanceCapabilities, InstanceIdentity, MouseButton}; fn caps( multi_frontend: bool, @@ -851,4 +1180,166 @@ mod tests { "peer should see EOF after the shutdown" ); } + #[test] + fn managed_attach_starts_only_for_absent_or_refused_sockets() { + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("managed.sock"); + let socket = socket_path.as_path(); + 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 temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("managed.sock"); + let socket = socket_path.as_path(); + 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 temp = tempfile::tempdir().expect("tempdir"); + let socket = temp.path().join("managed.sock"); + let result = connect_managed_inner( + &socket, + 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 + )); + } + + #[test] + fn managed_retry_survives_transients_and_uses_the_successful_stream() { + let temp = tempfile::tempdir().expect("tempdir"); + let socket = temp.path().join("managed.sock"); + let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair"); + let server = thread::spawn(move || { + let hello = Hello { + protocol_version: PROTOCOL_VERSION, + assigned_frontend_id: FrontendId::LOCAL, + instance_identity: InstanceIdentity { + pmacs_version: "managed-retry-test".to_owned(), + build_hash: None, + instance_name: None, + uptime_secs: 0, + working_directory: "/tmp".to_owned(), + }, + instance_capabilities: caps(true, true, true), + }; + write_message(&mut server_stream, &hello).expect("write Hello"); + let _: AttachRequest = + read_message(&mut server_stream).expect("read real AttachRequest"); + }); + let mut attempts = 0; + let mut client_stream = Some(client_stream); + let managed = connect_managed_inner( + &socket, + Path::new("/bin/sh"), + |_| { + attempts += 1; + match attempts { + 1 => Err(io::Error::new(io::ErrorKind::NotFound, "initial miss")), + 2 => Err(io::Error::new(io::ErrorKind::Interrupted, "signal")), + 3 => Err(io::Error::new(io::ErrorKind::WouldBlock, "backlog")), + 4 => Ok(client_stream.take().expect("single successful stream")), + _ => panic!("unexpected connection attempt"), + } + }, + |_, _| Command::new("/bin/sh").args(["-c", "exit 0"]).spawn(), + Duration::from_secs(1), + Duration::ZERO, + |_| true, + ) + .expect("transient sequence must attach"); + assert_eq!(attempts, 4); + assert!(managed.daemon.spawned_daemon()); + assert_eq!(managed.client.server_protocol_version(), PROTOCOL_VERSION); + server.join().expect("handshake server"); + } + + #[test] + fn startup_timeout_reports_the_configured_duration() { + let error = ManagedAttachError::StartupTimeout { + socket: PathBuf::from("/tmp/unused.sock"), + connect: io::Error::new(io::ErrorKind::NotFound, "still absent"), + daemon_status: Some("exit status: 17".to_owned()), + timeout: Duration::from_millis(1), + }; + let message = error.to_string(); + assert!( + message.contains("1ms"), + "unexpected timeout message: {message}" + ); + assert!(!message.contains("5 seconds")); + } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 464fe7b..98f08d5 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,68 @@ 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, + pending_events: Vec::new(), modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop @@ -765,6 +824,163 @@ 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); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + 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 +1015,72 @@ 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 + +NORMAL STARTUP: + pmacs --gpu [--socket NAME|PATH] start or reuse a managed daemon + +ADVANCED DIRECT ATTACH: + pmacs-gpu --attach attach to an existing daemon only + +OPTIONS: + 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 { + 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('-')) + { + return Err(format!( + "{flag} received option-like path operand {operand}; prefix it with ./ if it is a path" + )); + } + + 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); + [] => 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")) } - other => { - eprintln!("pmacs-gpu: unrecognized argument: {other}"); - std::process::exit(2); + [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}")), } } @@ -858,6 +1095,10 @@ struct App { /// a non-Option in a borrow. proxy: Option>, state: Option, + /// User events received before winit creates `state`. Managed attach + /// starts its reader before `run_app`, so the initial snapshot may arrive + /// before `resumed` on backends with a different callback order. + pending_events: Vec, /// Held both for stream lifetime and for the main loop's /// `send_viewport` / `send_key` write-back path. attach_client: Option, @@ -867,6 +1108,19 @@ struct App { modifiers: winit::keyboard::ModifiersState, } +fn defer_app_event( + state_ready: bool, + pending: &mut Vec, + event: AppEvent, +) -> Option { + if state_ready { + Some(event) + } else { + pending.push(event); + None + } +} + type LoroTextDeltaBatches = Arc>>>; /// All resources owned by one running pmacs-gpu instance. @@ -1547,6 +1801,69 @@ impl App { eprintln!("pmacs-gpu: send_menu_pointer failed: {e}"); } } + + fn dispatch_app_event(&mut self, event: AppEvent) { + let state = self + .state + .as_mut() + .expect("app events dispatch only after state initialization"); + match event { + AppEvent::Attach(AttachEvent::Message(msg)) => { + let debug_apply = debug_apply(); + let apply_start = debug_apply.then(std::time::Instant::now); + let label = debug_apply.then(|| instance_message_label(msg.as_ref())); + let follow_up = state.apply_attach_message(*msg); + if let (Some(start), Some(label)) = (apply_start, label) { + eprintln!( + "pmacs-gpu apply: {label}={}us", + std::time::Instant::now().duration_since(start).as_micros() + ); + } + // If the message triggered a follow-up Viewport + // (currently: every BufferSnapshot does), emit it back + // to the daemon. The daemon's `SemanticRenderState` + // produces no styling until a viewport is declared. + if let Some(ViewportSend { + buffer_id, + visible, + generation, + }) = follow_up + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_viewport(buffer_id, visible, generation) + { + eprintln!("pmacs-gpu: send Viewport failed: {e}"); + } + // Vterm Stage 3 — the dual declaration. After every + // snapshot the frontend re-declares BOTH its byte + // viewport (above) and its terminal cell size, because + // an empty terminal identity snapshot does not announce + // itself as a terminal. The daemon keeps whichever one + // matches the buffer's kind, which is what breaks the + // otherwise circular "need a frame to know to ask for + // one" dependency. + self.flush_terminal_declaration(); + let Some(state) = self.state.as_mut() else { + return; + }; + state.release_timed_out_floor(); + let ready_keys = state.take_ready_round_trip_keys(); + if let Some(client) = self.attach_client.as_ref() { + for (key, mods) in ready_keys { + if debug_input() { + eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}"); + } + if let Err(e) = client.send_key(key, mods) { + eprintln!("pmacs-gpu: flush send_key failed: {e}"); + } + } + } + } + AppEvent::Attach(AttachEvent::Disconnected(reason)) => { + eprintln!("pmacs-gpu: daemon disconnected ({reason})"); + state.on_daemon_disconnected("(daemon disconnected)"); + } + } + } } impl ApplicationHandler for App { @@ -1554,11 +1871,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 @@ -1584,6 +1902,9 @@ impl ApplicationHandler for App { } } } + for event in std::mem::take(&mut self.pending_events) { + self.dispatch_app_event(event); + } } #[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow. @@ -2173,64 +2494,9 @@ impl ApplicationHandler for App { } fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) { - let Some(state) = self.state.as_mut() else { - return; - }; - match event { - AppEvent::Attach(AttachEvent::Message(msg)) => { - let debug_apply = debug_apply(); - let apply_start = debug_apply.then(std::time::Instant::now); - let label = debug_apply.then(|| instance_message_label(msg.as_ref())); - let follow_up = state.apply_attach_message(*msg); - if let (Some(start), Some(label)) = (apply_start, label) { - eprintln!( - "pmacs-gpu apply: {label}={}us", - std::time::Instant::now().duration_since(start).as_micros() - ); - } - // If the message triggered a follow-up Viewport - // (currently: every BufferSnapshot does), emit it back - // to the daemon. The daemon's `SemanticRenderState` - // produces no styling until a viewport is declared. - if let Some(ViewportSend { - buffer_id, - visible, - generation, - }) = follow_up - && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_viewport(buffer_id, visible, generation) - { - eprintln!("pmacs-gpu: send Viewport failed: {e}"); - } - // Vterm Stage 3 — the dual declaration. After every - // snapshot the frontend re-declares BOTH its byte - // viewport (above) and its terminal cell size, because - // an empty terminal identity snapshot does not announce - // itself as a terminal. The daemon keeps whichever one - // matches the buffer's kind, which is what breaks the - // otherwise circular "need a frame to know to ask for - // one" dependency. - self.flush_terminal_declaration(); - let Some(state) = self.state.as_mut() else { - return; - }; - state.release_timed_out_floor(); - let ready_keys = state.take_ready_round_trip_keys(); - if let Some(client) = self.attach_client.as_ref() { - for (key, mods) in ready_keys { - if debug_input() { - eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}"); - } - if let Err(e) = client.send_key(key, mods) { - eprintln!("pmacs-gpu: flush send_key failed: {e}"); - } - } - } - } - AppEvent::Attach(AttachEvent::Disconnected(reason)) => { - eprintln!("pmacs-gpu: daemon disconnected ({reason})"); - state.on_daemon_disconnected("(daemon disconnected)"); - } + if let Some(event) = defer_app_event(self.state.is_some(), &mut self.pending_events, event) + { + self.dispatch_app_event(event); } } } @@ -13995,4 +14261,131 @@ 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!["--attach", "--help"], + vec!["--managed-attach", "/tmp/pmacs.sock", "--version"], + vec!["--headless-probe", "/tmp/pmacs.sock", "--help"], + vec![ + "--headless-managed-probe", + "/tmp/pmacs.sock", + "/tmp/report", + "--version", + ], + 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:?}" + ); + } + let error = parse_args(&["--attach".to_owned(), "--help".to_owned()]) + .expect_err("option-like socket operand must fail"); + assert!(error.contains("option-like path operand --help")); + } + + #[test] + fn pre_state_app_events_are_buffered_in_arrival_order() { + let mut pending = Vec::new(); + for reason in ["snapshot-predecessor", "snapshot-successor"] { + let event = AppEvent::Attach(AttachEvent::Disconnected(reason.to_owned())); + assert!(defer_app_event(false, &mut pending, event).is_none()); + } + assert_eq!(pending.len(), 2); + let reasons = pending + .into_iter() + .map(|event| match event { + AppEvent::Attach(AttachEvent::Disconnected(reason)) => reason, + AppEvent::Attach(AttachEvent::Message(_)) => panic!("unexpected message"), + }) + .collect::>(); + assert_eq!(reasons, ["snapshot-predecessor", "snapshot-successor"]); + + let immediate = AppEvent::Attach(AttachEvent::Disconnected("ready".to_owned())); + assert!(defer_app_event(true, &mut Vec::new(), immediate).is_some()); + } + + #[test] + fn gpu_cli_points_bare_invocation_to_broker_and_labels_direct_attach() { + let bare = parse_args(&[]).expect_err("bare GPU invocation must fail"); + assert!( + bare.contains("pmacs --gpu"), + "unexpected bare error: {bare}" + ); + assert!(GPU_USAGE.contains("NORMAL STARTUP")); + assert!(GPU_USAGE.contains("ADVANCED DIRECT ATTACH")); + + let extra = ["--help", "extra"].map(str::to_owned); + let error = parse_args(&extra).expect_err("help operands must fail"); + assert_eq!(error, "--help does not accept operands"); + } } diff --git a/src/main.rs b/src/main.rs index 47025e7..881b3da 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.is_file() { + 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,68 @@ 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::create_dir(&sibling).expect("create sibling directory"); + let (selected, _) = gpu_binary(&root, None); + assert_eq!(selected, PathBuf::from("pmacs-gpu")); + std::fs::remove_dir(&sibling).expect("remove sibling directory"); + + 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..4fa9196 --- /dev/null +++ b/tests/gpu_invocation_acceptance.rs @@ -0,0 +1,715 @@ +//! 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_socket_io_discovery_or_spawn() { + let temp = secure_tempdir(); + let runtime = temp.path().join("runtime"); + fs::create_dir(&runtime).expect("create runtime"); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).expect("chmod runtime 0700"); + 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) + .env("XDG_RUNTIME_DIR", &runtime) + .output() + .expect("run non-CRDT pmacs --gpu"); + assert!(!output.status.success()); + assert!(!marker.exists(), "GPU executable must not be spawned"); + assert!( + !runtime.join("pmacs/default.sock").exists(), + "the CRDT gate must run before default-socket creation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--features crdt"), + "unexpected stderr: {stderr}" + ); + + let occupied_socket = temp.path().join("occupied.sock"); + let listener = + std::os::unix::net::UnixListener::bind(&occupied_socket).expect("bind occupied socket"); + let occupied = Command::new(env!("CARGO_BIN_EXE_pmacs")) + .args(["--gpu", "--socket"]) + .arg(&occupied_socket) + .env(TEST_GPU_OVERRIDE, &fake_gpu) + .env("PMACS_TEST_MARKER", &marker) + .output() + .expect("run non-CRDT pmacs --gpu against occupied socket"); + assert!(!occupied.status.success()); + assert!( + !marker.exists(), + "live socket must not weaken the CRDT gate" + ); + assert!( + occupied_socket.exists(), + "live socket must remain untouched" + ); + drop(listener); +} + +#[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::cell::CellSize; + use pmacs::protocol::{ + AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, + InstanceCapabilities, InstanceIdentity, InstanceMessage, 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 attach_surviving_frontend(socket: &Path) -> (FrontendId, UnixStream) { + let mut stream = UnixStream::connect(socket).expect("connect surviving frontend"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set surviving frontend timeout"); + let hello: Hello = read_message(&mut stream).expect("surviving frontend Hello"); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: FrontendCapabilities { + multi_frontend: true, + crdt_replica: true, + ..FrontendCapabilities::default() + }, + initial_size: CellSize::new(24, 80), + }, + ) + .expect("attach surviving frontend"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut saw_snapshot = false; + let mut saw_full_grid = false; + while !(saw_snapshot && saw_full_grid) { + assert!( + Instant::now() < deadline, + "surviving frontend did not initialize" + ); + match read_message::(&mut stream).expect("initialize survivor") { + InstanceMessage::BufferSnapshot { .. } => saw_snapshot = true, + InstanceMessage::CellDelta { + full_grid: true, .. + } => saw_full_grid = true, + _ => {} + } + } + (hello.assigned_frontend_id, stream) + } + + 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 { + Self::spawn_with_env(socket, report, daemon_executable, home, &[]) + } + + fn spawn_with_env( + socket: &Path, + report: &Path, + daemon_executable: &Path, + home: &Path, + envs: &[(&str, &Path)], + ) -> Self { + assert!( + gpu_binary().is_file(), + "build pmacs-gpu before this acceptance suite" + ); + let mut command = Command::new(gpu_binary()); + command + .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()); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command.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(); + let daemon_reaped = fs::read_to_string(&self.report) + .ok() + .is_some_and(|report| report.lines().any(|line| line == "daemon_reaped=true")); + if !daemon_reaped && 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_and_reap_the_lock_loser() { + let temp = secure_tempdir(); + let socket = temp.path().join("race.sock"); + let first_ready = temp.path().join("first-ready"); + let second_ready = temp.path().join("second-ready"); + let first_wrapper = temp.path().join("first-daemon"); + let second_wrapper = temp.path().join("second-daemon"); + let wrapper = "touch \"$PMACS_BARRIER_SELF\"\n\ + while [ ! -e \"$PMACS_BARRIER_PEER\" ]; do sleep 0.01; done\n\ + exec \"$PMACS_REAL_DAEMON\" \"$@\""; + write_script(&first_wrapper, wrapper); + write_script(&second_wrapper, wrapper); + let real_daemon = pmacs_binary(); + + let mut first = ManagedProbe::spawn_with_env( + &socket, + &temp.path().join("first-report"), + &first_wrapper, + temp.path(), + &[ + ("PMACS_BARRIER_SELF", &first_ready), + ("PMACS_BARRIER_PEER", &second_ready), + ("PMACS_REAL_DAEMON", &real_daemon), + ], + ); + let mut second = ManagedProbe::spawn_with_env( + &socket, + &temp.path().join("second-report"), + &second_wrapper, + temp.path(), + &[ + ("PMACS_BARRIER_SELF", &second_ready), + ("PMACS_BARRIER_PEER", &first_ready), + ("PMACS_REAL_DAEMON", &real_daemon), + ], + ); + let first_facts = first.wait_ready(); + let second_facts = second.wait_ready(); + assert_eq!( + first_facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + assert_eq!( + second_facts.get("spawned_daemon").map(String::as_str), + Some("true") + ); + assert!(UnixStream::connect(&socket).is_ok()); + + let deadline = Instant::now() + Duration::from_secs(5); + let first_lost = loop { + let first_reaped = parse_report(&first.report) + .get("daemon_reaped") + .is_some_and(|value| value == "true"); + let second_reaped = parse_report(&second.report) + .get("daemon_reaped") + .is_some_and(|value| value == "true"); + if first_reaped ^ second_reaped { + break first_reaped; + } + assert!( + Instant::now() < deadline, + "exactly one losing daemon child was not reaped" + ); + thread::sleep(Duration::from_millis(20)); + }; + + if first_lost { + assert!(first.close().success()); + assert!(second.close().success()); + } else { + assert!(second.close().success()); + assert!(first.close().success()); + } + } + + #[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"); + let (survivor_id, mut survivor) = attach_surviving_frontend(&socket); + + kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT) + .expect("signal launcher group"); + let _ = wait_for_exit(&mut launcher, Duration::from_secs(5)); + + write_message( + &mut survivor, + &FrontendEvent::Resize { + frontend_id: survivor_id, + size: CellSize::new(31, 91), + }, + ) + .expect("resize surviving frontend after launcher Ctrl-C"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + assert!( + Instant::now() < deadline, + "pre-signal frontend did not render after launcher Ctrl-C" + ); + if matches!( + read_message::(&mut survivor) + .expect("read surviving frontend after launcher Ctrl-C"), + InstanceMessage::CellDelta { + full_grid: true, + .. + } + ) { + break; + } + } + 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()); + let help_text = String::from_utf8_lossy(&help.stdout); + assert!(help_text.contains("pmacs --gpu")); + assert!(help_text.contains("ADVANCED DIRECT 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")); + + let bare = Command::new(gpu_binary()) + .output() + .expect("bare GPU invocation"); + assert_eq!(bare.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&bare.stderr).contains("pmacs --gpu")); + + let help_extra = Command::new(gpu_binary()) + .args(["--help", "extra"]) + .output() + .expect("GPU help with extra operand"); + assert_eq!(help_extra.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&help_extra.stderr).contains("does not accept operands")); + + for argv in [ + vec!["--attach"], + vec!["--attach", "/tmp/x.sock", "ignored"], + vec!["--attach", "--help"], + 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:?}"); + } + } +}