use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::connector::{ServiceConnector, ServiceInfo, ServiceLifecycle, ServiceStatus};
use super::helpers::binary_on_path;
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
const METHOD_HEALTH: &str = "memory.health";
#[derive(Debug, serde::Deserialize)]
struct HealthEnvelope {
#[allow(dead_code)]
status: String,
version: Option<String>,
}
pub struct MemoryConnector {
socket: Option<PathBuf>,
}
impl MemoryConnector {
pub fn new() -> Self {
Self { socket: None }
}
pub fn with_socket(socket: PathBuf) -> Self {
Self {
socket: Some(socket),
}
}
fn socket_path(&self) -> Result<PathBuf, String> {
match &self.socket {
Some(p) => Ok(p.clone()),
None => trusty_common::daemon_socket_path("trusty-memory")
.map_err(|e| format!("could not resolve the trusty-memory socket path: {e:#}")),
}
}
}
impl Default for MemoryConnector {
fn default() -> Self {
Self::new()
}
}
enum ProbeOutcome {
Healthy(HealthEnvelope),
Unhealthy(String),
Silent,
}
fn probe_health(socket: &Path) -> ProbeOutcome {
let socket = socket.to_path_buf();
let spawned = std::thread::Builder::new()
.name("console-memory-probe".to_owned())
.spawn(move || {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return ProbeOutcome::Silent;
};
rt.block_on(async {
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": METHOD_HEALTH,
"params": {},
});
let sent = trusty_common::uds::send_framed_request::<
_,
trusty_common::uds::server::RpcResponse,
>(&socket, &request, HEALTH_TIMEOUT)
.await;
let Ok(response) = sent else {
return ProbeOutcome::Silent;
};
if let Some(error) = response.error {
return ProbeOutcome::Unhealthy(format!(
"trusty-memory answered {METHOD_HEALTH} with an error (code {}): {}",
error.code, error.message
));
}
match response.result.map(serde_json::from_value::<HealthEnvelope>) {
Some(Ok(health)) => ProbeOutcome::Healthy(health),
_ => ProbeOutcome::Unhealthy(format!(
"trusty-memory answered {METHOD_HEALTH} with a body that is not a health envelope"
)),
}
})
});
let Ok(handle) = spawned else {
return ProbeOutcome::Silent;
};
handle.join().unwrap_or(ProbeOutcome::Silent)
}
impl ServiceConnector for MemoryConnector {
fn id(&self) -> &'static str {
"trusty-memory"
}
fn display_name(&self) -> &'static str {
"Trusty Memory"
}
fn detect(&self) -> ServiceInfo {
self.detect_from(self.socket_path())
}
}
impl MemoryConnector {
fn detect_from(&self, socket: Result<PathBuf, String>) -> ServiceInfo {
let base =
|status: ServiceStatus, version: Option<String>, hint: Option<String>| ServiceInfo {
id: self.id().to_string(),
display_name: self.display_name().to_string(),
status,
version,
url: None,
hint,
lifecycle: ServiceLifecycle::Daemon,
};
if !binary_on_path("trusty-memory") {
return base(ServiceStatus::Absent, None, None);
}
let socket = match socket {
Ok(p) => p,
Err(reason) => return base(ServiceStatus::Available, None, Some(reason)),
};
match probe_health(&socket) {
ProbeOutcome::Healthy(health) => base(ServiceStatus::Running, health.version, None),
ProbeOutcome::Unhealthy(reason) => base(ServiceStatus::Degraded, None, Some(reason)),
ProbeOutcome::Silent => base(ServiceStatus::Available, None, None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spawn_health_socket(
socket: &Path,
reply: impl FnOnce(serde_json::Value) -> String + Send + 'static,
) {
let listener = trusty_common::uds::bind_hardened(socket).expect("bind");
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let Ok((mut conn, _)) = listener.accept().await else {
return;
};
let mut raw = Vec::new();
let _ = conn.read_to_end(&mut raw).await;
let frame = serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null);
let _ = conn.write_all(reply(frame).as_bytes()).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
});
}
#[test]
fn memory_connector_reports_available_when_nothing_is_serving() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let connector = MemoryConnector::with_socket(tmp.path().join("absent.sock"));
let info = connector.detect();
let expected = if which::which("trusty-memory").is_ok() {
ServiceStatus::Available
} else {
ServiceStatus::Absent
};
assert_eq!(info.status, expected);
assert_eq!(info.id, "trusty-memory");
assert_eq!(info.display_name, "Trusty Memory");
assert!(info.url.is_none(), "a UDS daemon has no URL to render");
assert!(
info.status != ServiceStatus::Absent || info.version.is_none(),
"Absent must have no version"
);
}
#[test]
fn memory_connector_surfaces_an_unresolvable_socket_path_as_a_hint() {
if which::which("trusty-memory").is_err() {
eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
return;
}
let info = MemoryConnector::new().detect_from(Err(
"could not resolve the trusty-memory socket path: nope".to_string(),
));
assert_eq!(
info.status,
ServiceStatus::Available,
"nothing was observed, so the verdict must not claim more than that"
);
let hint = info.hint.expect("an unresolvable path must explain itself");
assert!(
hint.contains("socket path"),
"the hint must name what could not be resolved: {hint}"
);
}
#[test]
fn memory_connector_attaches_no_hint_when_the_path_resolves() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let info = MemoryConnector::new().detect_from(Ok(tmp.path().join("absent.sock")));
assert!(
info.hint.is_none(),
"a resolvable path must not carry a remediation hint: {:?}",
info.hint
);
}
#[tokio::test(flavor = "multi_thread")]
async fn memory_connector_reads_the_version_off_a_live_socket() {
if which::which("trusty-memory").is_err() {
eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("memory.sock");
spawn_health_socket(&socket, |_frame| {
r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9","search_reachable":true}}"#.to_string()
});
let connector = MemoryConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_eq!(info.status, ServiceStatus::Running);
assert_eq!(info.version.as_deref(), Some("9.9.9"));
}
#[tokio::test(flavor = "multi_thread")]
async fn memory_connector_sends_params_so_a_strict_health_handler_answers() {
if which::which("trusty-memory").is_err() {
eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("memory.sock");
spawn_health_socket(&socket, |frame| {
match frame.get("params") {
Some(serde_json::Value::Object(_)) => r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"0.25.2"}}"#.to_string(),
_ => r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"params do not decode: invalid type: null, expected struct HealthQuery"}}"#.to_string(),
}
});
let connector = MemoryConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_eq!(
info.status,
ServiceStatus::Running,
"a daemon that answers health must read as Running, not {:?} (hint: {:?})",
info.status,
info.hint
);
assert_eq!(info.version.as_deref(), Some("0.25.2"));
}
#[tokio::test(flavor = "multi_thread")]
async fn memory_connector_accepts_the_envelope_a_real_daemon_sends() {
if which::which("trusty-memory").is_err() {
eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("memory.sock");
spawn_health_socket(&socket, |_frame| {
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}}}"#
.to_string()
});
let connector = MemoryConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_eq!(
info.status,
ServiceStatus::Running,
"a real daemon's own health frame must read as Running (hint: {:?})",
info.hint
);
assert_eq!(info.version.as_deref(), Some("0.25.2"));
}
#[tokio::test(flavor = "multi_thread")]
async fn memory_connector_reports_degraded_when_the_daemon_answers_with_an_error() {
if which::which("trusty-memory").is_err() {
eprintln!("skip: trusty-memory is not on PATH, so detect() short-circuits to Absent");
return;
}
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = tmp.path().join("sockets").join("memory.sock");
spawn_health_socket(&socket, |_frame| {
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#
.to_string()
});
let connector = MemoryConnector::with_socket(socket);
let info = tokio::task::spawn_blocking(move || connector.detect())
.await
.expect("detect");
assert_ne!(
info.status,
ServiceStatus::Running,
"an error answer is not health, however reachable the daemon is"
);
assert_eq!(
info.status,
ServiceStatus::Degraded,
"a daemon that answers is running, so the row must not read Available"
);
assert!(
info.version.is_none(),
"there was no health envelope to read a version off: {:?}",
info.version
);
let hint = info.hint.expect("an error answer must explain itself");
assert!(
hint.contains("-32601") && hint.contains("method not found"),
"the hint must carry the daemon's own error verbatim: {hint}"
);
}
}