Skip to main content

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}