Add one-command managed GPU invocation

Add the root --gpu broker, strict GPU entry points, daemon connect-or-start orchestration, process-group isolation, bounded retry, named child reaping, and a deterministic headless lifecycle probe. Cover the complete launch matrix with real subprocess acceptance, make root Cargo runs unambiguous, and document the coherent build and one-command workflow.
This commit is contained in:
Levi Neuwirth 2026-07-23 11:02:09 -04:00
parent 821835b8c5
commit 6fd583417b
7 changed files with 1599 additions and 125 deletions

View File

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

View File

@ -99,15 +99,33 @@ Single-process TUI:
pmacs [FILE] # TUI; -nw reserved for when a GUI default lands
```
Daemon + attached frontends (build with `--features crdt` for
multi-frontend editing and the GPU frontend):
GPU frontend (one command; the root binary starts or reuses the daemon):
```sh
pmacs --daemon --socket NAME # foreground daemon; bare NAME →
# <runtime>/pmacs/NAME.sock
pmacs --attach --socket NAME # TUI frontend; F12 detaches
pmacs --attach user@host # remote TUI over SSH
pmacs-gpu --attach /run/user/$UID/pmacs/NAME.sock # GPU frontend
pmacs --gpu # default instance
pmacs --gpu --socket NAME # named instance; bare NAME →
# <runtime>/pmacs/NAME.sock
```
`pmacs --gpu` requires the root `pmacs` binary to be built with the
`crdt` feature. It discovers a sibling `pmacs-gpu` binary first, then
falls back to `pmacs-gpu` on `PATH`. Closing the window detaches only
that frontend; the daemon remains available for later GPU or TUI
attaches.
Daemon + attached TUI frontends:
```sh
pmacs --daemon --socket NAME # foreground daemon
pmacs --attach --socket NAME # TUI frontend; F12 detaches
pmacs --attach user@host # remote TUI over SSH
```
For debugging an already-running daemon, the low-level GPU command stays
available and never auto-starts or replaces anything:
```sh
pmacs-gpu --attach /absolute/path/to/pmacs.sock
```
`pmacs --attach` also understands `ssh:user@host/instance`,
@ -126,11 +144,13 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust
`1.95.0`, edition 2024); rustup selects it automatically.
```sh
cargo build --release # target/release/pmacs (LuaJIT flavor)
cargo build --release --features crdt # + CRDT buffers (daemon use)
cargo build --release -p pmacs-gpu # the GPU frontend binary
cargo run --release -- <file> # build and run on a file
cargo test --workspace # unit + integration tests (all crates)
# Coherent root + GPU release build. The package-qualified feature keeps
# the separate pmacs-gpu package feature-free while enabling CRDT in pmacs.
cargo build --release --workspace --features pmacs/crdt
target/release/pmacs --gpu # one-command managed GPU launch
cargo run --release -- --version # default-run selects the pmacs binary
cargo test --workspace # unit + integration tests (all crates)
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings # incl. pmacs-gpu
```

View File

@ -1,7 +1,7 @@
# GPU invocation — one-command broker framing
**Revision 3 — pre-implementation. Ground truth: canonical `main` @
`96d0bae`, protocol v19, 2026-07-23.**
**Revision 4 — implemented on `gpu-invocation`. Ground truth: canonical
`main` @ `96d0bae`, protocol v19, 2026-07-23.**
The GPU editor works, but reaching it is still a development-session ritual:
build two packages with different feature requirements, keep a foreground
@ -30,6 +30,11 @@ keeps `Interrupted` / `WouldBlock` transient inside the post-spawn retry
window, states the process-group signal simulation in CI-executable terms,
and distinguishes the socket type check from liveness inference.
Revision 4 records the as-built cutover: the root broker, strict GPU CLI,
managed connector, process-group isolation, named child reaper, deterministic
managed probe, acceptance suite, coherent workspace build, and one-command
visible smoke are implemented and verified.
## Ground truth
### Current user path
@ -611,3 +616,40 @@ Vterm probe continues to cover offscreen wgpu.
build succeeds; `cargo run --release -- --version` selects `pmacs` through
`default-run`; no documented command requires users to spell the resolved
socket pathname for managed GPU startup.
## As built
- `Cargo.toml` sets `default-run = "pmacs"`. The documented coherent build is
`cargo build --release --workspace --features pmacs/crdt`.
- `src/main.rs` owns `pmacs --gpu [--socket NAME|PATH]`, the non-CRDT gate,
socket resolution, test override, sibling-first GPU discovery with PATH
fallback, child argv, and exit-status propagation.
- `pmacs-gpu/src/main.rs` accepts only explicit direct, managed, and headless
modes. Managed windowed attach completes before winit creates a window.
Bare invocation is an exit-2 usage error pointing users to `pmacs --gpu`.
- `pmacs-gpu/src/attach.rs` owns connect-or-start policy, the five-second /
50-ms retry window, socket-type protection, daemon process-group isolation,
and the named child-reaper thread. The first successful protocol connection
wins; protocol/capability failures never authorize replacement.
- `--headless-managed-probe SOCKET REPORT DAEMON_EXE` drives the production
managed connector, writes atomic `phase=ready` / `phase=complete` reports,
holds on stdin, and exposes disconnect plus daemon-reaper observations.
- `tests/gpu_invocation_acceptance.rs` covers the root broker, non-CRDT gate,
existing/missing/stale/racing daemon paths, process-group SIGINT isolation,
capability and protocol mismatches, bounded startup failure, child reaping,
outcome propagation, and strict headless CLI behavior.
Verification on 2026-07-23:
- root CLI unit suite: 33 passed;
- required GPU suite: 145 passed;
- managed invocation acceptance: 1 default + 9 CRDT passed;
- Vterm Stage 3: 7 passed with `PMACS_REQUIRE_GPU=1`;
- default / CRDT libraries: 1,768 / 1,944 passed;
- M4 acceptance: 121 passed, 3 ignored, 1 requested skip;
- strict workspace Clippy and formatting passed;
- the documented release workspace build and `cargo run --release --
--version` passed;
- two real `target/release/pmacs --gpu` launches on Wayland/Vulkan attached at
protocol v19. The first auto-started daemon remained alive after the GPU
process closed; the second reused that same daemon and created no replacement.

View File

@ -17,10 +17,16 @@
//! redraw.
use std::collections::VecDeque;
use std::fs;
use std::io;
use std::os::unix::fs::FileTypeExt;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use pmacs_protocol::{
AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities,
@ -89,6 +95,172 @@ impl std::fmt::Display for AttachClientError {
impl std::error::Error for AttachClientError {}
/// Failure while connecting the managed GPU path.
#[derive(Debug)]
pub enum ManagedAttachError {
/// The daemon connection reached the normal attach client and failed.
Attach(AttachClientError),
/// A refused socket path exists but is not a Unix socket.
NonSocketPath(PathBuf),
/// Inspecting a refused socket path failed.
InspectSocket {
/// Path whose entry type could not be inspected.
path: PathBuf,
/// Filesystem error from `metadata`.
source: io::Error,
},
/// The requested daemon executable could not be started.
SpawnDaemon {
/// Executable supplied by the root broker.
executable: PathBuf,
/// Process-spawn failure.
source: io::Error,
},
/// The daemon process could not be queried for an early exit.
ObserveDaemon(io::Error),
/// No attachable daemon appeared before the bounded deadline.
StartupTimeout {
/// Socket path that remained unreachable.
socket: PathBuf,
/// Most recent connect error.
connect: io::Error,
/// Observed daemon process outcome, when it exited early.
daemon_status: Option<String>,
},
}
impl std::fmt::Display for ManagedAttachError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Attach(error) => error.fmt(f),
Self::NonSocketPath(path) => write!(
f,
"refusing to start a daemon: socket path {} exists and is not a Unix socket",
path.display()
),
Self::InspectSocket { path, source } => write!(
f,
"cannot inspect refused socket path {}: {source}",
path.display()
),
Self::SpawnDaemon { executable, source } => write!(
f,
"could not start daemon executable {}: {source}",
executable.display()
),
Self::ObserveDaemon(source) => {
write!(f, "could not inspect the managed daemon process: {source}")
}
Self::StartupTimeout {
socket,
connect,
daemon_status,
} => {
write!(
f,
"daemon did not become attachable on {} within 5 seconds: {connect}",
socket.display()
)?;
if let Some(status) = daemon_status {
write!(f, " (spawned daemon {status})")?;
}
Ok(())
}
}
}
}
impl std::error::Error for ManagedAttachError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Attach(error) => Some(error),
Self::InspectSocket { source, .. }
| Self::SpawnDaemon { source, .. }
| Self::ObserveDaemon(source)
| Self::StartupTimeout {
connect: source, ..
} => Some(source),
Self::NonSocketPath(_) => None,
}
}
}
impl From<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 +421,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 +569,172 @@ pub fn connect_with_sink(
})
}
const MANAGED_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
const MANAGED_RETRY_INTERVAL: Duration = Duration::from_millis(50);
/// Connect to an existing semantic daemon or start the supplied daemon first.
pub fn connect_managed(
socket_path: &Path,
daemon_executable: &Path,
proxy: EventLoopProxy<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::inherit());
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)]
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 mut child = spawner(daemon_executable, socket_path).map_err(|source| {
ManagedAttachError::SpawnDaemon {
executable: daemon_executable.to_owned(),
source,
}
})?;
let daemon = ManagedDaemonFacts::spawned(child.id());
let deadline = Instant::now() + timeout;
loop {
match connector(socket_path) {
Ok(stream) => {
let attached = connect_stream_with_sink(stream, sink);
start_daemon_reaper(child, daemon.clone());
return Ok(ManagedAttach {
client: attached?,
daemon,
});
}
Err(error) => {
if !post_spawn_retryable(socket_path, &error)? {
return Err(AttachClientError::Connect(error).into());
}
if let Some(status) = child
.try_wait()
.map_err(ManagedAttachError::ObserveDaemon)?
{
daemon.record_wait(status.to_string());
}
if daemon.daemon_reaped() {
let status = daemon.daemon_wait_result();
if Instant::now() >= deadline {
return Err(ManagedAttachError::StartupTimeout {
socket: socket_path.to_owned(),
connect: error,
daemon_status: status,
});
}
} else if Instant::now() >= deadline {
return Err(ManagedAttachError::StartupTimeout {
socket: socket_path.to_owned(),
connect: error,
daemon_status: None,
});
}
thread::sleep(
retry_interval.min(deadline.saturating_duration_since(Instant::now())),
);
}
}
}
}
/// Handle the main loop keeps after `connect` returns. It queues
/// `FrontendEvent`s for the attach writer thread.
pub struct AttachClient {
@ -851,4 +1195,95 @@ mod tests {
"peer should see EOF after the shutdown"
);
}
#[test]
fn managed_attach_starts_only_for_absent_or_refused_sockets() {
let socket = Path::new("/tmp/pmacs-managed-test.sock");
assert!(
initial_startup_authorized(socket, &io::Error::new(io::ErrorKind::NotFound, "absent"))
.expect("classify absent socket")
);
assert!(
initial_startup_authorized(
socket,
&io::Error::new(io::ErrorKind::ConnectionRefused, "refused")
)
.expect("classify vanished socket")
);
for kind in [
io::ErrorKind::PermissionDenied,
io::ErrorKind::Interrupted,
io::ErrorKind::WouldBlock,
io::ErrorKind::InvalidInput,
] {
assert!(
!initial_startup_authorized(socket, &io::Error::new(kind, "final"))
.expect("classify final connect error"),
"{kind:?} must not authorize daemon startup"
);
}
}
#[test]
fn managed_retry_adds_only_interrupted_and_would_block() {
let socket = Path::new("/tmp/pmacs-managed-test.sock");
for kind in [io::ErrorKind::Interrupted, io::ErrorKind::WouldBlock] {
assert!(
post_spawn_retryable(socket, &io::Error::new(kind, "transient"))
.expect("classify transient retry")
);
}
assert!(
!post_spawn_retryable(
socket,
&io::Error::new(io::ErrorKind::PermissionDenied, "final")
)
.expect("classify final retry error")
);
}
#[test]
fn managed_attach_refuses_a_non_socket_path_without_spawning() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
let result = connect_managed_inner(
&path,
Path::new("/unused/pmacs"),
|_| {
Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
"synthetic refused connect",
))
},
|_, _| panic!("non-socket path must not spawn"),
Duration::from_millis(1),
Duration::from_millis(1),
|_| false,
);
assert!(matches!(
result,
Err(ManagedAttachError::NonSocketPath(rejected)) if rejected == path
));
}
#[test]
fn managed_attach_fails_closed_on_non_retryable_connect_errors() {
let result = connect_managed_inner(
Path::new("/tmp/pmacs-managed-test.sock"),
Path::new("/unused/pmacs"),
|_| {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"synthetic permission failure",
))
},
|_, _| panic!("permission failure must not spawn"),
Duration::from_millis(1),
Duration::from_millis(1),
|_| false,
);
assert!(matches!(
result,
Err(ManagedAttachError::Attach(AttachClientError::Connect(error)))
if error.kind() == io::ErrorKind::PermissionDenied
));
}
}

View File

@ -1,19 +1,19 @@
//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Two run modes:
//! User-facing invocation is strict:
//!
//! - **Hello-world** (no `--attach` argument; session 2 default).
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//! - `pmacs-gpu --attach <unix-socket-path>` directly attaches to an
//! already-running daemon and never starts or replaces it.
//! - The root `pmacs --gpu` broker invokes a hidden managed mode that connects
//! first, starts the supplied daemon only for an absent/refused socket, and
//! creates the window only after protocol and capability negotiation.
//! - Headless probe modes exercise the same direct and managed production
//! connectors for acceptance without requiring a display.
//!
//! An attached frontend imports the daemon's `BufferSnapshot` into a local
//! loro replica, sends a `Viewport` back to request scoped styling, and
//! consumes the `StyleSpans` stream. Live `CrdtOp` updates apply to the
//! replica; subsequent `StyleSpans` frames re-style it.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
@ -524,10 +524,7 @@ const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32;
const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4];
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
/// modes render. Once the daemon's `BufferSnapshot` arrives the
/// rendered text becomes the rope contents instead.
const HELLO_TEXT: &str = "hello, pmacs";
const CONNECTING_TEXT: &str = "(connecting...)";
/// Container id the daemon uses on its loro `LoroDoc` for the
/// buffer's text. Must match `pmacs::crdt::CrdtState`'s container
@ -546,13 +543,20 @@ pub enum AppEvent {
}
/// CLI mode derived from argv.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
/// `pmacs-gpu` (no args): inert hello-world.
HelloWorld,
/// `pmacs-gpu --attach <socket>`: connect + render the daemon's
/// rope.
/// Print CLI help without initializing winit or wgpu.
Help,
/// Print package and protocol versions without initializing winit or wgpu.
Version,
/// `pmacs-gpu --attach <socket>`: strict direct attach to an existing daemon.
Attach { socket: PathBuf },
/// Hidden root-broker entry: connect or start the supplied daemon before
/// creating the window.
ManagedAttach {
socket: PathBuf,
daemon_executable: PathBuf,
},
/// `pmacs-gpu --headless-probe <socket> <report>`: attach through
/// the real client, render real frames offscreen, and write a
/// machine-readable report.
@ -563,6 +567,12 @@ enum Mode {
/// `apply_attach_message`, and the same `render_to_view` the windowed
/// mode does — only winit is absent, because CI has no display.
HeadlessProbe { socket: PathBuf, report: PathBuf },
/// Hidden display-less acceptance seam for managed daemon lifecycle.
HeadlessManagedProbe {
socket: PathBuf,
report: PathBuf,
daemon_executable: PathBuf,
},
}
/// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes
@ -580,19 +590,67 @@ fn decimal_digits(mut n: usize) -> u32 {
fn main() {
env_logger::init();
let mode = parse_args(std::env::args().skip(1).collect());
if let Mode::HeadlessProbe { socket, report } = &mode {
std::process::exit(run_headless_probe(socket, report));
let mode = match parse_args(&std::env::args().skip(1).collect::<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,
modifiers: winit::keyboard::ModifiersState::empty(),
};
event_loop
@ -765,6 +823,162 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
0
}
/// Exercise the real managed connector without creating a display.
///
/// After the first real `BufferSnapshot`, the probe writes `phase=ready` and
/// holds the session open until stdin reaches EOF. Lifecycle observations
/// refresh the report while held; EOF writes `phase=complete`.
#[allow(
clippy::too_many_lines,
reason = "one linear managed-connect and lifecycle observation probe"
)]
fn run_headless_managed_probe(socket: &Path, report: &Path, daemon_executable: &Path) -> i32 {
use std::io::Read as _;
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (event_tx, event_rx) = mpsc::channel::<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);
}
}
}
let reaped = daemon.daemon_reaped();
let wait_result = daemon.daemon_wait_result();
if ready
&& (reaped != last_reaped
|| wait_result != last_wait_result
|| disconnect != last_disconnect)
{
if let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
{
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
);
return 5;
}
last_reaped = reaped;
last_wait_result = wait_result;
last_disconnect.clone_from(&disconnect);
}
if ready && stdin_closed {
if let Err(error) =
write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect)
{
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
);
return 5;
}
return 0;
}
if !ready && Instant::now() >= deadline {
let contents = format!(
"phase=error\nerror=timed out waiting for BufferSnapshot\ndisconnect={disconnect}\n"
);
let _ = write_probe_report(report, &contents);
eprintln!("pmacs-gpu managed probe: timed out waiting for BufferSnapshot");
return 6;
}
}
}
fn write_managed_probe_report(
report: &Path,
phase: &str,
protocol: u32,
daemon: &attach::ManagedDaemonFacts,
disconnect: &str,
) -> std::io::Result<()> {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "phase={phase}");
let _ = writeln!(out, "server_protocol_version={protocol}");
let _ = writeln!(out, "buffer_snapshot=true");
let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon());
let _ = writeln!(
out,
"daemon_pid={}",
daemon.daemon_pid().unwrap_or_default()
);
let _ = writeln!(out, "daemon_reaped={}", daemon.daemon_reaped());
let _ = writeln!(
out,
"daemon_wait_result={}",
daemon.daemon_wait_result().unwrap_or_default()
);
let _ = writeln!(out, "disconnect={disconnect}");
write_probe_report(report, &out)
}
fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> {
let mut temporary = report.as_os_str().to_os_string();
temporary.push(".tmp");
let temporary = PathBuf::from(temporary);
std::fs::write(&temporary, contents)?;
std::fs::rename(temporary, report)
}
/// Named observations the headless probe reports back to the acceptance.
#[derive(Default)]
struct ProbeFacts {
@ -799,51 +1013,49 @@ fn frame_probe_text(frame: &TerminalFrame) -> String {
text
}
/// Tiny argv parser. No `clap` because the surface is genuinely two
/// shapes; full CLI parsing arrives when there's more to parse. The
/// `for` ranges over a small set: at most one `--attach <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
USAGE:
pmacs-gpu --attach <socket> attach to an existing daemon
pmacs-gpu --help print this help
pmacs-gpu --version print package and protocol versions";
/// Strict parser for direct, managed, and headless GPU entry points.
fn parse_args(args: &[String]) -> Result<Mode, String> {
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);
}
other => {
eprintln!("pmacs-gpu: unrecognized argument: {other}");
std::process::exit(2);
[] => Err("an explicit mode is required; use --attach <socket>".to_owned()),
[flag, ..]
if matches!(
flag.as_str(),
"--attach" | "--managed-attach" | "--headless-probe" | "--headless-managed-probe"
) =>
{
Err(format!("{flag} received the wrong number of operands"))
}
[other, ..] => Err(format!("unrecognized argument: {other}")),
}
}
@ -1554,11 +1766,12 @@ impl ApplicationHandler<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
@ -13995,4 +14208,83 @@ mod tests {
hostile.title = Some("\u{1b}]0;pwned\u{7}".into());
assert!(hostile.validate().is_err());
}
#[test]
fn gpu_cli_accepts_only_explicit_exact_modes() {
let args = |values: &[&str]| {
values
.iter()
.map(|value| (*value).to_owned())
.collect::<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!["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:?}"
);
}
}
}

