Skip to main content

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}