716 lines
26 KiB
Rust
716 lines
26 KiB
Rust
//! End-to-end acceptance for one-command GPU invocation and managed daemon lifecycle.
|
|
|
|
#![cfg(unix)]
|
|
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
const TEST_GPU_OVERRIDE: &str = "PMACS_TEST_GPU_BIN";
|
|
|
|
fn secure_tempdir() -> TempDir {
|
|
let temp = tempfile::tempdir().expect("tempdir");
|
|
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
|
.expect("chmod tempdir 0700");
|
|
temp
|
|
}
|
|
|
|
fn write_script(path: &Path, body: &str) {
|
|
fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).expect("write script");
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod script");
|
|
}
|
|
|
|
#[cfg(not(feature = "crdt"))]
|
|
#[test]
|
|
fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() {
|
|
let temp = secure_tempdir();
|
|
let runtime = temp.path().join("runtime");
|
|
fs::create_dir(&runtime).expect("create runtime");
|
|
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700)).expect("chmod runtime 0700");
|
|
let fake_gpu = temp.path().join("fake-gpu");
|
|
let marker = temp.path().join("spawned");
|
|
write_script(&fake_gpu, "touch \"$PMACS_TEST_MARKER\"");
|
|
|
|
let output = Command::new(env!("CARGO_BIN_EXE_pmacs"))
|
|
.arg("--gpu")
|
|
.env(TEST_GPU_OVERRIDE, &fake_gpu)
|
|
.env("PMACS_TEST_MARKER", &marker)
|
|
.env("XDG_RUNTIME_DIR", &runtime)
|
|
.output()
|
|
.expect("run non-CRDT pmacs --gpu");
|
|
assert!(!output.status.success());
|
|
assert!(!marker.exists(), "GPU executable must not be spawned");
|
|
assert!(
|
|
!runtime.join("pmacs/default.sock").exists(),
|
|
"the CRDT gate must run before default-socket creation"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("--features crdt"),
|
|
"unexpected stderr: {stderr}"
|
|
);
|
|
|
|
let occupied_socket = temp.path().join("occupied.sock");
|
|
let listener =
|
|
std::os::unix::net::UnixListener::bind(&occupied_socket).expect("bind occupied socket");
|
|
let occupied = Command::new(env!("CARGO_BIN_EXE_pmacs"))
|
|
.args(["--gpu", "--socket"])
|
|
.arg(&occupied_socket)
|
|
.env(TEST_GPU_OVERRIDE, &fake_gpu)
|
|
.env("PMACS_TEST_MARKER", &marker)
|
|
.output()
|
|
.expect("run non-CRDT pmacs --gpu against occupied socket");
|
|
assert!(!occupied.status.success());
|
|
assert!(
|
|
!marker.exists(),
|
|
"live socket must not weaken the CRDT gate"
|
|
);
|
|
assert!(
|
|
occupied_socket.exists(),
|
|
"live socket must remain untouched"
|
|
);
|
|
drop(listener);
|
|
}
|
|
|
|
#[cfg(feature = "crdt")]
|
|
mod crdt {
|
|
use std::collections::HashMap;
|
|
use std::os::unix::net::{UnixListener, UnixStream};
|
|
use std::os::unix::process::CommandExt;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::process::{Child, ChildStdin};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use nix::sys::signal::{Signal, kill};
|
|
use nix::unistd::Pid;
|
|
use pmacs::cell::CellSize;
|
|
use pmacs::protocol::{
|
|
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello,
|
|
InstanceCapabilities, InstanceIdentity, InstanceMessage, PROTOCOL_VERSION,
|
|
};
|
|
use pmacs::transport::{read_message, write_message};
|
|
|
|
use super::*;
|
|
|
|
fn pmacs_binary() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_BIN_EXE_pmacs"))
|
|
}
|
|
|
|
fn gpu_binary() -> PathBuf {
|
|
pmacs_binary()
|
|
.parent()
|
|
.expect("test binary directory")
|
|
.join("pmacs-gpu")
|
|
}
|
|
|
|
fn parse_report(report: &Path) -> HashMap<String, String> {
|
|
fs::read_to_string(report)
|
|
.expect("read probe report")
|
|
.lines()
|
|
.filter_map(|line| line.split_once('='))
|
|
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
fn wait_for_fact(
|
|
report: &Path,
|
|
key: &str,
|
|
expected: &str,
|
|
timeout: Duration,
|
|
) -> HashMap<String, String> {
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if report.exists() {
|
|
let facts = parse_report(report);
|
|
if facts.get(key).is_some_and(|value| value == expected) {
|
|
return facts;
|
|
}
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
panic!(
|
|
"report {} did not reach {key}={expected}: {}",
|
|
report.display(),
|
|
fs::read_to_string(report).unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
fn signal_pid(pid: u32, signal: Signal) {
|
|
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
|
|
}
|
|
|
|
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
if let Some(status) = child.try_wait().expect("inspect child") {
|
|
return status;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"child did not exit within {timeout:?}"
|
|
);
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
fn wait_for_daemon(socket: &Path, child: &mut Child) {
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while Instant::now() < deadline {
|
|
if let Ok(mut stream) = UnixStream::connect(socket) {
|
|
let _: Hello = read_message(&mut stream).expect("read daemon Hello");
|
|
return;
|
|
}
|
|
if let Some(status) = child.try_wait().expect("inspect daemon") {
|
|
panic!("daemon exited before listening: {status}");
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
panic!("daemon did not listen on {}", socket.display());
|
|
}
|
|
|
|
fn attach_surviving_frontend(socket: &Path) -> (FrontendId, UnixStream) {
|
|
let mut stream = UnixStream::connect(socket).expect("connect surviving frontend");
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.expect("set surviving frontend timeout");
|
|
let hello: Hello = read_message(&mut stream).expect("surviving frontend Hello");
|
|
write_message(
|
|
&mut stream,
|
|
&AttachRequest {
|
|
protocol_version: PROTOCOL_VERSION,
|
|
frontend_capabilities: FrontendCapabilities {
|
|
multi_frontend: true,
|
|
crdt_replica: true,
|
|
..FrontendCapabilities::default()
|
|
},
|
|
initial_size: CellSize::new(24, 80),
|
|
},
|
|
)
|
|
.expect("attach surviving frontend");
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut saw_snapshot = false;
|
|
let mut saw_full_grid = false;
|
|
while !(saw_snapshot && saw_full_grid) {
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"surviving frontend did not initialize"
|
|
);
|
|
match read_message::<InstanceMessage>(&mut stream).expect("initialize survivor") {
|
|
InstanceMessage::BufferSnapshot { .. } => saw_snapshot = true,
|
|
InstanceMessage::CellDelta {
|
|
full_grid: true, ..
|
|
} => saw_full_grid = true,
|
|
_ => {}
|
|
}
|
|
}
|
|
(hello.assigned_frontend_id, stream)
|
|
}
|
|
|
|
fn spawn_daemon(socket: &Path, envs: &[(&str, &str)]) -> Child {
|
|
let home = socket.parent().expect("socket parent");
|
|
let mut command = Command::new(pmacs_binary());
|
|
command
|
|
.args(["--daemon", "--socket"])
|
|
.arg(socket)
|
|
.env("HOME", home)
|
|
.env("XDG_CONFIG_HOME", home)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
for (key, value) in envs {
|
|
command.env(key, value);
|
|
}
|
|
let mut child = command.spawn().expect("spawn daemon");
|
|
wait_for_daemon(socket, &mut child);
|
|
child
|
|
}
|
|
|
|
struct ManagedProbe {
|
|
child: Child,
|
|
stdin: Option<ChildStdin>,
|
|
report: PathBuf,
|
|
daemon_pid: Option<u32>,
|
|
}
|
|
|
|
impl ManagedProbe {
|
|
fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self {
|
|
Self::spawn_with_env(socket, report, daemon_executable, home, &[])
|
|
}
|
|
|
|
fn spawn_with_env(
|
|
socket: &Path,
|
|
report: &Path,
|
|
daemon_executable: &Path,
|
|
home: &Path,
|
|
envs: &[(&str, &Path)],
|
|
) -> Self {
|
|
assert!(
|
|
gpu_binary().is_file(),
|
|
"build pmacs-gpu before this acceptance suite"
|
|
);
|
|
let mut command = Command::new(gpu_binary());
|
|
command
|
|
.args(["--headless-managed-probe"])
|
|
.arg(socket)
|
|
.arg(report)
|
|
.arg(daemon_executable)
|
|
.env("HOME", home)
|
|
.env("XDG_CONFIG_HOME", home)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
for (key, value) in envs {
|
|
command.env(key, value);
|
|
}
|
|
let mut child = command.spawn().expect("spawn managed probe");
|
|
let stdin = child.stdin.take().expect("probe stdin");
|
|
Self {
|
|
child,
|
|
stdin: Some(stdin),
|
|
report: report.to_owned(),
|
|
daemon_pid: None,
|
|
}
|
|
}
|
|
|
|
fn wait_ready(&mut self) -> HashMap<String, String> {
|
|
let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10));
|
|
if facts
|
|
.get("spawned_daemon")
|
|
.is_some_and(|value| value == "true")
|
|
{
|
|
self.daemon_pid = facts.get("daemon_pid").and_then(|value| value.parse().ok());
|
|
}
|
|
facts
|
|
}
|
|
|
|
fn close(mut self) -> std::process::ExitStatus {
|
|
self.stdin.take();
|
|
wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5));
|
|
wait_for_exit(&mut self.child, Duration::from_secs(5))
|
|
}
|
|
}
|
|
|
|
impl Drop for ManagedProbe {
|
|
fn drop(&mut self) {
|
|
self.stdin.take();
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
let daemon_reaped = fs::read_to_string(&self.report)
|
|
.ok()
|
|
.is_some_and(|report| report.lines().any(|line| line == "daemon_reaped=true"));
|
|
if !daemon_reaped && let Some(pid) = self.daemon_pid {
|
|
signal_pid(pid, Signal::SIGTERM);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn root_broker_forwards_resolved_arguments_and_gpu_outcome() {
|
|
let temp = secure_tempdir();
|
|
let fake_gpu = temp.path().join("fake-gpu");
|
|
let record = temp.path().join("argv");
|
|
let socket = temp.path().join("broker.sock");
|
|
write_script(
|
|
&fake_gpu,
|
|
"printf '%s\\n' \"$@\" > \"$PMACS_TEST_RECORD\"\nexit \"$PMACS_TEST_EXIT\"",
|
|
);
|
|
|
|
let success = Command::new(pmacs_binary())
|
|
.args(["--gpu", "--socket"])
|
|
.arg(&socket)
|
|
.env(TEST_GPU_OVERRIDE, &fake_gpu)
|
|
.env("PMACS_TEST_RECORD", &record)
|
|
.env("PMACS_TEST_EXIT", "0")
|
|
.output()
|
|
.expect("run root broker success");
|
|
assert!(
|
|
success.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&success.stderr)
|
|
);
|
|
let argv = fs::read_to_string(&record).expect("read forwarded argv");
|
|
let args = argv.lines().collect::<Vec<_>>();
|
|
assert_eq!(args[0], "--managed-attach");
|
|
assert_eq!(Path::new(args[1]), socket);
|
|
assert_eq!(Path::new(args[2]), pmacs_binary());
|
|
|
|
let failure = Command::new(pmacs_binary())
|
|
.arg("--gpu")
|
|
.env(TEST_GPU_OVERRIDE, &fake_gpu)
|
|
.env("PMACS_TEST_RECORD", &record)
|
|
.env("PMACS_TEST_EXIT", "23")
|
|
.output()
|
|
.expect("run root broker failure");
|
|
assert_eq!(failure.status.code(), Some(23));
|
|
|
|
let missing = temp.path().join("missing-gpu");
|
|
let spawn_failure = Command::new(pmacs_binary())
|
|
.arg("--gpu")
|
|
.env(TEST_GPU_OVERRIDE, &missing)
|
|
.output()
|
|
.expect("run root broker spawn failure");
|
|
assert!(!spawn_failure.status.success());
|
|
assert!(
|
|
String::from_utf8_lossy(&spawn_failure.stderr).contains(&*missing.to_string_lossy())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn managed_attach_reuses_a_capable_daemon_without_spawning() {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("existing.sock");
|
|
let report = temp.path().join("report");
|
|
let marker = temp.path().join("spawned");
|
|
let fake_daemon = temp.path().join("fake-daemon");
|
|
write_script(&fake_daemon, "touch \"$PMACS_TEST_MARKER\"");
|
|
let mut daemon = spawn_daemon(&socket, &[]);
|
|
|
|
let mut probe = ManagedProbe::spawn(&socket, &report, &fake_daemon, temp.path());
|
|
let facts = probe.wait_ready();
|
|
assert_eq!(
|
|
facts.get("spawned_daemon").map(String::as_str),
|
|
Some("false")
|
|
);
|
|
assert!(!marker.exists());
|
|
assert!(probe.close().success());
|
|
signal_pid(daemon.id(), Signal::SIGTERM);
|
|
assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_and_stale_sockets_start_real_daemons() {
|
|
for stale in [false, true] {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("managed.sock");
|
|
if stale {
|
|
let listener = UnixListener::bind(&socket).expect("bind stale socket");
|
|
drop(listener);
|
|
assert!(socket.exists());
|
|
}
|
|
let report = temp.path().join("report");
|
|
let mut probe = ManagedProbe::spawn(&socket, &report, &pmacs_binary(), temp.path());
|
|
let facts = probe.wait_ready();
|
|
assert_eq!(
|
|
facts.get("spawned_daemon").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
assert!(UnixStream::connect(&socket).is_ok());
|
|
let pid = probe.daemon_pid.expect("spawned daemon pid");
|
|
assert!(probe.close().success());
|
|
signal_pid(pid, Signal::SIGTERM);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_managed_launches_converge_and_reap_the_lock_loser() {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("race.sock");
|
|
let first_ready = temp.path().join("first-ready");
|
|
let second_ready = temp.path().join("second-ready");
|
|
let first_wrapper = temp.path().join("first-daemon");
|
|
let second_wrapper = temp.path().join("second-daemon");
|
|
let wrapper = "touch \"$PMACS_BARRIER_SELF\"\n\
|
|
while [ ! -e \"$PMACS_BARRIER_PEER\" ]; do sleep 0.01; done\n\
|
|
exec \"$PMACS_REAL_DAEMON\" \"$@\"";
|
|
write_script(&first_wrapper, wrapper);
|
|
write_script(&second_wrapper, wrapper);
|
|
let real_daemon = pmacs_binary();
|
|
|
|
let mut first = ManagedProbe::spawn_with_env(
|
|
&socket,
|
|
&temp.path().join("first-report"),
|
|
&first_wrapper,
|
|
temp.path(),
|
|
&[
|
|
("PMACS_BARRIER_SELF", &first_ready),
|
|
("PMACS_BARRIER_PEER", &second_ready),
|
|
("PMACS_REAL_DAEMON", &real_daemon),
|
|
],
|
|
);
|
|
let mut second = ManagedProbe::spawn_with_env(
|
|
&socket,
|
|
&temp.path().join("second-report"),
|
|
&second_wrapper,
|
|
temp.path(),
|
|
&[
|
|
("PMACS_BARRIER_SELF", &second_ready),
|
|
("PMACS_BARRIER_PEER", &first_ready),
|
|
("PMACS_REAL_DAEMON", &real_daemon),
|
|
],
|
|
);
|
|
let first_facts = first.wait_ready();
|
|
let second_facts = second.wait_ready();
|
|
assert_eq!(
|
|
first_facts.get("spawned_daemon").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
assert_eq!(
|
|
second_facts.get("spawned_daemon").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
assert!(UnixStream::connect(&socket).is_ok());
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let first_lost = loop {
|
|
let first_reaped = parse_report(&first.report)
|
|
.get("daemon_reaped")
|
|
.is_some_and(|value| value == "true");
|
|
let second_reaped = parse_report(&second.report)
|
|
.get("daemon_reaped")
|
|
.is_some_and(|value| value == "true");
|
|
if first_reaped ^ second_reaped {
|
|
break first_reaped;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"exactly one losing daemon child was not reaped"
|
|
);
|
|
thread::sleep(Duration::from_millis(20));
|
|
};
|
|
|
|
if first_lost {
|
|
assert!(first.close().success());
|
|
assert!(second.close().success());
|
|
} else {
|
|
assert!(second.close().success());
|
|
assert!(first.close().success());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("signal.sock");
|
|
let report = temp.path().join("signal-report");
|
|
let wrapper = temp.path().join("headless-gpu-wrapper");
|
|
write_script(
|
|
&wrapper,
|
|
"exec \"$PMACS_REAL_GPU\" --headless-managed-probe \"$2\" \"$PMACS_REPORT\" \"$3\"",
|
|
);
|
|
|
|
let mut command = Command::new(pmacs_binary());
|
|
command
|
|
.args(["--gpu", "--socket"])
|
|
.arg(&socket)
|
|
.env(TEST_GPU_OVERRIDE, &wrapper)
|
|
.env("PMACS_REAL_GPU", gpu_binary())
|
|
.env("PMACS_REPORT", &report)
|
|
.env("HOME", temp.path())
|
|
.env("XDG_CONFIG_HOME", temp.path())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
command.process_group(0);
|
|
let mut launcher = command.spawn().expect("spawn launcher process group");
|
|
let facts = wait_for_fact(&report, "phase", "ready", Duration::from_secs(10));
|
|
let daemon_pid = facts["daemon_pid"].parse::<u32>().expect("daemon pid");
|
|
let (survivor_id, mut survivor) = attach_surviving_frontend(&socket);
|
|
|
|
kill(Pid::from_raw(-launcher.id().cast_signed()), Signal::SIGINT)
|
|
.expect("signal launcher group");
|
|
let _ = wait_for_exit(&mut launcher, Duration::from_secs(5));
|
|
|
|
write_message(
|
|
&mut survivor,
|
|
&FrontendEvent::Resize {
|
|
frontend_id: survivor_id,
|
|
size: CellSize::new(31, 91),
|
|
},
|
|
)
|
|
.expect("resize surviving frontend after launcher Ctrl-C");
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"pre-signal frontend did not render after launcher Ctrl-C"
|
|
);
|
|
if matches!(
|
|
read_message::<InstanceMessage>(&mut survivor)
|
|
.expect("read surviving frontend after launcher Ctrl-C"),
|
|
InstanceMessage::CellDelta {
|
|
full_grid: true,
|
|
..
|
|
}
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
signal_pid(daemon_pid, Signal::SIGTERM);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_and_protocol_mismatches_never_spawn_replacements() {
|
|
let temp = secure_tempdir();
|
|
let marker = temp.path().join("spawned");
|
|
let fake_daemon = temp.path().join("fake-daemon");
|
|
write_script(&fake_daemon, "touch \"$PMACS_TEST_MARKER\"");
|
|
|
|
let capability_socket = temp.path().join("capability.sock");
|
|
let mut daemon = spawn_daemon(
|
|
&capability_socket,
|
|
&[
|
|
("PMACS_INSTANCE_CRDT_REPLICA", "0"),
|
|
("PMACS_INSTANCE_SEMANTIC_RENDER", "0"),
|
|
],
|
|
);
|
|
let capability_report = temp.path().join("capability-report");
|
|
let output = Command::new(gpu_binary())
|
|
.args(["--headless-managed-probe"])
|
|
.arg(&capability_socket)
|
|
.arg(&capability_report)
|
|
.arg(&fake_daemon)
|
|
.env("PMACS_TEST_MARKER", &marker)
|
|
.output()
|
|
.expect("run capability mismatch probe");
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
fs::read_to_string(&capability_report)
|
|
.unwrap()
|
|
.contains("required capabilities")
|
|
);
|
|
assert!(!marker.exists());
|
|
assert!(daemon.try_wait().expect("inspect daemon").is_none());
|
|
signal_pid(daemon.id(), Signal::SIGTERM);
|
|
let _ = daemon.wait();
|
|
|
|
let protocol_socket = temp.path().join("protocol.sock");
|
|
let listener = UnixListener::bind(&protocol_socket).expect("bind protocol fixture");
|
|
let server = thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().expect("accept protocol fixture");
|
|
let hello = Hello {
|
|
protocol_version: PROTOCOL_VERSION + 100,
|
|
assigned_frontend_id: FrontendId::LOCAL,
|
|
instance_identity: InstanceIdentity {
|
|
pmacs_version: "protocol-fixture".to_owned(),
|
|
build_hash: None,
|
|
instance_name: None,
|
|
uptime_secs: 0,
|
|
working_directory: "/tmp".to_owned(),
|
|
},
|
|
instance_capabilities: InstanceCapabilities {
|
|
multi_frontend: true,
|
|
crdt_replica: true,
|
|
semantic_render: true,
|
|
},
|
|
};
|
|
write_message(&mut stream, &hello).expect("write mismatched Hello");
|
|
});
|
|
let protocol_report = temp.path().join("protocol-report");
|
|
let output = Command::new(gpu_binary())
|
|
.args(["--headless-managed-probe"])
|
|
.arg(&protocol_socket)
|
|
.arg(&protocol_report)
|
|
.arg(&fake_daemon)
|
|
.env("PMACS_TEST_MARKER", &marker)
|
|
.output()
|
|
.expect("run protocol mismatch probe");
|
|
server.join().expect("protocol fixture");
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
fs::read_to_string(&protocol_report)
|
|
.unwrap()
|
|
.contains("protocol version")
|
|
);
|
|
assert!(!marker.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn bounded_startup_failure_reports_child_status() {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("never.sock");
|
|
let report = temp.path().join("failure-report");
|
|
let failing_daemon = temp.path().join("failing-daemon");
|
|
write_script(&failing_daemon, "exit 17");
|
|
let start = Instant::now();
|
|
let output = Command::new(gpu_binary())
|
|
.args(["--headless-managed-probe"])
|
|
.arg(&socket)
|
|
.arg(&report)
|
|
.arg(&failing_daemon)
|
|
.output()
|
|
.expect("run bounded failure probe");
|
|
assert!(!output.status.success());
|
|
assert!(start.elapsed() >= Duration::from_secs(4));
|
|
assert!(start.elapsed() < Duration::from_secs(8));
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("exit status: 17"),
|
|
"unexpected stderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn managed_probe_observes_disconnect_and_reaps_daemon_child() {
|
|
let temp = secure_tempdir();
|
|
let socket = temp.path().join("reap.sock");
|
|
let report = temp.path().join("reap-report");
|
|
let mut probe = ManagedProbe::spawn(&socket, &report, &pmacs_binary(), temp.path());
|
|
probe.wait_ready();
|
|
let daemon_pid = probe.daemon_pid.expect("daemon pid");
|
|
signal_pid(daemon_pid, Signal::SIGTERM);
|
|
let facts = wait_for_fact(&report, "daemon_reaped", "true", Duration::from_secs(5));
|
|
assert!(!facts["disconnect"].is_empty());
|
|
assert!(probe.close().success());
|
|
let final_facts = parse_report(&report);
|
|
assert_eq!(
|
|
final_facts.get("phase").map(String::as_str),
|
|
Some("complete")
|
|
);
|
|
assert_eq!(
|
|
final_facts.get("daemon_reaped").map(String::as_str),
|
|
Some("true")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_cli_help_version_and_invalid_argv_are_headless_and_strict() {
|
|
let help = Command::new(gpu_binary())
|
|
.arg("--help")
|
|
.output()
|
|
.expect("GPU help");
|
|
assert!(help.status.success());
|
|
let help_text = String::from_utf8_lossy(&help.stdout);
|
|
assert!(help_text.contains("pmacs --gpu"));
|
|
assert!(help_text.contains("ADVANCED DIRECT ATTACH"));
|
|
|
|
let version = Command::new(gpu_binary())
|
|
.arg("--version")
|
|
.output()
|
|
.expect("GPU version");
|
|
assert!(version.status.success());
|
|
assert!(String::from_utf8_lossy(&version.stdout).contains("protocol v"));
|
|
|
|
let bare = Command::new(gpu_binary())
|
|
.output()
|
|
.expect("bare GPU invocation");
|
|
assert_eq!(bare.status.code(), Some(2));
|
|
assert!(String::from_utf8_lossy(&bare.stderr).contains("pmacs --gpu"));
|
|
|
|
let help_extra = Command::new(gpu_binary())
|
|
.args(["--help", "extra"])
|
|
.output()
|
|
.expect("GPU help with extra operand");
|
|
assert_eq!(help_extra.status.code(), Some(2));
|
|
assert!(String::from_utf8_lossy(&help_extra.stderr).contains("does not accept operands"));
|
|
|
|
for argv in [
|
|
vec!["--attach"],
|
|
vec!["--attach", "/tmp/x.sock", "ignored"],
|
|
vec!["--attach", "--help"],
|
|
vec!["unexpected"],
|
|
] {
|
|
let output = Command::new(gpu_binary())
|
|
.args(&argv)
|
|
.output()
|
|
.expect("invalid GPU CLI");
|
|
assert_eq!(output.status.code(), Some(2), "accepted argv {argv:?}");
|
|
}
|
|
}
|
|
}
|