Merge pull request #141 from levineuwirth/gpu-invocation
Add one-command managed GPU invocation
This commit is contained in:
commit
63fbc66943
|
|
@ -2583,6 +2583,7 @@ dependencies = [
|
|||
"pmacs-protocol",
|
||||
"pollster",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"unicode-width",
|
||||
"wgpu",
|
||||
"winit",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ unicode-width = "0.2"
|
|||
|
||||
[package]
|
||||
name = "pmacs"
|
||||
default-run = "pmacs"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -99,15 +99,33 @@ Single-process TUI:
|
|||
pmacs [FILE] # TUI; -nw reserved for when a GUI default lands
|
||||
```
|
||||
|
||||
Daemon + attached frontends (build with `--features crdt` for
|
||||
multi-frontend editing and the GPU frontend):
|
||||
GPU frontend (one command; the root binary starts or reuses the daemon):
|
||||
|
||||
```sh
|
||||
pmacs --daemon --socket NAME # foreground daemon; bare NAME →
|
||||
# <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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-22, after Vterm Stage 3 (#135, protocol v19 and
|
||||
native GPU terminal) landed on `main`, following tab-width rendering parity
|
||||
(#137), locals-query processing (#134), modeline detection (#132), mode system
|
||||
wiring (#129), config registry (#127), Vterm Stages 1–2 (#126/#130), and
|
||||
completed Themes Arc 4 (#120/#124/#125).**
|
||||
**Last updated: 2026-07-23, with the approved one-command GPU invocation
|
||||
broker implemented on open PR #141, after the documentation refresh (#140),
|
||||
Vterm Stage 3 (#135, protocol v19 and native GPU terminal), tab-width
|
||||
rendering parity (#137), locals-query processing (#134), modeline detection
|
||||
(#132), mode system wiring (#129), config registry (#127), Vterm Stages 1–2
|
||||
(#126/#130), and completed Themes Arc 4 (#120/#124/#125).**
|
||||
This file is the
|
||||
bridge between development machines. If you are an agent reading
|
||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
||||
|
|
@ -16,11 +17,23 @@ reads it the way you just did.
|
|||
For volatile branches, checkpoints, verification, and recovery
|
||||
commands, read `docs/active-work.md` immediately after this file.
|
||||
|
||||
## 1. Where the project stands (2026-07-22)
|
||||
## 1. Where the project stands (2026-07-23)
|
||||
|
||||
- `main` @ `cac4961` (Vterm Stage 3 #135 atop tab-width parity #137),
|
||||
protocol **v19** (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 =
|
||||
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events).
|
||||
- `main` @ `96d0bae` (documentation refresh #140 atop Vterm Stage 3 #135
|
||||
and tab-width parity #137), protocol **v19** (`SUPPORTED=[6..=19]`; v16 =
|
||||
`ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal
|
||||
frames/events).
|
||||
- **One-command GPU invocation is implemented on OPEN PR #141, not yet on
|
||||
`main`** (`gpu-invocation`, second implementation-review checkpoint
|
||||
`154cb9f`, framing Revision 6). The additive public path is
|
||||
`pmacs --gpu [--socket NAME|PATH]`; bare `pmacs [FILE]` remains the TUI.
|
||||
Root owns the CRDT gate, socket resolution, sibling-regular-file GPU
|
||||
discovery/PATH fallback, and GPU outcome. The separate `pmacs-gpu` binary
|
||||
owns connect-or-start, a five-second / 50-ms retry window, pre-winit event
|
||||
buffering, daemon process-group/stdin/stdout/stderr isolation, and named
|
||||
child reaping with explicit ownership handoff. Direct
|
||||
`pmacs-gpu --attach RAW_PATH` remains strict, is documented as advanced,
|
||||
and never auto-starts. No protocol change.
|
||||
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
|
||||
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
|
||||
typed, introspectable options registry the backlog ranked first, and
|
||||
|
|
|
|||
|
|
@ -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#GI3–GI6 and Q#GI13: real binary, real Unix
|
||||
socket, real Hello/capability negotiation, and real daemon subprocess. A
|
||||
decoded-message fixture or a second test-only connect implementation is not
|
||||
accepted evidence.
|
||||
|
||||
The existing `--headless-probe <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.
|
||||
|
|
@ -62,3 +62,6 @@ pollster = "0.4.0"
|
|||
wgpu = "29.0.3"
|
||||
winit = "0.30.13"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
217
src/main.rs
217
src/main.rs
|
|
@ -2,59 +2,36 @@
|
|||
|
||||
//! Pmacs binary entry point.
|
||||
//!
|
||||
//! Parses command-line arguments and dispatches to [`pmacs::editor::run`].
|
||||
//! Parses command-line arguments and dispatches local TUI, daemon, attach,
|
||||
//! remote bridge, and managed GPU modes.
|
||||
//!
|
||||
//! # Command-line surface
|
||||
//!
|
||||
//! ```text
|
||||
//! pmacs [-nw|--no-window] [--help] [--version] [FILE]
|
||||
//! pmacs --gpu [--socket NAME|PATH]
|
||||
//! pmacs --daemon [--socket NAME|PATH]
|
||||
//! pmacs --attach [--socket NAME|PATH]
|
||||
//! pmacs --attach <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(
|
||||
¤t_exe,
|
||||
std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from),
|
||||
);
|
||||
let status = Command::new(&gpu)
|
||||
.arg("--managed-attach")
|
||||
.arg(&socket_path)
|
||||
.arg(¤t_exe)
|
||||
.status();
|
||||
match status {
|
||||
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||
Ok(status) => {
|
||||
eprintln!("pmacs: GPU frontend {} exited with {status}", gpu.display());
|
||||
status
|
||||
.code()
|
||||
.and_then(|code| u8::try_from(code).ok())
|
||||
.map_or(ExitCode::FAILURE, ExitCode::from)
|
||||
}
|
||||
Err(error) => {
|
||||
if gpu == Path::new("pmacs-gpu") {
|
||||
eprintln!(
|
||||
"pmacs: could not launch GPU frontend: sibling {} is absent and PATH lookup \
|
||||
for pmacs-gpu failed: {error}",
|
||||
sibling.display()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"pmacs: could not launch GPU frontend {}: {error}",
|
||||
gpu.display()
|
||||
);
|
||||
}
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue