ghidra_worker_ctl/
boot.rs1use crate::config::WorkerConfig;
13use crate::conn::WorkerConn;
14use crate::launch::WorkerProcess;
15use crate::state::WorkerLifecycle;
16use crate::token::ConnectionToken;
17use ghidra_ipc::frame::FrameDecoder;
18use ghidra_ipc::rpc::{InitAnnounce, RpcNotification, INIT_METHOD, PROTOCOL_VERSION};
19use std::io;
20use std::time::Duration;
21use thiserror::Error;
22use tokio::io::AsyncReadExt;
23use tokio::net::{TcpListener, TcpStream};
24
25#[derive(Debug, Error)]
26pub enum BootError {
27 #[error("no valid worker handshake before the boot deadline")]
28 Timeout,
29 #[error("worker process exited during boot (code {code:?}) — check the worker log on stderr")]
30 WorkerExited { code: Option<i32> },
31 #[error("worker protocol {observed} incompatible with host {expected}")]
32 Incompatible { expected: u32, observed: u32 },
33 #[error(transparent)]
34 Io(#[from] io::Error),
35}
36
37pub struct HandshakeListener {
39 listener: TcpListener,
40}
41
42impl HandshakeListener {
43 pub async fn bind() -> io::Result<Self> {
44 Ok(Self {
45 listener: TcpListener::bind("127.0.0.1:0").await?,
46 })
47 }
48
49 pub fn port(&self) -> u16 {
50 self.listener.local_addr().map(|a| a.port()).unwrap_or(0)
51 }
52
53 pub async fn await_valid_worker(
56 &self,
57 expected: &ConnectionToken,
58 deadline: Duration,
59 ) -> Result<InitAnnounce, BootError> {
60 let (announce, _stream) = self.accept_valid(expected, deadline).await?;
61 Ok(announce)
62 }
63
64 pub async fn accept_valid(
66 &self,
67 expected: &ConnectionToken,
68 deadline: Duration,
69 ) -> Result<(InitAnnounce, TcpStream), BootError> {
70 const PER_CONN_READ: Duration = Duration::from_secs(5);
75 let overall = tokio::time::sleep(deadline);
76 tokio::pin!(overall);
77 loop {
78 let one_connection = async {
81 let (mut sock, _peer) = self.listener.accept().await?;
82 let ann = tokio::time::timeout(PER_CONN_READ, read_init_frame(&mut sock))
83 .await
84 .map_err(|_| {
85 io::Error::new(io::ErrorKind::TimedOut, "handshake read timed out")
86 })??;
87 Ok::<(InitAnnounce, TcpStream), io::Error>((ann, sock))
88 };
89 tokio::select! {
90 _ = &mut overall => return Err(BootError::Timeout),
91 res = one_connection => {
92 match res {
93 Ok((ann, sock)) => {
94 if !expected.verify(&ann.token) {
95 continue; }
97 if ann.protocol_version != PROTOCOL_VERSION {
98 return Err(BootError::Incompatible {
99 expected: PROTOCOL_VERSION,
100 observed: ann.protocol_version,
101 });
102 }
103 return Ok((ann, sock));
104 }
105 Err(_) => continue,
108 }
109 }
110 }
111 }
112 }
113}
114
115async fn read_init_frame(sock: &mut TcpStream) -> io::Result<InitAnnounce> {
117 let mut dec = FrameDecoder::new();
118 let mut tmp = [0u8; 8192];
119 let body = loop {
120 let n = sock.read(&mut tmp).await?;
121 if n == 0 {
122 return Err(io::Error::new(
123 io::ErrorKind::UnexpectedEof,
124 "closed before init",
125 ));
126 }
127 dec.push(&tmp[..n]);
128 if let Some(b) = dec.next_frame().map_err(io::Error::other)? {
129 break b;
130 }
131 };
132 let note: RpcNotification = serde_json::from_slice(&body).map_err(io::Error::other)?;
133 if note.method != INIT_METHOD {
134 return Err(io::Error::new(
135 io::ErrorKind::InvalidData,
136 format!(
137 "frame 1 method is {:?}, expected {INIT_METHOD}",
138 note.method
139 ),
140 ));
141 }
142 let params = note
143 .params
144 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "init has no params"))?;
145 serde_json::from_value(params).map_err(io::Error::other)
146}
147
148pub struct BootedWorker {
151 pub conn: WorkerConn,
152 pub process: WorkerProcess,
153 pub lifecycle: WorkerLifecycle,
154 pub announce: InitAnnounce,
155}
156
157struct TokenDir(std::path::PathBuf);
162
163impl Drop for TokenDir {
164 fn drop(&mut self) {
165 let _ = std::fs::remove_dir_all(&self.0);
166 }
167}
168
169pub async fn boot_worker(cfg: &WorkerConfig) -> Result<BootedWorker, BootError> {
172 let listener = HandshakeListener::bind().await?;
173 let port = listener.port();
174
175 let token = ConnectionToken::generate();
176 let boot_dir = std::env::temp_dir().join(format!("ghidra-mcp-boot-{}", uuid::Uuid::new_v4()));
177 let token_file = boot_dir.join("worker.token");
178 let _token_dir = TokenDir(boot_dir.clone());
183
184 std::fs::create_dir_all(&boot_dir)?;
185 ghidra_ipc::owner_only::restrict_to_owner(&boot_dir)?;
189 token.write_to_file(&token_file)?;
190 let argv = cfg.build_argv(port, &token_file);
191 let mut process =
192 WorkerProcess::spawn(&cfg.analyze_headless_path(), &argv, cfg.max_heap.as_deref())?;
193
194 let handshake = listener.accept_valid(&token, cfg.boot_timeout);
199 tokio::pin!(handshake);
200 let outcome = loop {
201 tokio::select! {
202 r = &mut handshake => break r,
203 _ = tokio::time::sleep(Duration::from_millis(250)) => {
204 if let Ok(Some(status)) = process.child.try_wait() {
205 break Err(BootError::WorkerExited { code: status.code() });
206 }
207 }
208 }
209 };
210 let (announce, stream) = outcome?;
213
214 let mut lifecycle = WorkerLifecycle::new();
215 lifecycle
216 .on_ready()
217 .expect("fresh lifecycle must accept on_ready");
218
219 Ok(BootedWorker {
220 conn: WorkerConn::new(stream),
221 process,
222 lifecycle,
223 announce,
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use ghidra_ipc::frame::encode_frame;
231 use ghidra_ipc::rpc::{
232 InitAnnounce, JsonRpcVersion, RpcNotification, INIT_METHOD, PROTOCOL_VERSION,
233 };
234 use tokio::io::AsyncWriteExt;
235 use tokio::net::TcpStream;
236
237 #[test]
241 fn booted_worker_is_send() {
242 fn is_send<T: Send>() {}
243 is_send::<BootedWorker>();
244 }
245
246 async fn send_init(port: u16, token: &str, protocol: u32) {
247 let mut s = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
248 let ann = InitAnnounce {
249 protocol_version: protocol,
250 ghidra_version: "12.1.2".to_string(),
251 worker_script_version: "0.0.0".to_string(),
252 token: token.to_string(),
253 };
254 let note = RpcNotification {
255 jsonrpc: JsonRpcVersion,
256 method: INIT_METHOD.to_string(),
257 params: Some(serde_json::to_value(&ann).unwrap()),
258 };
259 let frame = encode_frame(&serde_json::to_vec(¬e).unwrap()).unwrap();
260 s.write_all(&frame).await.unwrap();
261 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
263 }
264
265 #[tokio::test]
266 async fn accepts_valid_token_and_version() {
267 let h = HandshakeListener::bind().await.unwrap();
268 let port = h.port();
269 let token = crate::token::ConnectionToken::generate();
270 let expect = token.as_str().to_string();
271 let joined = tokio::spawn(async move { send_init(port, &expect, PROTOCOL_VERSION).await });
272 let announce = h
273 .await_valid_worker(&token, std::time::Duration::from_secs(3))
274 .await
275 .expect("handshake ok");
276 assert_eq!(announce.protocol_version, PROTOCOL_VERSION);
277 joined.await.unwrap();
278 }
279
280 #[tokio::test]
281 async fn rejects_bad_token_then_times_out() {
282 let h = HandshakeListener::bind().await.unwrap();
283 let port = h.port();
284 let token = crate::token::ConnectionToken::generate();
285 let joined =
286 tokio::spawn(async move { send_init(port, "not-the-token", PROTOCOL_VERSION).await });
287 let res = h
288 .await_valid_worker(&token, std::time::Duration::from_millis(600))
289 .await;
290 assert!(matches!(res, Err(BootError::Timeout)));
291 joined.await.unwrap();
292 }
293
294 #[tokio::test]
295 async fn rejects_version_mismatch() {
296 let h = HandshakeListener::bind().await.unwrap();
297 let port = h.port();
298 let token = crate::token::ConnectionToken::generate();
299 let expect = token.as_str().to_string();
300 let joined =
301 tokio::spawn(async move { send_init(port, &expect, PROTOCOL_VERSION + 1).await });
302 let res = h
303 .await_valid_worker(&token, std::time::Duration::from_millis(600))
304 .await;
305 assert!(matches!(res, Err(BootError::Incompatible { .. })));
306 joined.await.unwrap();
307 }
308}