use std::path::{Path, PathBuf};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use super::super::search_rpc;
pub(crate) const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const PROBE_ATTEMPTS: usize = 3;
const PROBE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
pub(super) async fn check_memory(_home: &Path) -> DoctorCheck {
let socket = trusty_common::memory_rpc::resolve_memory_socket_or_unreachable();
let addr = socket.display().to_string();
probe_health("memory", "trusty-memory", &socket, &addr).await
}
enum ProbeOutcome {
Success(Option<serde_json::Value>),
NonSuccess(u16),
TimedOut,
Unreachable(String),
}
async fn probe_health_once(socket: &Path) -> ProbeOutcome {
let started = std::time::Instant::now();
match trusty_common::memory_rpc::call_memory_tool_at_with_timeout(
socket,
"memory.health",
serde_json::json!({}),
PROBE_TIMEOUT,
)
.await
{
Ok(body) => ProbeOutcome::Success(Some(body)),
Err(e) => {
if let Some(rpc) = e.downcast_ref::<trusty_common::memory_rpc::MemoryRpcError>() {
return ProbeOutcome::NonSuccess(rpc.code.unsigned_abs() as u16);
}
if started.elapsed() >= PROBE_TIMEOUT {
return ProbeOutcome::TimedOut;
}
ProbeOutcome::Unreachable(format!("{e:#}"))
}
}
}
pub(super) async fn probe_health(
check: &str,
service: &str,
socket: &Path,
addr: &str,
) -> DoctorCheck {
let mut last_timeout = false;
let mut last_err: Option<String> = None;
let mut last_status: Option<u16> = None;
for attempt in 0..PROBE_ATTEMPTS {
match probe_health_once(socket).await {
ProbeOutcome::Success(body) => {
return interpret_health(check, service, addr, body.as_ref());
}
ProbeOutcome::NonSuccess(code) => {
last_status = Some(code);
last_timeout = false;
}
ProbeOutcome::TimedOut => {
last_timeout = true;
}
ProbeOutcome::Unreachable(e) => {
last_err = Some(e);
last_timeout = false;
}
}
if attempt + 1 < PROBE_ATTEMPTS {
tokio::time::sleep(PROBE_RETRY_DELAY).await;
}
}
if last_timeout {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} did not answer its health probe within {}s across \
{PROBE_ATTEMPTS} \
attempts, but the connection was NOT refused — the daemon may be alive and \
merely slow. Health could not be determined. Check the MCP surface before \
restarting anything.",
PROBE_TIMEOUT.as_secs()
),
);
}
if let Some(code) = last_status {
return DoctorCheck::new(
check,
CheckStatus::Fail,
format!("{service} at {addr} refused the health probe with code {code}"),
);
}
DoctorCheck::new(
check,
CheckStatus::Fail,
format!(
"{service} unreachable at {addr}: {}",
last_err.unwrap_or_else(|| "connection refused".to_string())
),
)
}
fn interpret_health(
check: &str,
service: &str,
addr: &str,
body: Option<&serde_json::Value>,
) -> DoctorCheck {
let Some(body) = body else {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} answered /health but the body was unreadable — the \
listener is up; whether its workers are progressing is UNKNOWN"
),
);
};
let worker = body.get("worker");
let wedged = worker
.and_then(|w| w.get("wedged"))
.and_then(serde_json::Value::as_bool);
match wedged {
Some(true) => {
let oldest = worker
.and_then(|w| w.get("oldest_age_secs"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default();
let in_flight = worker
.and_then(|w| w.get("in_flight"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default();
return DoctorCheck::new(
check,
CheckStatus::Fail,
format!(
"{service} at {addr} is answering /health BUT reports a WEDGED worker \
pool: oldest in-flight operation {oldest}s, {in_flight} in flight. The \
listener responding does not mean writes are progressing (issue #3992)."
),
);
}
None => {
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} is reachable but does not report worker-pool \
occupancy (pre-#4001 build) — liveness confirmed, progress UNKNOWN"
),
);
}
Some(false) => {}
}
if worker
.and_then(|w| w.get("stall_tracking_ok"))
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
let detail = body
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or("this daemon predates the palace-lock stall detector (#4001)");
return DoctorCheck::new(
check,
CheckStatus::Unknown,
format!(
"{service} at {addr} is reachable but its palace-lock stall detector is not \
reporting: {detail} — whether a palace lock is wedged is UNKNOWN"
),
);
}
if body.get("daemon_state").and_then(|v| v.as_str()) == Some("warming") {
return DoctorCheck::new(
check,
CheckStatus::Warn,
format!(
"{service} at {addr} is WARMING UP (embedder initialising) — normal shortly after a restart"
),
);
}
if trusty_common::memory_rpc::MemoryHealthStatus::from_health_body(body)
== trusty_common::memory_rpc::MemoryHealthStatus::Degraded
{
let detail = body
.get("detail")
.and_then(|v| v.as_str())
.unwrap_or("no detail reported");
return DoctorCheck::new(
check,
CheckStatus::Warn,
format!("{service} at {addr} reports DEGRADED: {detail}"),
);
}
DoctorCheck::new(
check,
CheckStatus::Ok,
format!("{service} healthy at {addr}, workers progressing"),
)
}
pub(super) async fn check_search(_home: &Path, project_dir: Option<&Path>) -> DoctorCheck {
let socket = match search_rpc::search_socket() {
Ok(socket) => socket,
Err(e) => {
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("cannot resolve the trusty-search socket: {e:#}"),
);
}
};
let at = socket.display().to_string();
if let Err(e) = search_rpc::call_at(
&socket,
search_rpc::METHOD_HEALTH,
serde_json::json!({}),
PROBE_TIMEOUT,
)
.await
{
return DoctorCheck::new(
"search",
CheckStatus::Fail,
format!("trusty-search unreachable at {at}: {e:#}"),
);
}
let expected_index = expected_search_index_id(project_dir);
match search_rpc::call_at(
&socket,
search_rpc::METHOD_INDEXES_LIST,
serde_json::json!({}),
PROBE_TIMEOUT,
)
.await
{
Ok(body) if index_present(&body, &expected_index) => DoctorCheck::new(
"search",
CheckStatus::Ok,
format!("trusty-search healthy at {at}, `{expected_index}` index present"),
),
Ok(_) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {at} but the `{expected_index}` index is missing"),
),
Err(e) => DoctorCheck::new(
"search",
CheckStatus::Warn,
format!("trusty-search healthy at {at} but listing indexes failed: {e:#}"),
),
}
}
pub(super) fn expected_search_index_id(project_dir: Option<&Path>) -> String {
let start = match project_dir {
Some(dir) => dir.to_path_buf(),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let root = trusty_common::resolve_project_root(&start);
trusty_common::derive_index_id(&root)
}
pub(super) fn index_present(body: &serde_json::Value, name: &str) -> bool {
let array = body
.as_array()
.or_else(|| body.get("indexes").and_then(|v| v.as_array()));
let Some(array) = array else {
return false;
};
array.iter().any(|entry| {
if entry.as_str() == Some(name) {
return true;
}
["id", "name", "index_id"]
.iter()
.any(|key| entry.get(key).and_then(|v| v.as_str()) == Some(name))
})
}