use std::collections::HashSet;
use std::time::Duration;
use anyhow::{Context, Result};
use astrid_core::PrincipalId;
use astrid_types::ipc::{IpcMessage, IpcPayload};
use serde_json::{Value, json};
use tokio::time::Instant;
use tracing::{debug, info};
use uuid::Uuid;
use crate::socket_client::SocketClient;
use super::server::{TOOLS_LIST_TOPIC, new_req_id};
const CAPSULES_LOADED_TOPIC: &str = "astrid.v1.capsules_loaded";
const BROKER_READY_DEADLINE: Duration = Duration::from_secs(55);
const PROBE_RETRY_INTERVAL: Duration = Duration::from_secs(3);
#[allow(async_fn_in_trait)]
trait ReadinessIo {
async fn send_message(&mut self, message: IpcMessage) -> Result<()>;
async fn read_raw_frame(&mut self) -> Result<Option<Vec<u8>>>;
}
impl ReadinessIo for SocketClient {
async fn send_message(&mut self, message: IpcMessage) -> Result<()> {
SocketClient::send_message(self, message).await
}
async fn read_raw_frame(&mut self) -> Result<Option<Vec<u8>>> {
SocketClient::read_raw_frame(self).await
}
}
pub(super) async fn wait_for_broker(
client: &mut SocketClient,
principal: &PrincipalId,
) -> Result<()> {
wait_for_broker_on(
client,
principal,
BROKER_READY_DEADLINE,
PROBE_RETRY_INTERVAL,
)
.await
}
async fn wait_for_broker_on<I: ReadinessIo>(
io: &mut I,
principal: &PrincipalId,
ready_deadline: Duration,
retry_interval: Duration,
) -> Result<()> {
let deadline = Instant::now()
.checked_add(ready_deadline)
.unwrap_or_else(Instant::now);
let mut outstanding = HashSet::new();
send_probe(io, principal, &mut outstanding).await?;
let mut retry_at = Instant::now()
.checked_add(retry_interval)
.unwrap_or(deadline);
loop {
let now = Instant::now();
if now >= deadline {
anyhow::bail!(
"MCP broker did not become ready for principal '{principal}' within {}s; no capsule answered {TOOLS_LIST_TOPIC}",
ready_deadline.as_secs()
);
}
if now >= retry_at {
debug!(%principal, "MCP broker readiness probe interval elapsed; retrying idempotent tools/list");
send_probe(io, principal, &mut outstanding).await?;
retry_at = Instant::now()
.checked_add(retry_interval)
.unwrap_or(deadline);
continue;
}
let wake_at = retry_at.min(deadline);
let frame = match tokio::time::timeout_at(wake_at, io.read_raw_frame()).await {
Ok(Ok(Some(frame))) => frame,
Ok(Ok(None)) => {
anyhow::bail!(
"daemon connection closed while waiting for the MCP broker for principal '{principal}'"
)
},
Ok(Err(error)) => {
return Err(error).with_context(|| {
format!(
"daemon connection failed while waiting for the MCP broker for principal '{principal}'"
)
});
},
Err(_) => {
if Instant::now() >= deadline {
anyhow::bail!(
"MCP broker did not become ready for principal '{principal}' within {}s; no capsule answered {TOOLS_LIST_TOPIC}",
ready_deadline.as_secs()
);
}
debug!(%principal, "MCP broker readiness probe unanswered; retrying idempotent tools/list");
send_probe(io, principal, &mut outstanding).await?;
retry_at = Instant::now()
.checked_add(retry_interval)
.unwrap_or(deadline);
continue;
},
};
let Ok(raw) = serde_json::from_slice::<Value>(&frame) else {
continue;
};
if frame_principal_mismatches(&raw, principal) {
continue;
}
let topic = raw.get("topic").and_then(Value::as_str);
if topic.is_some_and(|topic| outstanding.contains(topic)) {
info!(%principal, "MCP broker readiness confirmed");
return Ok(());
}
if topic == Some(CAPSULES_LOADED_TOPIC) {
debug!(%principal, "principal capsule load completed; reproving MCP broker readiness");
send_probe(io, principal, &mut outstanding).await?;
retry_at = Instant::now()
.checked_add(retry_interval)
.unwrap_or(deadline);
}
}
}
async fn send_probe<I: ReadinessIo>(
io: &mut I,
principal: &PrincipalId,
outstanding: &mut HashSet<String>,
) -> Result<()> {
let req_id = new_req_id();
let response_topic = astrid_types::Topic::kernel_response(&req_id).to_string();
let message = IpcMessage::new(
astrid_types::Topic::from_raw(TOOLS_LIST_TOPIC),
IpcPayload::RawJson(json!({ "req_id": req_id })),
Uuid::nil(),
)
.with_principal(principal.to_string());
io.send_message(message)
.await
.context("failed to publish MCP broker readiness probe")?;
outstanding.insert(response_topic);
Ok(())
}
fn frame_principal_mismatches(raw: &Value, principal: &PrincipalId) -> bool {
raw.get("principal")
.and_then(Value::as_str)
.is_some_and(|frame_principal| frame_principal != principal.as_str())
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use super::*;
struct ColdStartIo {
frames: VecDeque<Vec<u8>>,
sends: usize,
principal: PrincipalId,
drop_first_probe: bool,
continuous_noise: bool,
}
impl ColdStartIo {
fn new(principal: PrincipalId, drop_first_probe: bool) -> Self {
Self {
frames: VecDeque::new(),
sends: 0,
principal,
drop_first_probe,
continuous_noise: false,
}
}
fn noisy(principal: PrincipalId) -> Self {
Self {
continuous_noise: true,
..Self::new(principal, true)
}
}
fn raw_frame(value: &Value) -> Vec<u8> {
serde_json::to_vec(value).expect("serialize test frame")
}
}
impl ReadinessIo for ColdStartIo {
async fn send_message(&mut self, message: IpcMessage) -> Result<()> {
self.sends = self.sends.checked_add(1).expect("test send count overflow");
let req_id = match message.payload {
IpcPayload::RawJson(body) => body
.get("req_id")
.and_then(Value::as_str)
.expect("probe req_id")
.to_string(),
other => panic!("unexpected probe payload: {other:?}"),
};
if self.drop_first_probe && self.sends == 1 {
if !self.continuous_noise {
self.frames.push_back(Self::raw_frame(&json!({
"topic": CAPSULES_LOADED_TOPIC,
"principal": self.principal.to_string(),
"payload": { "status": "ready", "capsules": [] }
})));
}
} else {
self.frames.push_back(Self::raw_frame(&json!({
"topic": astrid_types::Topic::kernel_response(&req_id).to_string(),
"principal": self.principal.to_string(),
"payload": {
"type": "raw_json",
"value": { "kind": "tools.list", "req_id": req_id, "tools": [] }
}
})));
}
Ok(())
}
async fn read_raw_frame(&mut self) -> Result<Option<Vec<u8>>> {
match self.frames.pop_front() {
Some(frame) => Ok(Some(frame)),
None if self.continuous_noise => Ok(Some(Self::raw_frame(&json!({
"topic": "astrid.v1.unrelated",
"principal": self.principal.to_string(),
"payload": {}
})))),
None => std::future::pending().await,
}
}
}
#[tokio::test]
async fn cold_start_replays_probe_after_principal_capsules_load() {
let principal = PrincipalId::new("codex-code").expect("principal");
let mut io = ColdStartIo::new(principal.clone(), true);
wait_for_broker_on(
&mut io,
&principal,
Duration::from_secs(1),
Duration::from_millis(50),
)
.await
.expect("broker becomes ready after principal warm");
assert_eq!(io.sends, 2, "dropped first probe must be replayed once");
}
#[tokio::test]
async fn warm_broker_needs_only_one_probe() {
let principal = PrincipalId::new("codex-code").expect("principal");
let mut io = ColdStartIo::new(principal.clone(), false);
wait_for_broker_on(
&mut io,
&principal,
Duration::from_secs(1),
Duration::from_millis(50),
)
.await
.expect("warm broker answers first probe");
assert_eq!(io.sends, 1);
}
#[tokio::test]
async fn unrelated_frames_cannot_starve_retry_interval() {
let principal = PrincipalId::new("codex-code").expect("principal");
let mut io = ColdStartIo::noisy(principal.clone());
wait_for_broker_on(
&mut io,
&principal,
Duration::from_millis(100),
Duration::from_millis(10),
)
.await
.expect("wall-clock retry fires despite continuously ready noise");
assert_eq!(io.sends, 2, "noise must not starve the fallback retry");
}
#[test]
fn mismatched_principal_frame_is_rejected() {
let principal = PrincipalId::new("codex-code").expect("principal");
assert!(frame_principal_mismatches(
&json!({ "principal": "claude-code" }),
&principal
));
assert!(!frame_principal_mismatches(
&json!({ "principal": "codex-code" }),
&principal
));
assert!(!frame_principal_mismatches(&json!({}), &principal));
}
}