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:
parent
821835b8c5
commit
6fd583417b
|
|
@ -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,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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
212
src/main.rs
212
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.exists() {
|
||||
return (sibling.clone(), sibling);
|
||||
}
|
||||
(PathBuf::from("pmacs-gpu"), sibling)
|
||||
}
|
||||
|
||||
fn run_gpu(socket: Option<&str>) -> ExitCode {
|
||||
if !cfg!(feature = "crdt") {
|
||||
eprintln!("pmacs: --gpu requires pmacs built with --features crdt");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let socket_path = pmacs::socket_path::resolve_socket_path(socket);
|
||||
let current_exe = match std::env::current_exe() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
eprintln!("pmacs: cannot locate the running pmacs executable: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let (gpu, sibling) = gpu_binary(
|
||||
¤t_exe,
|
||||
std::env::var_os(PMACS_TEST_GPU_BIN).map(PathBuf::from),
|
||||
);
|
||||
let status = Command::new(&gpu)
|
||||
.arg("--managed-attach")
|
||||
.arg(&socket_path)
|
||||
.arg(¤t_exe)
|
||||
.status();
|
||||
match status {
|
||||
Ok(status) if status.success() => ExitCode::SUCCESS,
|
||||
Ok(status) => {
|
||||
eprintln!("pmacs: GPU frontend {} exited with {status}", gpu.display());
|
||||
status
|
||||
.code()
|
||||
.and_then(|code| u8::try_from(code).ok())
|
||||
.map_or(ExitCode::FAILURE, ExitCode::from)
|
||||
}
|
||||
Err(error) => {
|
||||
if gpu == Path::new("pmacs-gpu") {
|
||||
eprintln!(
|
||||
"pmacs: could not launch GPU frontend: sibling {} is absent and PATH lookup \
|
||||
for pmacs-gpu failed: {error}",
|
||||
sibling.display()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"pmacs: could not launch GPU frontend {}: {error}",
|
||||
gpu.display()
|
||||
);
|
||||
}
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue