Skip to main content

trusty_console/detect/
memory.rs

1//! `ServiceConnector` implementation for `trusty-memory`.
2//!
3//! Why: trusty-memory served TCP loopback HTTP and published its bound address
4//! in `~/.trusty-memory/http_addr`; this connector read that file, probed the
5//! port, and fell back to probing 7879 when the file was absent. #6286
6//! (ADR-0032) moved the daemon onto a hardened Unix socket, so all three of
7//! those are gone — there is no port, no discovery file, and nothing to fall
8//! back FROM: the socket path is derived, and the daemon and this connector
9//! resolve it through the same `trusty_common::daemon_socket_path` call.
10//!
11//! The retired fallback probed `127.0.0.1:7879` whenever the file was missing,
12//! which is the same shape of bug #6277 removed from the review connector: any
13//! process that took 7879 read as a healthy trusty-memory. It is deleted rather
14//! than corrected.
15//!
16//! What: `MemoryConnector::detect()` dials `memory.health` over the socket and
17//! reads `version` off the answer.
18//! Test: `memory_connector_reports_available_when_nothing_is_serving`,
19//! `memory_connector_reads_the_version_off_a_live_socket`,
20//! `memory_connector_sends_params_so_a_strict_health_handler_answers`,
21//! `memory_connector_accepts_the_envelope_a_real_daemon_sends`,
22//! `memory_connector_reports_degraded_when_the_daemon_answers_with_an_error`.
23
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
28
29use super::helpers::binary_on_path;
30
31/// How long one health dial may take, end to end.
32///
33/// A local socket answers in single-digit milliseconds; trusty-memory's health
34/// handler probes trusty-search before answering, so this leaves headroom over
35/// that without letting one wedged service stall the console's whole detection
36/// pass.
37const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
38
39/// The method name `trusty-memory`'s router registers for its health check.
40///
41/// Duplicated as a literal rather than imported: `trusty-console` has no Cargo
42/// edge on `trusty-memory` and adding one to share a `&str` would pull an ONNX
43/// embedder and a redb store into the console's build.
44/// `transport::uds::METHOD_HEALTH` is the definition; this is the client's
45/// copy, and the integration test in
46/// `trusty-memory/tests/uds_consumer_contract.rs` is what keeps them equal.
47const METHOD_HEALTH: &str = "memory.health";
48
49/// The `result` half of an `memory.health` response, as far as the console
50/// reads it.
51///
52/// Only `version` is consumed — the card renders it. `status` is deserialised
53/// too so a body that carries neither is refused as not-a-health-envelope
54/// rather than silently rendering a versionless Running card.
55#[derive(Debug, serde::Deserialize)]
56struct HealthEnvelope {
57    /// `"ok"` or `"degraded"`. Presence is what makes this a health answer.
58    #[allow(dead_code)]
59    status: String,
60    /// The daemon's own version, rendered on the service card.
61    version: Option<String>,
62}
63
64/// ServiceConnector for `trusty-memory`.
65///
66/// Why: the console's dashboard needs to know whether the analyzer daemon is
67/// running, and since #6286 that question is answered by dialling its socket.
68/// What: implements `detect()` — binary on PATH, then one `memory.health` call.
69/// Test: see the module docs.
70pub struct MemoryConnector {
71    /// Override for the socket path (used in tests).
72    ///
73    /// Before #6286 this was a HOME override, because the dotfile lived under
74    /// `~`. The socket path comes from the data directory now, which
75    /// `TRUSTY_DATA_DIR_OVERRIDE` already redirects — but that variable is
76    /// process-global and this connector runs beside five others in one poll,
77    /// so a path override keeps a test from redirecting its siblings too.
78    socket: Option<PathBuf>,
79}
80
81impl MemoryConnector {
82    /// Create a new `MemoryConnector`.
83    pub fn new() -> Self {
84        Self { socket: None }
85    }
86
87    /// Create a connector that dials `socket` instead of the resolved path.
88    ///
89    /// Why: unit tests must not dial the real user's running daemon, and the
90    /// integration test needs to point this at a socket it bound itself.
91    /// Test: `memory_connector_reports_available_when_nothing_is_serving`.
92    pub fn with_socket(socket: PathBuf) -> Self {
93        Self {
94            socket: Some(socket),
95        }
96    }
97
98    /// The socket this connector dials, or why it could not be resolved.
99    ///
100    /// Why the error is carried rather than discarded: a data directory that
101    /// cannot be resolved or created is an operator-fixable condition
102    /// (permissions, a `TRUSTY_DATA_DIR_OVERRIDE` pointing somewhere unusable),
103    /// and it is indistinguishable on the dashboard from a daemon that is simply
104    /// not running. `detect()` still reports `Available` — nothing was observed,
105    /// so claiming otherwise would be a guess — but puts the reason in `hint`, so
106    /// the card says what to fix instead of silently under-reporting.
107    fn socket_path(&self) -> Result<PathBuf, String> {
108        match &self.socket {
109            Some(p) => Ok(p.clone()),
110            None => trusty_common::daemon_socket_path("trusty-memory")
111                .map_err(|e| format!("could not resolve the trusty-memory socket path: {e:#}")),
112        }
113    }
114}
115
116impl Default for MemoryConnector {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122/// What one `memory.health` dial observed.
123///
124/// Why (#6356): "the daemon did not answer" and "the daemon answered, but not
125/// with health" are different facts about a machine, and collapsing both into
126/// `None` is what let a live daemon render as "Binary found but daemon is not
127/// running" for as long as the request was malformed. A daemon that answers at
128/// all is running; only the first case is silence.
129/// What: three variants, one per verdict `detect_from` can reach.
130/// Test: `memory_connector_reports_degraded_when_the_daemon_answers_with_an_error`.
131enum ProbeOutcome {
132    /// The daemon answered with a readable health envelope.
133    Healthy(HealthEnvelope),
134    /// The daemon answered, but not with health. Carries the operator-facing
135    /// reason, which becomes the card's hint.
136    Unhealthy(String),
137    /// Nothing answered - no socket, no listener, or the dial timed out.
138    Silent,
139}
140
141/// Dial `memory.health` and report what came back.
142///
143/// Why: `ServiceConnector::detect` is synchronous — the poller calls it inside
144/// `spawn_blocking` — and the shared UDS client is async. The exchange runs on
145/// a dedicated thread with its own current-thread runtime rather than through
146/// `Handle::block_on`, for the reason `trusty-installer`'s
147/// `probe_member_http_blocking` records: building a runtime and blocking on it
148/// from inside another runtime's worker panics, and this way the call is safe
149/// from any caller regardless of what it is running on.
150///
151/// What: one `send_framed_request` bounded by [`HEALTH_TIMEOUT`], then a
152/// JSON-RPC envelope check, mapped onto [`ProbeOutcome`]. A response carrying
153/// an `error` is [`ProbeOutcome::Unhealthy`], not silence - the daemon is
154/// there, and reporting otherwise is what #6356 was.
155///
156/// Test: `memory_connector_reports_available_when_nothing_is_serving`,
157/// `memory_connector_sends_params_so_a_strict_health_handler_answers`,
158/// `memory_connector_reports_degraded_when_the_daemon_answers_with_an_error`.
159fn probe_health(socket: &Path) -> ProbeOutcome {
160    let socket = socket.to_path_buf();
161    let spawned = std::thread::Builder::new()
162        .name("console-memory-probe".to_owned())
163        .spawn(move || {
164            let Ok(rt) = tokio::runtime::Builder::new_current_thread()
165                .enable_all()
166                .build()
167            else {
168                return ProbeOutcome::Silent;
169            };
170            rt.block_on(async {
171                // #6356: `memory.health` binds `HealthQuery`, and
172                // `RpcRouter::typed` decodes an absent `params` as
173                // `Value::Null`, which a derived `Deserialize` refuses however
174                // many of its fields default. The sibling daemons bind
175                // `NoParams`, whose hand-written `Deserialize` accepts null,
176                // which is why only this connector was affected. `{}` takes
177                // every default - the cheap health path, not the embedder
178                // round-trip `probe`/`deep` would ask for.
179                let request = serde_json::json!({
180                    "jsonrpc": "2.0",
181                    "id": 1,
182                    "method": METHOD_HEALTH,
183                    "params": {},
184                });
185                let sent = trusty_common::uds::send_framed_request::<
186                    _,
187                    trusty_common::uds::server::RpcResponse,
188                >(&socket, &request, HEALTH_TIMEOUT)
189                .await;
190                let Ok(response) = sent else {
191                    return ProbeOutcome::Silent;
192                };
193                if let Some(error) = response.error {
194                    return ProbeOutcome::Unhealthy(format!(
195                        "trusty-memory answered {METHOD_HEALTH} with an error (code {}): {}",
196                        error.code, error.message
197                    ));
198                }
199                match response.result.map(serde_json::from_value::<HealthEnvelope>) {
200                    Some(Ok(health)) => ProbeOutcome::Healthy(health),
201                    _ => ProbeOutcome::Unhealthy(format!(
202                        "trusty-memory answered {METHOD_HEALTH} with a body that is not a health envelope"
203                    )),
204                }
205            })
206        });
207    let Ok(handle) = spawned else {
208        return ProbeOutcome::Silent;
209    };
210    handle.join().unwrap_or(ProbeOutcome::Silent)
211}
212
213impl ServiceConnector for MemoryConnector {
214    fn id(&self) -> &'static str {
215        "trusty-memory"
216    }
217
218    fn display_name(&self) -> &'static str {
219        "Trusty Memory"
220    }
221
222    /// Detect trusty-memory status.
223    ///
224    /// Why: the console dashboard needs to know whether the daemon is up, and
225    /// `tctl` makes the same call for a different reason — so the two must
226    /// agree, which they do by dialling the same method on the same derived
227    /// path (#6286).
228    /// What: binary check → `memory.health` over the socket → status. `url` is
229    /// deliberately `None`: a UDS daemon has no URL, and ADR-0032 makes
230    /// trusty-console the only HTTP surface in the workspace, so a synthesised
231    /// `http://` address would be a link that cannot work. A socket path that
232    /// cannot be resolved reports `Available` with the reason in `hint` — see
233    /// [`MemoryConnector::socket_path`].
234    /// Test: `memory_connector_reports_available_when_nothing_is_serving`,
235    /// `memory_connector_reads_the_version_off_a_live_socket`,
236    /// `memory_connector_surfaces_an_unresolvable_socket_path_as_a_hint`,
237    /// `memory_connector_reports_degraded_when_the_daemon_answers_with_an_error`.
238    fn detect(&self) -> ServiceInfo {
239        self.detect_from(self.socket_path())
240    }
241}
242
243impl MemoryConnector {
244    /// [`ServiceConnector::detect`]'s body, over an already-resolved path.
245    ///
246    /// Why: the unresolvable-path arm is only reachable when
247    /// `trusty_common::daemon_socket_path` fails, and the only way to make it
248    /// fail from a test is to set `TRUSTY_DATA_DIR_OVERRIDE` — which is
249    /// process-global and, in this crate's test binary, is read by five sibling
250    /// connectors running in parallel. Taking the resolved result as a parameter
251    /// makes the arm assertable with no global state at all.
252    /// What: binary check, then the four verdicts.
253    /// Test: `memory_connector_surfaces_an_unresolvable_socket_path_as_a_hint`,
254    /// `memory_connector_reports_degraded_when_the_daemon_answers_with_an_error`.
255    fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
256        let base =
257            |status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
258                id: self.id().to_string(),
259                display_name: self.display_name().to_string(),
260                status,
261                version,
262                url: None,
263                hint,
264                // #6416: trusty-memory is a resident daemon; `Available` means stopped.
265                lifecycle: ServiceLifecycle::Daemon,
266            };
267
268        if !binary_on_path("trusty-memory") {
269            return base(ServiceStatus::Absent, None, None);
270        }
271
272        let socket = match socket {
273            Ok(p) => p,
274            Err(reason) => return base(ServiceStatus::Available, None, Some(reason)),
275        };
276
277        match probe_health(&socket) {
278            ProbeOutcome::Healthy(health) => base(ServiceStatus::Running, health.version, None),
279            // #6356: a daemon that answers is running, so the row says so
280            // rather than repeating the "not running" line an operator has
281            // already disproved by seeing the socket. `Degraded` is the row
282            // model's existing "reachable, but not answering as expected"
283            // state, and the card renders its hint verbatim.
284            ProbeOutcome::Unhealthy(reason) => base(ServiceStatus::Degraded, None, Some(reason)),
285            ProbeOutcome::Silent => base(ServiceStatus::Available, None, None),
286        }
287    }
288}
289
290// ─── tests ────────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// Bind `socket` and answer exactly one frame with whatever `reply` makes
297    /// of the request it received.
298    ///
299    /// Why: three tests need a `memory.health` responder and differ only in
300    /// what it answers - one accepts anything, one mirrors trusty-memory's
301    /// `HealthQuery` decode, one refuses everything. Sharing the
302    /// accept-read-write half leaves that difference as the only thing each
303    /// test states.
304    /// What: binds a hardened socket, reads the request frame to EOF (the
305    /// client half-closes its write side after sending), and writes
306    /// `reply(frame)` followed by the newline the framing terminates on.
307    /// Test: used by the three socket-backed tests below.
308    fn spawn_health_socket(
309        socket: &Path,
310        reply: impl FnOnce(serde_json::Value) -> String + Send + 'static,
311    ) {
312        let listener = trusty_common::uds::bind_hardened(socket).expect("bind");
313        tokio::spawn(async move {
314            use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
315            let Ok((mut conn, _)) = listener.accept().await else {
316                return;
317            };
318            let mut raw = Vec::new();
319            let _ = conn.read_to_end(&mut raw).await;
320            let frame = serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
321            let _ = conn.write_all(reply(frame).as_bytes()).await;
322            let _ = conn.write_all(b"\n").await;
323            let _ = conn.flush().await;
324        });
325    }
326
327    /// Why (#6286): the pre-migration connector read a dotfile nothing rewrites
328    /// any more, so any process holding the port it named made this report a
329    /// trusty-memory that was not there. The file read is gone, and this is what
330    /// keeps it gone: an absent socket is `Available`, never `Running`, whatever
331    /// else is listening on the machine.
332    /// What: points the connector at a path in an empty temp dir and asserts the
333    /// verdict, branching only on whether the binary is installed.
334    /// Test: this is the test.
335    #[test]
336    fn memory_connector_reports_available_when_nothing_is_serving() {
337        let tmp = tempfile::TempDir::new().expect("tempdir");
338        let connector = MemoryConnector::with_socket(tmp.path().join("absent.sock"));
339        let info = connector.detect();
340
341        let expected = if which::which("trusty-memory").is_ok() {
342            ServiceStatus::Available
343        } else {
344            ServiceStatus::Absent
345        };
346        assert_eq!(info.status, expected);
347        assert_eq!(info.id, "trusty-memory");
348        assert_eq!(info.display_name, "Trusty Memory");
349        assert!(info.url.is_none(), "a UDS daemon has no URL to render");
350        assert!(
351            info.status != ServiceStatus::Absent || info.version.is_none(),
352            "Absent must have no version"
353        );
354    }
355
356    /// Why (#6286): a data directory that cannot be resolved is operator-fixable
357    /// — a permissions problem, or a `TRUSTY_DATA_DIR_OVERRIDE` pointing
358    /// somewhere unusable — but on the dashboard it looks identical to a daemon
359    /// that is merely stopped. Reporting the reason turns a silent under-report
360    /// into something actionable, without upgrading the verdict.
361    /// What: a resolution failure reports `Available` carrying the reason.
362    /// Test: this is the test.
363    #[test]
364    fn memory_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
365        if which::which("trusty-memory").is_err() {
366            eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
367            return;
368        }
369
370        let info = MemoryConnector::new().detect_from(Err(
371            "could not resolve the trusty-memory socket path: nope".to_string(),
372        ));
373
374        assert_eq!(
375            info.status,
376            ServiceStatus::Available,
377            "nothing was observed, so the verdict must not claim more than that"
378        );
379        let hint = info.hint.expect("an unresolvable path must explain itself");
380        assert!(
381            hint.contains("socket path"),
382            "the hint must name what could not be resolved: {hint}"
383        );
384    }
385
386    /// Why: the hint is for the failure case only. A connector that attached one
387    /// to a healthy or merely-stopped daemon would put a permanent "something is
388    /// wrong" note on a card where nothing is.
389    /// Test: this is the test.
390    #[test]
391    fn memory_connector_attaches_no_hint_when_the_path_resolves() {
392        let tmp = tempfile::TempDir::new().expect("tempdir");
393        let info = MemoryConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
394        assert!(
395            info.hint.is_none(),
396            "a resolvable path must not carry a remediation hint: {:?}",
397            info.hint
398        );
399    }
400
401    /// Why: `Running` is the verdict that has to be earned by an ANSWER, and the
402    /// version it carries is what the card renders. A connector that reported
403    /// Running off a bare connect would have no version to show and would call a
404    /// wedged daemon healthy.
405    /// What: binds a socket that answers one `memory.health` frame with a real
406    /// envelope, and asserts the connector reads the version off it.
407    /// Test: this is the test.
408    #[tokio::test(flavor = "multi_thread")]
409    async fn memory_connector_reads_the_version_off_a_live_socket() {
410        if which::which("trusty-memory").is_err() {
411            eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
412            return;
413        }
414
415        let tmp = tempfile::TempDir::new().expect("tempdir");
416        let socket = tmp.path().join("sockets").join("memory.sock");
417        spawn_health_socket(&socket, |_frame| {
418            r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9","search_reachable":true}}"#.to_string()
419        });
420
421        let connector = MemoryConnector::with_socket(socket);
422        let info = tokio::task::spawn_blocking(move || connector.detect())
423            .await
424            .expect("detect");
425
426        assert_eq!(info.status, ServiceStatus::Running);
427        assert_eq!(info.version.as_deref(), Some("9.9.9"));
428    }
429
430    /// Why (#6356): the probe sent no `params` at all, and trusty-memory 0.25.2
431    /// answers `-32602` - "params do not decode: invalid type: null, expected
432    /// struct HealthQuery" - instead of health, so a running daemon rendered as
433    /// "Available - Binary found but daemon is not running". This is the test
434    /// that fails without the `"params": {}` the request now carries.
435    /// What: the responder mirrors the daemon's own decode - an object `params`
436    /// is accepted, anything else is refused exactly as the real handler
437    /// refuses it - and the connector must come back Running with the version.
438    /// Test: this is the test.
439    #[tokio::test(flavor = "multi_thread")]
440    async fn memory_connector_sends_params_so_a_strict_health_handler_answers() {
441        if which::which("trusty-memory").is_err() {
442            eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
443            return;
444        }
445
446        let tmp = tempfile::TempDir::new().expect("tempdir");
447        let socket = tmp.path().join("sockets").join("memory.sock");
448        spawn_health_socket(&socket, |frame| {
449            match frame.get("params") {
450                Some(serde_json::Value::Object(_)) => r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"0.25.2"}}"#.to_string(),
451                _ => r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"params do not decode: invalid type: null, expected struct HealthQuery"}}"#.to_string(),
452            }
453        });
454
455        let connector = MemoryConnector::with_socket(socket);
456        let info = tokio::task::spawn_blocking(move || connector.detect())
457            .await
458            .expect("detect");
459
460        assert_eq!(
461            info.status,
462            ServiceStatus::Running,
463            "a daemon that answers health must read as Running, not {:?} (hint: {:?})",
464            info.status,
465            info.hint
466        );
467        assert_eq!(info.version.as_deref(), Some("0.25.2"));
468    }
469
470    /// Why (#6356): the two earlier tests reply with a hand-written envelope
471    /// carrying exactly the two fields `HealthEnvelope` names, so neither one
472    /// can catch the connector refusing what the daemon actually sends. This
473    /// replies with a frame captured verbatim from trusty-memory 0.25.2 over
474    /// its live socket — eight extra fields, including a nested `worker`
475    /// object — because the failure mode that recurred on 2026-08-28 was a
476    /// running daemon reading as stopped, and a `HealthEnvelope` that grew a
477    /// required field would reproduce it exactly.
478    /// What: the real answer, unedited, must read as Running with its version.
479    /// Test: this is the test.
480    #[tokio::test(flavor = "multi_thread")]
481    async fn memory_connector_accepts_the_envelope_a_real_daemon_sends() {
482        if which::which("trusty-memory").is_err() {
483            eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
484            return;
485        }
486
487        let tmp = tempfile::TempDir::new().expect("tempdir");
488        let socket = tmp.path().join("sockets").join("memory.sock");
489        spawn_health_socket(&socket, |_frame| {
490            r#"{"jsonrpc":"2.0","id":1,"result":{"cpu_pct":2.501335620880127,"daemon_state":"ready","disk_bytes":0,"fd_soft_limit":8192,"open_fds":22,"rss_mb":5151,"socket":"/Users/x/Library/Application Support/trusty-memory/trusty-memory.sock","status":"ok","uptime_secs":12124,"version":"0.25.2","worker":{"in_flight":0,"wedged":false}}}"#
491                .to_string()
492        });
493
494        let connector = MemoryConnector::with_socket(socket);
495        let info = tokio::task::spawn_blocking(move || connector.detect())
496            .await
497            .expect("detect");
498
499        assert_eq!(
500            info.status,
501            ServiceStatus::Running,
502            "a real daemon's own health frame must read as Running (hint: {:?})",
503            info.hint
504        );
505        assert_eq!(info.version.as_deref(), Some("0.25.2"));
506    }
507
508    /// Why (#6356): telling "answered wrongly" apart from "did not answer" must
509    /// not turn the probe fail-open. An error answer is still not health, so
510    /// the row must never claim `Running` or invent a version - it reports the
511    /// daemon as reachable-but-wrong and hands the operator the error verbatim.
512    /// What: a responder that refuses every call, whatever it is sent.
513    /// Test: this is the test.
514    #[tokio::test(flavor = "multi_thread")]
515    async fn memory_connector_reports_degraded_when_the_daemon_answers_with_an_error() {
516        if which::which("trusty-memory").is_err() {
517            eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
518            return;
519        }
520
521        let tmp = tempfile::TempDir::new().expect("tempdir");
522        let socket = tmp.path().join("sockets").join("memory.sock");
523        spawn_health_socket(&socket, |_frame| {
524            r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#
525                .to_string()
526        });
527
528        let connector = MemoryConnector::with_socket(socket);
529        let info = tokio::task::spawn_blocking(move || connector.detect())
530            .await
531            .expect("detect");
532
533        assert_ne!(
534            info.status,
535            ServiceStatus::Running,
536            "an error answer is not health, however reachable the daemon is"
537        );
538        assert_eq!(
539            info.status,
540            ServiceStatus::Degraded,
541            "a daemon that answers is running, so the row must not read Available"
542        );
543        assert!(
544            info.version.is_none(),
545            "there was no health envelope to read a version off: {:?}",
546            info.version
547        );
548        let hint = info.hint.expect("an error answer must explain itself");
549        assert!(
550            hint.contains("-32601") && hint.contains("method not found"),
551            "the hint must carry the daemon's own error verbatim: {hint}"
552        );
553    }
554}