View File

@ -2,59 +2,36 @@
//! Pmacs binary entry point.
//!
//! Parses command-line arguments and dispatches to [`pmacs::editor::run`].
//! Parses command-line arguments and dispatches local TUI, daemon, attach,
//! remote bridge, and managed GPU modes.
//!
//! # Command-line surface
//!
//! ```text
//! pmacs [-nw|--no-window] [--help] [--version] [FILE]
//! pmacs --gpu [--socket NAME|PATH]
//! pmacs --daemon [--socket NAME|PATH]
//! pmacs --attach [--socket NAME|PATH]
//! pmacs --attach <target>
//! pmacs --daemon-attach [--socket NAME|PATH]
//! ```
//!
//! * `FILE` (positional, optional): file to open. Without one, the editor
//! opens an empty `*scratch*` buffer.
//! * `-nw` / `--no-window`: select the terminal (TUI) frontend explicitly.
//! This is the *only* frontend pmacs ships in v0.1, so the flag is
//! currently a no-op marker — but it's parsed and recorded now so that
//! when a GUI frontend lands in M4 ("The Service Layer"), `pmacs` with
//! no flags will default to the GUI and `pmacs -nw` will keep launching
//! the TUI exactly as it does today. This mirrors GNU Emacs's
//! `emacs -nw` and Doom's behavior, and lets users wire `pmacs -nw` into
//! `EDITOR=` / git hooks today without their config breaking when the
//! GUI ships.
//! * `--help` / `-h`, `--version` / `-V`: standard.
//! `--gpu` is additive: bare `pmacs [FILE]` remains the local TUI. The root
//! broker resolves the socket, requires a CRDT-capable build, discovers the
//! separate `pmacs-gpu` executable, and waits for that frontend's outcome.
//! The GPU child owns connect-or-start orchestration for the supplied daemon
//! executable. Direct TUI and GPU attach modes remain available for debugging.
//!
//! Anything else is a usage error and exits 2.
//!
//! # Frontend selection (planning note for M4)
//!
//! When the GUI lands, the entry-point split looks like this:
//!
//! ```ignore
//! match selected_frontend(&args) {
//! Frontend::Tui => editor::run_tui(file),
//! Frontend::Gui => editor::run_gui(file),
//! }
//! ```
//!
//! Selection precedence (high to low):
//! 1. Explicit `-nw` / `--no-window` → TUI.
//! 2. Explicit `--gui` (future) → GUI.
//! 3. `PMACS_FRONTEND=tui|gui` env var.
//! 4. `$DISPLAY` / `$WAYLAND_DISPLAY` present and a GUI build was linked
//! in → GUI; otherwise → TUI.
//! 5. Fallback: TUI.
//!
//! `editor::run` stays as the canonical TUI entry point. The split
//! happens in `main`, not deeper, so the rest of the codebase stays
//! frontend-agnostic at the [`pmacs::frontend`] trait surface.
use std::path::PathBuf;
use std::process::ExitCode;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use pmacs::protocol::{AttachTarget, AttachTargetError};
const USAGE: &str = "\
usage: pmacs [-nw|--no-window] [--help] [--version] [FILE]
pmacs --gpu [--socket NAME|PATH]
pmacs --daemon [--socket NAME|PATH]
pmacs --attach [--socket NAME|PATH]
pmacs --attach <target>
@ -64,6 +41,8 @@ usage: pmacs [-nw|--no-window] [--help] [--version] [FILE]
(currently the only frontend; reserved for the
M4 GUI rollout, where `pmacs` will default to
the GUI and `-nw` will keep launching the TUI)
--gpu start or reuse a CRDT daemon, then launch the
separate pmacs-gpu frontend
--daemon run as a foreground daemon listening on a Unix
socket; supervised by the user (systemd, tmux,
`nohup &`, etc.)
@ -116,6 +95,9 @@ enum Mode {
file: Option<PathBuf>,
frontend: FrontendChoice,
},
/// `pmacs --gpu [--socket ...]`: launch the separate GPU frontend,
/// starting a CRDT daemon on the resolved socket when absent.
Gpu { socket: Option<String> },
/// `pmacs --daemon [--socket ...]`: run a foreground daemon on a
/// Unix socket, supervised by the user.
Daemon { socket: Option<String> },
@ -199,10 +181,15 @@ fn parse_attach_target_with_shorthand(s: &str) -> Result<AttachTarget, AttachTar
}
}
#[allow(
clippy::too_many_lines,
reason = "single-pass parser keeps mutually exclusive CLI modes explicit"
)]
fn parse_args(args: &[String]) -> CliResult {
let mut file: Option<PathBuf> = None;
let mut frontend = FrontendChoice::Auto;
let mut daemon = false;
let mut gpu = false;
let mut attach = false;
let mut daemon_attach = false;
let mut socket: Option<String> = None;
@ -210,6 +197,7 @@ fn parse_args(args: &[String]) -> CliResult {
while let Some(arg) = iter.next() {
match arg.as_str() {
"-nw" | "--no-window" => frontend = FrontendChoice::Tui,
"--gpu" => gpu = true,
"--daemon" => daemon = true,
"--attach" => attach = true,
"--daemon-attach" => daemon_attach = true,
@ -242,12 +230,25 @@ fn parse_args(args: &[String]) -> CliResult {
}
}
}
let mode_flags = u8::from(daemon) + u8::from(attach) + u8::from(daemon_attach);
let mode_flags = u8::from(gpu) + u8::from(daemon) + u8::from(attach) + u8::from(daemon_attach);
if mode_flags > 1 {
return CliResult::Error(
"--daemon, --attach, and --daemon-attach are mutually exclusive".into(),
"--gpu, --daemon, --attach, and --daemon-attach are mutually exclusive".into(),
);
}
if gpu {
if file.is_some() {
return CliResult::Error(
"--gpu does not yet accept FILE; open it from the GPU with C-x C-f".into(),
);
}
if frontend == FrontendChoice::Tui {
return CliResult::Error("--gpu and --no-window are mutually exclusive".into());
}
return CliResult::Run(CliArgs {
mode: Mode::Gpu { socket },
});
}
if daemon {
if file.is_some() {
return CliResult::Error("--daemon does not take a file argument".into());
@ -280,11 +281,82 @@ fn parse_args(args: &[String]) -> CliResult {
mode: Mode::DaemonAttach { socket },
});
}
if socket.is_some() {
return CliResult::Error(
"--socket requires --gpu, --daemon, --attach, or --daemon-attach".into(),
);
}
CliResult::Run(CliArgs {
mode: Mode::Local { file, frontend },
})
}
const PMACS_TEST_GPU_BIN: &str = "PMACS_TEST_GPU_BIN";
fn gpu_binary(current_exe: &Path, override_bin: Option<PathBuf>) -> (PathBuf, PathBuf) {
let sibling = current_exe
.parent()
.unwrap_or_else(|| Path::new(""))
.join("pmacs-gpu");
if let Some(override_bin) = override_bin {
return (override_bin, sibling);
}
if sibling.exists() {
return (sibling.clone(), sibling);
}
(PathBuf::from("pmacs-gpu"), sibling)
}
fn run_gpu(socket: Option<&str>) -> ExitCode {
if !cfg!(feature = "crdt") {
eprintln!("pmacs: --gpu requires pmacs built with --features crdt");
return ExitCode::FAILURE;
}
let socket_path = pmacs::socket_path::resolve_socket_path(socket);
let current_exe = match std::env::current_exe() {
Ok(path) => path,
Err(error) => {
eprintln!("pmacs: cannot locate the running pmacs executable: {error}");
return ExitCode::FAILURE;
}
};
let (gpu, sibling) = gpu_binary(
&current_exe,
std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from),
);
let status = Command::new(&gpu)
.arg("--managed-attach")
.arg(&socket_path)
.arg(&current_exe)
.status();
match status {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(status) => {
eprintln!("pmacs: GPU frontend {} exited with {status}", gpu.display());
status
.code()
.and_then(|code| u8::try_from(code).ok())
.map_or(ExitCode::FAILURE, ExitCode::from)
}
Err(error) => {
if gpu == Path::new("pmacs-gpu") {
eprintln!(
"pmacs: could not launch GPU frontend: sibling {} is absent and PATH lookup \
for pmacs-gpu failed: {error}",
sibling.display()
);
} else {
eprintln!(
"pmacs: could not launch GPU frontend {}: {error}",
gpu.display()
);
}
ExitCode::FAILURE
}
}
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match parse_args(&args) {
@ -316,6 +388,7 @@ fn main() -> ExitCode {
ExitCode::FAILURE
}
},
Mode::Gpu { socket } => run_gpu(socket.as_deref()),
Mode::Daemon { socket } => {
let socket_path = pmacs::socket_path::resolve_socket_path(socket.as_deref());
// The user-provided NAME (no slashes) becomes the
@ -736,4 +809,63 @@ mod tests {
}
}
}
#[test]
fn gpu_flag_selects_managed_gpu_with_optional_socket() {
for (argv, expected) in [
(vec!["--gpu"], None),
(vec!["--gpu", "--socket", "research"], Some("research")),
] {
let argv = args(&argv);
match parse_args(&argv) {
CliResult::Run(CliArgs {
mode: Mode::Gpu { socket },
}) => assert_eq!(socket.as_deref(), expected),
other => panic!("expected GPU mode; got {other:?}"),
}
}
}
#[test]
fn gpu_flag_rejects_files_tui_and_other_modes() {
for argv in [
vec!["--gpu", "README.md"],
vec!["--gpu", "-nw"],
vec!["--gpu", "--daemon"],
vec!["--gpu", "--attach"],
vec!["--gpu", "--daemon-attach"],
] {
assert!(
matches!(parse_args(&args(&argv)), CliResult::Error(_)),
"accepted conflicting argv: {argv:?}"
);
}
}
#[test]
fn bare_socket_is_never_silently_ignored() {
match parse_args(&args(&["--socket", "research"])) {
CliResult::Error(message) => assert!(message.contains("--socket requires")),
other => panic!("expected bare --socket error; got {other:?}"),
}
}
#[test]
fn gpu_binary_discovery_prefers_override_then_sibling_then_path() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("pmacs");
let sibling = temp.path().join("pmacs-gpu");
let override_bin = temp.path().join("override-gpu");
let (selected, reported_sibling) = gpu_binary(&root, Some(override_bin.clone()));
assert_eq!(selected, override_bin);
assert_eq!(reported_sibling, sibling);
std::fs::write(&sibling, b"gpu").expect("create sibling");
let (selected, _) = gpu_binary(&root, None);
assert_eq!(selected, sibling);
std::fs::remove_file(&sibling).expect("remove sibling");
let (selected, reported_sibling) = gpu_binary(&root, None);
assert_eq!(selected, PathBuf::from("pmacs-gpu"));
assert_eq!(reported_sibling, sibling);
}
}

