Merge pull request #141 from levineuwirth/gpu-invocation

Add one-command managed GPU invocation
This commit is contained in:
Levi Neuwirth 2026-07-23 17:29:31 +00:00 committed by GitHub
commit 63fbc66943
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2704 additions and 196 deletions

1
Cargo.lock generated
View File

@ -2583,6 +2583,7 @@ dependencies = [
"pmacs-protocol",
"pollster",
"sys-locale",
"tempfile",
"unicode-width",
"wgpu",
"winit",

View File

@ -20,6 +20,7 @@ unicode-width = "0.2"
[package]
name = "pmacs"
default-run = "pmacs"
version = "1.0.0"
edition = "2024"
rust-version = "1.95"

View File

@ -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 →
# <runtime>/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 →
# <runtime>/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 -- <file> # 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
```

View File

@ -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`

View File

@ -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 12 (#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 12
(#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

View File

@ -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 -- <file>`
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 `<runtime>/pmacs/NAME.sock`; omission means `default.sock`; a
value containing `/` is used as a path (`src/socket_path.rs`).
- `<runtime>` is nonempty `$XDG_RUNTIME_DIR`, otherwise
`/tmp/pmacs-<uid>`. 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 <socket>` 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 <socket> <report>`. 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 <resolved-path>`, 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<Child>` 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 <socket> <report>` 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 <socket> <report> <daemon-exe>
```
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#GI3GI6 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 <socket> <report>` 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.

View File

@ -62,3 +62,6 @@ pollster = "0.4.0"
wgpu = "29.0.3"
winit = "0.30.13"
unicode-width = "0.2"
[dev-dependencies]
tempfile = "3"

View File

@ -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<String>,
/// 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<AttachClientError> for ManagedAttachError {
fn from(error: AttachClientError) -> Self {
Self::Attach(error)
}
}
#[derive(Debug, Default)]
struct DaemonProcessState {
reaped: bool,
wait_result: Option<String>,
}
/// Observable process facts for a daemon started by managed attach.
#[derive(Clone, Debug)]
pub struct ManagedDaemonFacts {
spawned: bool,
pid: Option<u32>,
state: Arc<Mutex<DaemonProcessState>>,
}
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<u32> {
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<String> {
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<AttachClient, AttachClientError> {
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<AttachClient, AttachClientError> {
// 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<AppEvent>,
) -> Result<ManagedAttach, ManagedAttachError> {
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<ManagedAttach, ManagedAttachError> {
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<Child> {
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<bool, ManagedAttachError> {
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<bool, ManagedAttachError> {
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<C, S, F>(
socket_path: &Path,
daemon_executable: &Path,
mut connector: C,
spawner: S,
timeout: Duration,
retry_interval: Duration,
sink: F,
) -> Result<ManagedAttach, ManagedAttachError>
where
C: FnMut(&Path) -> io::Result<UnixStream>,
S: FnOnce(&Path, &Path) -> io::Result<Child>,
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"));
}
}

View File

@ -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 <unix-socket-path>`; 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 <unix-socket-path>` 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 <socket>`: 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 <socket>`: 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 <socket> <report>`: 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::<Vec<_>>()) {
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::<AppEvent>::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::<AttachEvent>();
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 <socket>` or
/// `--help` arrives, plus any stray unrecognized flag.
fn parse_args(args: Vec<String>) -> 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 <socket> 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<Mode, String> {
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 <socket> \
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 <socket>"
.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<winit::event_loop::EventLoopProxy<AppEvent>>,
state: Option<State>,
/// 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<AppEvent>,
/// Held both for stream lifetime and for the main loop's
/// `send_viewport` / `send_key` write-back path.
attach_client: Option<AttachClient>,
@ -867,6 +1108,19 @@ struct App {
modifiers: winit::keyboard::ModifiersState,
}
fn defer_app_event(
state_ready: bool,
pending: &mut Vec<AppEvent>,
event: AppEvent,
) -> Option<AppEvent> {
if state_ready {
Some(event)
} else {
pending.push(event);
None
}
}
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
/// 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<AppEvent> for App {
@ -1554,11 +1871,12 @@ impl ApplicationHandler<AppEvent> 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<AppEvent> 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<AppEvent> 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::<Vec<_>>()
};
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::<Vec<_>>();
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::<Vec<_>>();
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");
}
}

View File

@ -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 <target>
//! 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 <target>
@ -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<PathBuf>,
frontend: FrontendChoice,
},
/// `pmacs --gpu [--socket ...]`: launch the separate GPU frontend,
/// starting a CRDT daemon on the resolved socket when absent.
Gpu { socket: Option<String> },
/// `pmacs --daemon [--socket ...]`: run a foreground daemon on a
/// Unix socket, supervised by the user.
Daemon { socket: Option<String> },
@ -199,10 +181,15 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result<AttachTarget, AttachTar
}
}
#[allow(
clippy::too_many_lines,
reason = "single-pass parser keeps mutually exclusive CLI modes explicit"
)]
fn parse_args(args: &[String]) -> CliResult {
let mut file: Option<PathBuf> = 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<String> = 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, 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(
&current_exe,
std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from),
);
let status = Command::new(&gpu)
.arg("--managed-attach")
.arg(&socket_path)
.arg(&current_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<String> = 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);
}
}

View File

@ -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<String, String> {
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<String, String> {
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::<InstanceMessage>(&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<ChildStdin>,
report: PathBuf,
daemon_pid: Option<u32>,
}
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<String, String> {
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::<Vec<_>>();
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::<u32>().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::<InstanceMessage>(&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:?}");
}
}
}