//! 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 { 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 { 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, report: PathBuf, daemon_pid: Option, } 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 { 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::>(); 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::().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 ")); 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:?}"); } } }