View File

@ -0,0 +1,552 @@
//! End-to-end acceptance for one-command GPU invocation and managed daemon lifecycle.
#![cfg(unix)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
const TEST_GPU_OVERRIDE: &str = "PMACS_TEST_GPU_BIN";
fn secure_tempdir() -> TempDir {
let temp = tempfile::tempdir().expect("tempdir");
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
.expect("chmod tempdir 0700");
temp
}
fn write_script(path: &Path, body: &str) {
fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).expect("write script");
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod script");
}
#[cfg(not(feature = "crdt"))]
#[test]
fn non_crdt_root_rejects_gpu_before_discovery_or_spawn() {
let temp = secure_tempdir();
let fake_gpu = temp.path().join("fake-gpu");
let marker = temp.path().join("spawned");
write_script(&fake_gpu, "touch \"$PMACS_TEST_MARKER\"");
let output = Command::new(env!("CARGO_BIN_EXE_pmacs"))
.arg("--gpu")
.env(TEST_GPU_OVERRIDE, &fake_gpu)
.env("PMACS_TEST_MARKER", &marker)
.output()
.expect("run non-CRDT pmacs --gpu");
assert!(!output.status.success());
assert!(
!marker.exists(),
"GPU executable must not be discovered or spawned"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--features crdt"),
"unexpected stderr: {stderr}"
);
}
#[cfg(feature = "crdt")]
mod crdt {
use std::collections::HashMap;
use std::os::unix::net::{UnixListener, UnixStream};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::Stdio;
use std::process::{Child, ChildStdin};
use std::thread;
use std::time::{Duration, Instant};
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use pmacs::protocol::{
FrontendId, Hello, InstanceCapabilities, InstanceIdentity, PROTOCOL_VERSION,
};
use pmacs::transport::{read_message, write_message};
use super::*;
fn pmacs_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_pmacs"))
}
fn gpu_binary() -> PathBuf {
pmacs_binary()
.parent()
.expect("test binary directory")
.join("pmacs-gpu")
}
fn parse_report(report: &Path) -> HashMap<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 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 {
assert!(
gpu_binary().is_file(),
"build pmacs-gpu before this acceptance suite"
);
let mut child = Command::new(gpu_binary())
.args(["--headless-managed-probe"])
.arg(socket)
.arg(report)
.arg(daemon_executable)
.env("HOME", home)
.env("XDG_CONFIG_HOME", home)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn managed probe");
let stdin = child.stdin.take().expect("probe stdin");
Self {
child,
stdin: Some(stdin),
report: report.to_owned(),
daemon_pid: None,
}
}
fn wait_ready(&mut self) -> HashMap<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();
if let Some(pid) = self.daemon_pid {
signal_pid(pid, Signal::SIGTERM);
}
}
}
#[test]
fn root_broker_forwards_resolved_arguments_and_gpu_outcome() {
let temp = secure_tempdir();
let fake_gpu = temp.path().join("fake-gpu");
let record = temp.path().join("argv");
let socket = temp.path().join("broker.sock");
write_script(
&fake_gpu,
"printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"",
);
let success = Command::new(pmacs_binary())
.args(["--gpu", "--socket"])
.arg(&socket)
.env(TEST_GPU_OVERRIDE, &fake_gpu)
.env("PMACS_TEST_RECORD", &record)
.env("PMACS_TEST_EXIT", "0")
.output()
.expect("run root broker success");
assert!(
success.status.success(),
"{}",
String::from_utf8_lossy(&success.stderr)
);
let argv = fs::read_to_string(&record).expect("read forwarded argv");
let args = argv.lines().collect::<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_on_one_socket_owner() {
let temp = secure_tempdir();
let socket = temp.path().join("race.sock");
let mut first = ManagedProbe::spawn(
&socket,
&temp.path().join("first-report"),
&pmacs_binary(),
temp.path(),
);
let mut second = ManagedProbe::spawn(
&socket,
&temp.path().join("second-report"),
&pmacs_binary(),
temp.path(),
);
let first_facts = first.wait_ready();
let second_facts = second.wait_ready();
assert_eq!(
first_facts.get("buffer_snapshot").map(String::as_str),
Some("true")
);
assert_eq!(
second_facts.get("buffer_snapshot").map(String::as_str),
Some("true")
);
assert!(UnixStream::connect(&socket).is_ok());
let pids = [first.daemon_pid, second.daemon_pid];
assert!(first.close().success());
assert!(second.close().success());
for pid in pids.into_iter().flatten() {
signal_pid(pid, Signal::SIGTERM);
}
}
#[test]
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
let temp = secure_tempdir();
let socket = temp.path().join("signal.sock");
let report = temp.path().join("signal-report");
let wrapper = temp.path().join("headless-gpu-wrapper");
write_script(
&wrapper,
"exec \"$PMACS_REAL_GPU\" --headless-managed-probe \"$2\" \"$PMACS_REPORT\" \"$3\"",
);
let mut command = Command::new(pmacs_binary());
command
.args(["--gpu", "--socket"])
.arg(&socket)
.env(TEST_GPU_OVERRIDE, &wrapper)
.env("PMACS_REAL_GPU", gpu_binary())
.env("PMACS_REPORT", &report)
.env("HOME", temp.path())
.env("XDG_CONFIG_HOME", temp.path())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
command.process_group(0);
let mut launcher = command.spawn().expect("spawn launcher process group");
let facts = wait_for_fact(&report, "phase", "ready", Duration::from_secs(10));
let daemon_pid = facts["daemon_pid"].parse::<u32>().expect("daemon pid");
kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT)
.expect("signal launcher group");
let _ = wait_for_exit(&mut launcher, Duration::from_secs(5));
let mut stream = UnixStream::connect(&socket).expect("daemon survived launcher Ctrl-C");
let hello: Hello = read_message(&mut stream).expect("surviving daemon Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
signal_pid(daemon_pid, Signal::SIGTERM);
}
#[test]
fn capability_and_protocol_mismatches_never_spawn_replacements() {
let temp = secure_tempdir();
let marker = temp.path().join("spawned");
let fake_daemon = temp.path().join("fake-daemon");
write_script(&fake_daemon, "touch \"$PMACS_TEST_MARKER\"");
let capability_socket = temp.path().join("capability.sock");
let mut daemon = spawn_daemon(
&capability_socket,
&[
("PMACS_INSTANCE_CRDT_REPLICA", "0"),
("PMACS_INSTANCE_SEMANTIC_RENDER", "0"),
],
);
let capability_report = temp.path().join("capability-report");
let output = Command::new(gpu_binary())
.args(["--headless-managed-probe"])
.arg(&capability_socket)
.arg(&capability_report)
.arg(&fake_daemon)
.env("PMACS_TEST_MARKER", &marker)
.output()
.expect("run capability mismatch probe");
assert!(!output.status.success());
assert!(
fs::read_to_string(&capability_report)
.unwrap()
.contains("required capabilities")
);
assert!(!marker.exists());
assert!(daemon.try_wait().expect("inspect daemon").is_none());
signal_pid(daemon.id(), Signal::SIGTERM);
let _ = daemon.wait();
let protocol_socket = temp.path().join("protocol.sock");
let listener = UnixListener::bind(&protocol_socket).expect("bind protocol fixture");
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept protocol fixture");
let hello = Hello {
protocol_version: PROTOCOL_VERSION + 100,
assigned_frontend_id: FrontendId::LOCAL,
instance_identity: InstanceIdentity {
pmacs_version: "protocol-fixture".to_owned(),
build_hash: None,
instance_name: None,
uptime_secs: 0,
working_directory: "/tmp".to_owned(),
},
instance_capabilities: InstanceCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
},
};
write_message(&mut stream, &hello).expect("write mismatched Hello");
});
let protocol_report = temp.path().join("protocol-report");
let output = Command::new(gpu_binary())
.args(["--headless-managed-probe"])
.arg(&protocol_socket)
.arg(&protocol_report)
.arg(&fake_daemon)
.env("PMACS_TEST_MARKER", &marker)
.output()
.expect("run protocol mismatch probe");
server.join().expect("protocol fixture");
assert!(!output.status.success());
assert!(
fs::read_to_string(&protocol_report)
.unwrap()
.contains("protocol version")
);
assert!(!marker.exists());
}
#[test]
fn bounded_startup_failure_reports_child_status() {
let temp = secure_tempdir();
let socket = temp.path().join("never.sock");
let report = temp.path().join("failure-report");
let failing_daemon = temp.path().join("failing-daemon");
write_script(&failing_daemon, "exit 17");
let start = Instant::now();
let output = Command::new(gpu_binary())
.args(["--headless-managed-probe"])
.arg(&socket)
.arg(&report)
.arg(&failing_daemon)
.output()
.expect("run bounded failure probe");
assert!(!output.status.success());
assert!(start.elapsed() >= Duration::from_secs(4));
assert!(start.elapsed() < Duration::from_secs(8));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("exit status: 17"),
"unexpected stderr: {stderr}"
);
}
#[test]
fn managed_probe_observes_disconnect_and_reaps_daemon_child() {
let temp = secure_tempdir();
let socket = temp.path().join("reap.sock");
let report = temp.path().join("reap-report");
let mut probe = ManagedProbe::spawn(&socket, &report, &pmacs_binary(), temp.path());
probe.wait_ready();
let daemon_pid = probe.daemon_pid.expect("daemon pid");
signal_pid(daemon_pid, Signal::SIGTERM);
let facts = wait_for_fact(&report, "daemon_reaped", "true", Duration::from_secs(5));
assert!(!facts["disconnect"].is_empty());
assert!(probe.close().success());
let final_facts = parse_report(&report);
assert_eq!(
final_facts.get("phase").map(String::as_str),
Some("complete")
);
assert_eq!(
final_facts.get("daemon_reaped").map(String::as_str),
Some("true")
);
}
#[test]
fn gpu_cli_help_version_and_invalid_argv_are_headless_and_strict() {
let help = Command::new(gpu_binary())
.arg("--help")
.output()
.expect("GPU help");
assert!(help.status.success());
assert!(String::from_utf8_lossy(&help.stdout).contains("--attach <socket>"));
let version = Command::new(gpu_binary())
.arg("--version")
.output()
.expect("GPU version");
assert!(version.status.success());
assert!(String::from_utf8_lossy(&version.stdout).contains("protocol v"));
for argv in [
vec![],
vec!["--attach"],
vec!["--attach", "/tmp/x.sock", "ignored"],
vec!["unexpected"],
] {
let output = Command::new(gpu_binary())
.args(&argv)
.output()
.expect("invalid GPU CLI");
assert_eq!(output.status.code(), Some(2), "accepted argv {argv:?}");
}
}
}