ghidra_worker_ctl/launch.rs
1//! Spawn the headless launcher and shield host stdout (spike D4). Both child pipes are drained on
2//! dedicated threads into the host's STDERR so ~16 KB of Ghidra log spam per run never contaminates
3//! host stdout (reserved for MCP JSON-RPC, M1). The child is wrapped in a kill-guard (Windows Job
4//! Object or Unix process group) so the whole host->shell->java.exe tree dies with the host.
5//! On targets that are neither (e.g. WASM) the guard is a no-op and that guarantee does not hold:
6//! only the direct child is killed when `WorkerProcess` is dropped.
7
8use crate::job_object::JobObject;
9use std::io::{self, BufRead, BufReader};
10use std::path::Path;
11use std::process::{Child, Command, Stdio};
12use std::thread::JoinHandle;
13
14/// A spawned worker process: the child, its kill-guard, and the two pipe-drain threads. Drop
15/// order (struct fields drop top-to-bottom) closes the guard last, but the kill mechanism
16/// (KILL_ON_JOB_CLOSE or SIGKILL to process group) plus the child's own exit make teardown robust.
17pub struct WorkerProcess {
18 pub child: Child,
19 _guard: JobObject,
20 _drains: Vec<JoinHandle<()>>,
21}
22
23impl WorkerProcess {
24 /// Spawn `program args…`, assign to a fresh kill-guard (Job Object on Windows, process group
25 /// on Unix), and drain stdout+stderr to host stderr.
26 /// `max_heap` (e.g. "2G") is forwarded to Ghidra via the MAXMEM env var if set.
27 pub fn spawn(program: &Path, args: &[String], max_heap: Option<&str>) -> io::Result<Self> {
28 let mut cmd = Command::new(program);
29 cmd.args(args)
30 .stdin(Stdio::null())
31 .stdout(Stdio::piped())
32 .stderr(Stdio::piped());
33 if let Some(heap) = max_heap {
34 // Ghidra's launcher honors MAXMEM (e.g. "2G") for the JVM -Xmx.
35 cmd.env("MAXMEM", heap);
36 }
37
38 // Create the kill-guard BEFORE spawning: if guard creation fails we never launch a JVM we
39 // couldn't reap. And if `assign` fails AFTER spawn, kill the child explicitly — dropping a
40 // std::process::Child does NOT kill it, so a bare `?` there would orphan the Ghidra JVM
41 // permanently (agy panel R1).
42 #[cfg(windows)]
43 let guard = JobObject::new()?;
44
45 #[cfg(unix)]
46 let mut guard = JobObject::new()?;
47
48 // Neither Windows nor Unix (e.g. WASM): proc-guard's JobObject is a no-op whose
49 // methods take `&self`, so this binding must not be `mut` or `-D warnings` fails.
50 #[cfg(not(any(windows, unix)))]
51 let guard = JobObject::new()?;
52
53 // On Unix, configure the command to create a new process group before spawning.
54 #[cfg(unix)]
55 guard.configure_command(&mut cmd);
56
57 let mut child = cmd.spawn()?;
58
59 // On Windows, assign the child to the Job Object after spawning.
60 #[cfg(windows)]
61 if let Err(e) = guard.assign(&child) {
62 let _ = child.kill();
63 return Err(e);
64 }
65
66 // On Unix, record the child PID for process group cleanup on drop.
67 #[cfg(unix)]
68 guard.assign(&child);
69
70 // On other targets the guard cannot track the child; `assign` always returns Ok.
71 #[cfg(not(any(windows, unix)))]
72 let _ = guard.assign(&child);
73
74 let mut drains = Vec::new();
75 if let Some(out) = child.stdout.take() {
76 drains.push(spawn_drain("worker/stdout", out));
77 }
78 if let Some(err) = child.stderr.take() {
79 drains.push(spawn_drain("worker/stderr", err));
80 }
81
82 Ok(WorkerProcess {
83 child,
84 _guard: guard,
85 _drains: drains,
86 })
87 }
88}
89
90fn spawn_drain<R: io::Read + Send + 'static>(tag: &'static str, r: R) -> JoinHandle<()> {
91 std::thread::spawn(move || {
92 let reader = BufReader::new(r);
93 for line in reader.lines() {
94 match line {
95 Ok(l) => eprintln!("[{tag}] {l}"),
96 Err(_) => break,
97 }
98 }
99 })
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use std::path::PathBuf;
106 use std::time::Duration;
107
108 #[cfg(windows)]
109 fn echo_cmd() -> (PathBuf, Vec<String>) {
110 (
111 PathBuf::from("cmd.exe"),
112 vec!["/c".into(), "echo hello-from-child".into()],
113 )
114 }
115 #[cfg(not(windows))]
116 fn echo_cmd() -> (PathBuf, Vec<String>) {
117 (PathBuf::from("echo"), vec!["hello-from-child".into()])
118 }
119
120 // Smoke test ONLY: proves spawn + job-assign + drain-thread wiring returns Ok and tears down
121 // cleanly. It deliberately does NOT assert stdout-shielding — that invariant's real oracles are
122 // spike D4 (measured host stdout = 0 bytes) and the e2e test (Task 11). Named to avoid the
123 // false confidence of implying it verifies the shield (agy panel R3).
124 #[test]
125 fn spawn_and_drain_wire_up_without_error() {
126 let (prog, args) = echo_cmd();
127 let wp = WorkerProcess::spawn(&prog, &args, None).expect("spawn");
128 // Give the drain threads a moment; the child exits on its own.
129 std::thread::sleep(Duration::from_millis(300));
130 drop(wp);
131 }
132}