ghidra_mcp/state.rs
1//! Server state: the single worker held behind an async mutex, its active attachment + address
2//! canonicalizer, the poison set, and a permit semaphore. Lazy single-flight boot (spec §5); strict
3//! inner<outer timeouts (spec §4.7); transparent crash respawn (spec §4.3) lives in `execute.rs`.
4
5use crate::config::ServerConfig;
6use crate::poison::{PoisonSet, DEFAULT_POISON_CAPACITY};
7use ghidra_ipc::address::AddressCanonicalizer;
8use ghidra_ipc::error::{ErrorCode, ErrorEnvelope};
9use ghidra_worker_ctl::boot::BootedWorker;
10use std::path::PathBuf;
11use std::sync::Mutex as StdMutex;
12use tokio::sync::{Mutex, Semaphore};
13
14/// The worker script version this binary requires; must equal `GhidraMcpWorker.java`'s
15/// `WORKER_SCRIPT_VERSION` (bumped to "0.7.0" for M2d: the `set_local` write method + the structured
16/// `locals[]` array on `inspect_function`). Bump BOTH in lockstep whenever the worker's method surface
17/// changes. Exact-match (not semver-range) is sufficient because the worker is embedded + hash-extracted,
18/// so host and worker are always the same build in practice (spec §5).
19pub const EXPECTED_WORKER_VERSION: &str = "0.7.0";
20
21/// Max concurrent in-flight+queued tool calls before `SERVER_BUSY` (spec §5 / §9.4).
22pub const SERVER_PERMITS: usize = 4;
23
24/// A booted, attached worker plus the derived per-program address canonicalizer.
25pub struct WorkerHolder {
26 pub booted: BootedWorker,
27 /// The last program that a request SUCCESSFULLY attached (spec §4.3 state-commit ordering). On
28 /// boot this is the bootstrap program (learned from `worker_info`).
29 pub active_program_path: Option<String>,
30 /// Canonicalizer for the active program's address geometry (rebuilt on each successful attach).
31 pub canon: AddressCanonicalizer,
32}
33
34// There is exactly ONE WorkerSlot instance (behind `ServerState.worker: Mutex<WorkerSlot>`), so the
35// large-variant padding is ~360 bytes ONCE. Boxing `Ready` (clippy's suggestion) would add a heap
36// indirection on the hot path (every RPC touches `Ready(h)`) for no real memory win — allow instead.
37#[allow(clippy::large_enum_variant)]
38pub enum WorkerSlot {
39 Empty,
40 Booting,
41 Ready(WorkerHolder),
42 /// A deterministic, non-retryable failure (version-skew). Every tool returns this without a reboot.
43 Fatal(ErrorEnvelope),
44}
45
46pub struct ServerState {
47 /// The resolved configuration, or the failure that resolving it produced.
48 ///
49 /// Held as a `Result` rather than a `ServerConfig` so that `serve` can start, handshake and list
50 /// its tools on a machine with no Ghidra install. Resolution still happens ONCE, at construction —
51 /// the inputs are CLI flags and environment variables, immutable for the process's lifetime — so
52 /// what is deferred is the FAILURE, not the work. Every call therefore reports the same error, and
53 /// no lock or interior mutability is needed to read it on the hot path.
54 ///
55 /// Private on purpose: reach it through [`ServerState::cfg`], which is the `?`-able accessor that
56 /// makes the deferred failure impossible to ignore at a call site.
57 cfg: Result<ServerConfig, ErrorEnvelope>,
58 pub script_dir: PathBuf,
59 pub worker: Mutex<WorkerSlot>,
60 pub poison: StdMutex<PoisonSet>,
61 pub sem: Semaphore,
62 /// Signalled by `spawn_boot` when a background boot finishes (success OR failure), so a caller
63 /// waiting out `warming_deadline` (execute.rs §4.7) wakes promptly instead of polling blind.
64 pub boot_done: tokio::sync::Notify,
65 /// Count of worker boots attempted (test-only single-flight assertion; spec §8).
66 pub boot_count: std::sync::atomic::AtomicUsize,
67}
68
69impl ServerState {
70 /// Build from an already-resolved configuration.
71 pub fn new(cfg: ServerConfig, script_dir: PathBuf) -> Self {
72 Self::build(Ok(cfg), script_dir)
73 }
74
75 /// Build from unresolved inputs, deferring any resolution failure to the first tool call.
76 ///
77 /// This is what `serve` uses. A configuration that cannot resolve is no longer fatal at startup:
78 /// the server comes up, `tools/list` answers normally, and the failure is reported by
79 /// [`ServerState::cfg`] when something actually needs the worker.
80 pub fn from_raw(raw: crate::config::RawConfig, script_dir: PathBuf) -> Self {
81 Self::build(raw.resolve().map_err(config_unusable), script_dir)
82 }
83
84 fn build(cfg: Result<ServerConfig, ErrorEnvelope>, script_dir: PathBuf) -> Self {
85 Self {
86 cfg,
87 script_dir,
88 worker: Mutex::new(WorkerSlot::Empty),
89 poison: StdMutex::new(PoisonSet::new(DEFAULT_POISON_CAPACITY)),
90 sem: Semaphore::new(SERVER_PERMITS),
91 boot_done: tokio::sync::Notify::new(),
92 boot_count: std::sync::atomic::AtomicUsize::new(0),
93 }
94 }
95
96 /// The resolved configuration, or the deferred configuration failure.
97 ///
98 /// Returns an owned error so callers can `?` it out of a `Result<_, ErrorEnvelope>` without
99 /// fighting the borrow of `self`.
100 pub fn cfg(&self) -> Result<&ServerConfig, ErrorEnvelope> {
101 self.cfg.as_ref().map_err(|e| e.clone())
102 }
103
104 /// Whether a worker could be booted at all. `false` means there is no usable configuration, so
105 /// anything that would launch or talk to a worker must decline instead of trying.
106 pub fn is_configured(&self) -> bool {
107 self.cfg.is_ok()
108 }
109
110 /// Number of worker boots attempted (test-only single-flight assertion; spec §8).
111 pub fn boot_count(&self) -> usize {
112 self.boot_count.load(std::sync::atomic::Ordering::SeqCst)
113 }
114
115 /// Boot a worker, verify version, call worker_info to learn the bootstrap program + geometry, and
116 /// build the canonicalizer.
117 pub(crate) async fn boot_and_probe(&self) -> Result<WorkerHolder, ErrorEnvelope> {
118 self.boot_count
119 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
120 use ghidra_ipc::protocol::AttachResult;
121 // Extract the worker script into the per-version dir (idempotent by hash).
122 crate::worker_asset::extract_worker(&self.script_dir)
123 .map_err(|e| boot_env(format!("extract worker script: {e}")))?;
124 let wcfg = self.cfg()?.worker_config(self.script_dir.clone());
125 let mut booted = ghidra_worker_ctl::boot::boot_worker(&wcfg)
126 .await
127 .map_err(|e| boot_env(format!("boot worker: {e}")))?;
128 // Version-skew is deterministic → the caller makes it Fatal.
129 check_worker_version(&booted.announce.worker_script_version)?;
130 // Learn the bootstrap program + geometry (spec §6/§7), with a deadline so a worker that
131 // handshakes then hangs before answering worker_info doesn't wedge the slot forever (R4 seat-M).
132 let info = tokio::time::timeout(
133 self.cfg()?.rpc_deadline,
134 booted
135 .conn
136 .request(&mk("worker_info", serde_json::json!({}))),
137 )
138 .await
139 .map_err(|_| {
140 boot_env("worker booted but did not answer worker_info before the deadline".to_string())
141 })?
142 .map_err(|e| boot_env(format!("worker_info: {e}")))?;
143 let result = expect_success(info)?;
144 let attach: AttachResult = serde_json::from_value(result)
145 .map_err(|e| boot_env(format!("worker_info shape: {e}")))?;
146 let canon = AddressCanonicalizer::new(attach.default_space.clone(), attach.pointer_size);
147 Ok(WorkerHolder {
148 booted,
149 active_program_path: attach.attached,
150 canon,
151 })
152 }
153}
154
155impl ServerState {
156 /// Spawn the single-flight background boot. Caller must have set the slot to `Booting` while
157 /// holding the lock (so exactly one boot runs). On completion the task stores Ready/Fatal, or
158 /// resets to Empty on a transient failure (retryable).
159 pub fn spawn_boot(self: std::sync::Arc<Self>) {
160 tokio::spawn(async move {
161 let outcome = self.boot_and_probe().await;
162 {
163 let mut slot = self.worker.lock().await;
164 if matches!(&*slot, WorkerSlot::Booting) {
165 match outcome {
166 Ok(holder) => *slot = WorkerSlot::Ready(holder),
167 Err(e) if e.error.code == ErrorCode::WorkerIncompatible => {
168 *slot = WorkerSlot::Fatal(e)
169 }
170 Err(_transient) => *slot = WorkerSlot::Empty,
171 }
172 }
173 }
174 self.boot_done.notify_waiters();
175 });
176 }
177
178 /// Start the single-flight warmup boot: if the slot is `Empty`, mark it `Booting` under the lock and
179 /// spawn the background boot; a slot already `Booting`/`Ready`/`Fatal` is left alone so exactly one
180 /// boot ever runs. This is the ONLY correct way to kick a warmup — calling `spawn_boot` directly on an
181 /// `Empty` slot leaves it `Empty` (spawn_boot publishes only when it finds `Booting`), so a racing
182 /// `wait_until_ready` would launch a SECOND concurrent boot (two JVMs → project `LockException`).
183 pub async fn start_warmup(self: &std::sync::Arc<Self>) {
184 // Nothing to boot WITH. Left unguarded, the boot would fail instantly, `spawn_boot` would
185 // treat that as transient and reset the slot to `Empty`, and the next tool call's
186 // `wait_until_ready` would kick the same doomed boot again in a tight loop until
187 // `warming_deadline` expired — answering WORKER_WARMING, which says nothing about the real
188 // problem. `execute.rs` short-circuits ahead of that loop for the same reason.
189 if !self.is_configured() {
190 return;
191 }
192 let mut slot = self.worker.lock().await;
193 if matches!(&*slot, WorkerSlot::Empty) {
194 *slot = WorkerSlot::Booting;
195 drop(slot);
196 std::sync::Arc::clone(self).spawn_boot();
197 }
198 }
199}
200
201/// Pure: decide whether an announced worker version is compatible. Skew → a sticky `WORKER_INCOMPATIBLE`
202/// envelope (spec §5).
203pub fn check_worker_version(announced: &str) -> Result<(), ErrorEnvelope> {
204 if announced == EXPECTED_WORKER_VERSION {
205 Ok(())
206 } else {
207 Err(ErrorEnvelope::new(
208 ErrorCode::WorkerIncompatible,
209 format!("worker script version {announced} != required {EXPECTED_WORKER_VERSION}"),
210 ))
211 }
212}
213
214/// A configuration that could not be resolved, as the envelope every tool call will return.
215///
216/// Deterministic and non-retryable: nothing the server does will fix it, so the message is the
217/// resolver's own, which names the missing field and the flag/variable that supplies it.
218///
219/// The code is picked per variant rather than fixed. `suggested_action` is serialized into the tool
220/// result the user reads (`ghidra-ipc/src/error.rs`), and `GhidraNotFound` carries "point
221/// GHIDRA_INSTALL_DIR at your Ghidra install root" — a wrong instruction for someone whose install is
222/// fine and whose project settings are not. The action is overridden too, because every one of these
223/// is a settings problem and none of the code-level hints says so.
224fn config_unusable(e: crate::config::ConfigError) -> ErrorEnvelope {
225 use crate::config::ConfigError;
226 let code = match &e {
227 // `bootstrap_program` belongs here and NOT under `ProgramNotFound`: that code already has one
228 // producer with a precise meaning — the worker's `PROGRAM_NOT_FOUND` wire code
229 // (`ghidra-ipc/src/error_map.rs`), pinned by `tests/crucible.rs` to "the program_path you
230 // passed is not in the project VFS". A second, unrelated producer would leave a client unable
231 // to tell a bad tool argument from an unconfigured server. Which program to bootstrap is part
232 // of the project's configuration, so it reports as a project problem.
233 ConfigError::Missing {
234 field: "project_dir" | "project_name" | "bootstrap_program",
235 ..
236 }
237 | ConfigError::NoProjectDir(_) => ErrorCode::ProjectNotFound,
238 ConfigError::Missing { .. } | ConfigError::NotGhidra(_) => ErrorCode::GhidraNotFound,
239 };
240 ErrorEnvelope::new(code, e.to_string()).with_action(
241 "set the value named in the message — as a CLI flag, an environment variable, \
242 or in this agent's settings file — then restart the server",
243 )
244}
245
246/// A structured boot/transport failure envelope (retryable).
247fn boot_env(msg: impl Into<String>) -> ErrorEnvelope {
248 ErrorEnvelope::new(ErrorCode::WorkerUnavailable, msg.into())
249}
250
251/// Re-attach `last_good` on a freshly-booted holder if it differs from the boot's initial attachment
252/// (spec §4.3). Best-effort: a re-attach failure is swallowed (the fresh worker is still usable at its
253/// bootstrap program; the caller's retry will surface any real problem).
254pub async fn boot_reattach(fresh: &mut WorkerHolder, last_good: Option<String>) {
255 use ghidra_ipc::protocol::AttachResult;
256 let Some(path) = last_good else { return };
257 if fresh.active_program_path.as_deref() == Some(path.as_str()) {
258 return;
259 }
260 let resp = fresh
261 .booted
262 .conn
263 .request(&mk(
264 "attach_program",
265 serde_json::json!({ "program_path": path }),
266 ))
267 .await;
268 if let Ok(r) = resp {
269 if let Ok(v) = expect_success(r) {
270 if let Ok(a) = serde_json::from_value::<AttachResult>(v) {
271 fresh.active_program_path = a.attached;
272 fresh.canon = AddressCanonicalizer::new(a.default_space, a.pointer_size);
273 }
274 }
275 }
276}
277
278/// Build an RpcRequest with a fixed id=1 (single in-flight request per conn — the worker services one
279/// at a time; the id is validated by `WorkerConn` per §4.4).
280pub fn mk(method: &str, params: serde_json::Value) -> ghidra_ipc::rpc::RpcRequest {
281 use ghidra_ipc::rpc::{JsonRpcVersion, RpcId, RpcRequest};
282 RpcRequest {
283 jsonrpc: JsonRpcVersion,
284 id: RpcId::Number(1),
285 method: method.to_string(),
286 params: Some(params),
287 }
288}
289
290/// Turn an `RpcResponse` into its `result` Value, or map a worker `Failure` into a structured envelope.
291pub fn expect_success(
292 resp: ghidra_ipc::rpc::RpcResponse,
293) -> Result<serde_json::Value, ErrorEnvelope> {
294 use ghidra_ipc::error_map::map_worker_error;
295 use ghidra_ipc::rpc::RpcResponse;
296 match resp {
297 RpcResponse::Success { result, .. } => Ok(result),
298 RpcResponse::Failure { error, .. } => {
299 let code = error
300 .get("code")
301 .and_then(|c| c.as_str())
302 .unwrap_or("WORKER_UNAVAILABLE");
303 let message = error
304 .get("message")
305 .and_then(|m| m.as_str())
306 .unwrap_or("worker error");
307 let mut env = map_worker_error(code, message);
308 // Preserve any candidates the worker attached (AMBIGUOUS_SYMBOL — spec §6) into details.
309 if let Some(c) = error.get("candidates") {
310 env = env.with_details(serde_json::json!({ "candidates": c }));
311 }
312 // Generic details object (RENAME_CONFLICT, INVALID_PARAMS.reason — spec §4). Mutually exclusive
313 // with the candidates channel above; prefer the explicit object when the worker sent one.
314 if let Some(d) = error.get("details") {
315 env = env.with_details(d.clone());
316 }
317 Err(env)
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn matching_version_is_compatible() {
328 assert!(check_worker_version(EXPECTED_WORKER_VERSION).is_ok());
329 }
330
331 #[test]
332 fn expected_worker_version_is_0_7_0() {
333 assert_eq!(EXPECTED_WORKER_VERSION, "0.7.0");
334 }
335
336 #[test]
337 fn skewed_version_is_fatal_incompatible() {
338 let err = check_worker_version("9.9.9").unwrap_err();
339 assert_eq!(err.error.code, ErrorCode::WorkerIncompatible);
340 assert!(err.error.message.contains("9.9.9"));
341 }
342
343 #[test]
344 fn expect_success_lifts_worker_details_object() {
345 let resp = ghidra_ipc::rpc::RpcResponse::Failure {
346 jsonrpc: ghidra_ipc::rpc::JsonRpcVersion,
347 id: ghidra_ipc::rpc::RpcId::Number(1),
348 error: serde_json::json!({
349 "code": "RENAME_CONFLICT", "message": "current name is 'compute', not 'x'",
350 "details": { "address": "ram:00401000", "expected": "x", "actual": "compute" }
351 }),
352 };
353 let env = expect_success(resp).unwrap_err();
354 assert_eq!(env.error.code, ghidra_ipc::error::ErrorCode::RenameConflict);
355 assert_eq!(env.error.details.unwrap()["actual"], "compute");
356 }
357}