use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg, }; use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; use std::io::Result; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::net::UnixStream; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; use tokio::sync::{Mutex, oneshot, watch}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; const INITIAL_BACKOFF: Duration = Duration::from_millis(200); const MAX_BACKOFF: Duration = Duration::from_secs(10); const MAX_RECONNECT_ATTEMPTS: u32 = 50; const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); /// Connection state exposed to the UI. #[derive(Clone, Debug)] pub enum IpcConnectionState { Connecting, Connected, Reconnecting { attempt: u32 }, Incompatible { message: String }, Failed { message: String }, Disconnected, } /// Daemon information shown by the UI. This is separate from socket connectivity: /// a connected daemon may still be starting or degraded. #[derive(Clone, Debug, Default)] pub struct DaemonStatus { pub version: String, pub instance_id: String, pub startup_phase: Option, pub degraded_reason: Option, pub lifecycle: Option, pub health: iota_ipc::HealthStatus, pub deployment_mode: Option, pub supervisor: Option, pub components: std::collections::BTreeMap, } /// Pending request awaiting a response. struct PendingRequest { response_tx: oneshot::Sender, } struct ActiveWriter { generation: u64, writer: OwnedWriteHalf, } struct NegotiatedConnection { reader: OwnedReadHalf, writer: OwnedWriteHalf, ack: HelloAck, buffered_messages: Vec, } /* The TUI owns this cache. IPC updates replace daemon snapshots and append * logs, so rendering never reaches into daemon-owned storage or connections. */ pub struct IpcClient { state: ClientState, writer: Mutex>, next_generation: AtomicU64, next_request_id: AtomicU64, pending: Mutex>, connection_state: watch::Sender, daemon_status: watch::Sender, path: PathBuf, reconnector_started: AtomicBool, cancellation: CancellationToken, background_tasks: StdMutex>>, } impl IpcClient { pub async fn connect(path: impl AsRef) -> Result> { let path = path.as_ref().to_path_buf(); let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; Self::connect_until(&path, deadline).await } async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result> { let path = path.to_path_buf(); let stream = Self::connect_stream(&path, deadline).await?; let negotiated = Self::negotiate_stream(stream, deadline).await?; // These values are visible before MainScreen subscribes. Do not // publish the handshake into a channel with no retained receiver. let initial_status = DaemonStatus { version: negotiated.ack.daemon_version.clone(), instance_id: negotiated.ack.instance_id.clone(), startup_phase: Some(negotiated.ack.startup_phase), degraded_reason: None, lifecycle: Some(negotiated.ack.lifecycle), health: negotiated.ack.health, deployment_mode: Some(negotiated.ack.deployment_mode), supervisor: Some(negotiated.ack.supervisor), components: std::collections::BTreeMap::new(), }; let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); let (daemon_status_tx, _) = watch::channel(initial_status); let client = Arc::new(Self { state: ClientState::new(), writer: Mutex::new(Some(ActiveWriter { generation: 1, writer: negotiated.writer, })), next_generation: AtomicU64::new(2), next_request_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), connection_state: conn_state_tx, daemon_status: daemon_status_tx, path: path.clone(), reconnector_started: AtomicBool::new(false), cancellation: CancellationToken::new(), background_tasks: StdMutex::new(Vec::new()), }); // Apply messages received while waiting for subscription confirmation // before exposing the connection to the UI. for message in negotiated.buffered_messages { client.apply(message).await; } // Start reader task (continues reading after handshake) let reader_client = client.clone(); let task = tokio::spawn(async move { reader_client.read_loop(negotiated.reader, 1).await; }); client.background_tasks.lock().unwrap().push(task); Ok(client) } /// Try to connect with retries for socket activation. pub async fn connect_or_activate(path: impl AsRef) -> Result> { let path = path.as_ref().to_path_buf(); let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT; let mut last_error = None; while tokio::time::Instant::now() < deadline { match Self::connect_until(&path, deadline).await { Ok(client) => return Ok(client), Err(error) => { last_error = Some(error); tokio::time::sleep(Duration::from_millis(250)).await; } } } Err(last_error.unwrap_or_else(|| { std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon") })) } async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result { tokio::time::timeout_at(deadline, UnixStream::connect(path)) .await .map_err(|_| { std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out connecting to daemon", ) })? } async fn negotiate_stream( stream: UnixStream, deadline: tokio::time::Instant, ) -> Result { let (mut reader, mut writer) = stream.into_split(); tokio::time::timeout_at( deadline, write_msg( &mut writer, &ClientMessage::Hello { supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION], }, ), ) .await .map_err(|_| { std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello") })??; let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)) .await { Err(_) => { return Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out waiting for IPC HelloAck", )); } Ok(Ok(DaemonMessage::HelloAck(ack))) => ack, Ok(Ok(_)) => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "Expected HelloAck as the first daemon message", )); } Ok(Err(error)) => return Err(error), }; if !Self::is_compatible_version(ack.protocol_version) { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, format!( "Unsupported daemon protocol version {}", ack.protocol_version ), )); } tokio::time::timeout_at( deadline, write_msg( &mut writer, &ClientMessage::Subscribe { log_classes: vec![], metric_interval_ms: Some(500), }, ), ) .await .map_err(|_| { std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out sending IPC subscription", ) })??; // The daemon may send its initial StateUpdate before the acknowledgement. // Keep draining until the subscription itself is confirmed, otherwise a // UI can report Connected while no state stream exists yet. let mut buffered_messages = Vec::new(); loop { match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await { Err(_) => { return Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out waiting for IPC subscription acknowledgement", )); } Ok(Ok(DaemonMessage::Subscribed)) => break, Ok(Ok(message)) => buffered_messages.push(message), Ok(Err(error)) => return Err(error), } } Ok(NegotiatedConnection { reader, writer, ack, buffered_messages, }) } /// Start the reconnection actor. pub fn spawn_reconnector(self: &Arc) { if self.reconnector_started.swap(true, Ordering::AcqRel) { return; } let client = self.clone(); let task = tokio::spawn(async move { client.reconnection_loop().await; }); self.background_tasks.lock().unwrap().push(task); } async fn reconnection_loop(self: Arc) { let mut rx = self.connection_status(); loop { while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) { if tokio::select! { changed = rx.changed() => changed.is_err(), _ = self.cancellation.cancelled() => true, } { return; } } let mut backoff = INITIAL_BACKOFF; for attempt in 1..=MAX_RECONNECT_ATTEMPTS { if self.cancellation.is_cancelled() { return; } let _ = self .connection_state .send(IpcConnectionState::Reconnecting { attempt }); tokio::select! { _ = tokio::time::sleep(backoff) => {}, _ = self.cancellation.cancelled() => return, } let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; let result = async { let stream = Self::connect_stream(&self.path, deadline).await?; Self::negotiate_stream(stream, deadline).await } .await; match result { Ok(connection) => { self.install_connection(connection).await; break; } Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { let _ = self .connection_state .send(IpcConnectionState::Incompatible { message: error.to_string(), }); return; } Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => { let _ = self.connection_state.send(IpcConnectionState::Failed { message: format!("Reconnect failed after {attempt} attempts: {error}"), }); return; } Err(error) => { eprintln!( "IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}", self.path.display(), error.kind() ); backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); } } } } } async fn install_connection(self: &Arc, connection: NegotiatedConnection) { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); *self.writer.lock().await = Some(ActiveWriter { generation, writer: connection.writer, }); self.update_hello_ack(connection.ack); for message in connection.buffered_messages { self.apply(message).await; } let _ = self.connection_state.send(IpcConnectionState::Connected); let client = self.clone(); let task = tokio::spawn(async move { client.read_loop(connection.reader, generation).await; }); self.background_tasks.lock().unwrap().push(task); } async fn fail_pending_requests(&self) { let mut pending = self.pending.lock().await; for (_, request) in pending.drain() { let _ = request .response_tx .send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)); } } async fn mark_disconnected(&self, generation: u64) { let removed = { let mut writer = self.writer.lock().await; match writer.as_ref() { Some(active) if active.generation == generation => { writer.take(); true } _ => false, } }; if removed { let _ = self.connection_state.send(IpcConnectionState::Disconnected); self.fail_pending_requests().await; } } async fn read_loop(self: Arc, mut reader: OwnedReadHalf, generation: u64) { loop { let result = tokio::select! { result = read_msg::<_, DaemonMessage>(&mut reader) => result, _ = self.cancellation.cancelled() => break, }; match result { Ok(message) => self.apply(message).await, Err(_) => { self.mark_disconnected(generation).await; break; } } } } pub fn state(&self) -> ClientState { self.state.clone() } pub fn connection_status(&self) -> watch::Receiver { self.connection_state.subscribe() } pub fn connection_status_snapshot(&self) -> IpcConnectionState { self.connection_state.borrow().clone() } pub fn daemon_status(&self) -> watch::Receiver { self.daemon_status.subscribe() } /// Stop the IPC reader/reconnector and release the socket writer. This /// is deliberately bounded so UI shutdown cannot hang on a peer. pub async fn shutdown(&self) { self.cancellation.cancel(); self.writer.lock().await.take(); let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap()); for mut task in tasks { if tokio::time::timeout(Duration::from_secs(2), &mut task) .await .is_err() { task.abort(); } } } fn is_compatible_version(version: u16) -> bool { (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version) } fn update_hello_ack(&self, ack: HelloAck) { self.daemon_status.send_modify(|status| { status.version = ack.daemon_version; status.instance_id = ack.instance_id; status.startup_phase = Some(ack.startup_phase); status.lifecycle = Some(ack.lifecycle); status.health = ack.health; status.deployment_mode = Some(ack.deployment_mode); status.supervisor = Some(ack.supervisor); }); } fn format_payload(payload: &ResponsePayload) -> String { match payload { ResponsePayload::Status(status) => { let mut msg = format!("Phase: {}", status.phase); if !status.tasks.is_empty() { msg.push_str(&format!(", Tasks: {}", status.tasks.join(", "))); } if let Some(reason) = &status.degraded_reason { msg.push_str(&format!(", Degraded: {reason}")); } msg } ResponsePayload::Tasks(tasks) => { if tasks.is_empty() { "No active tasks.".into() } else { tasks .iter() .map(|t| t.name.as_str()) .collect::>() .join(", ") } } ResponsePayload::Users(users) => { if users.is_empty() { "No users.".into() } else { users .iter() .map(|u| format!("{} ({})", u.username, u.user_id)) .collect::>() .join("\n") } } ResponsePayload::UserCreated { user_id, username } => { format!("Created user {} ({})", username, user_id) } ResponsePayload::UserRemoved { user_id } => { format!("Removed user {}", user_id) } ResponsePayload::Acknowledged { message } => message.clone(), ResponsePayload::DaemonStatus(status) => status.formatted.clone(), ResponsePayload::Config(config) => config.yaml.clone(), ResponsePayload::OmikronStatus(status) => { let mut msg = format!("Connected: {}", status.connected); if let Some(id) = status.iota_id { msg.push_str(&format!("\nIota ID: {}", id)); } msg } ResponsePayload::Components(components) => { if components.is_empty() { "No component health data available.".into() } else { components .iter() .map(|c| { let status_str = match c.status { iota_ipc::HealthStatus::Healthy => "healthy", iota_ipc::HealthStatus::Degraded => "degraded", iota_ipc::HealthStatus::Failed => "failed", }; format!("{:?}: {}", c.id, status_str) }) .collect::>() .join("\n") } } ResponsePayload::UserDetail(user) => { let mut msg = format!("User: {} ({})", user.username, user.user_id); if let Some(ref name) = user.display_name { msg.push_str(&format!("\nDisplay Name: {name}")); } msg.push_str(&format!("\nCreated At: {}", user.created_at)); if !user.trusted_apps.is_empty() { msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); } msg } ResponsePayload::LogEntries(logs) => logs .entries .iter() .map(|e| { let level = if e.is_error { "ERR" } else { "INF" }; format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) }) .collect::>() .join("\n"), ResponsePayload::UpdateStatus(status) => { if status.available { "Update available.".into() } else { "Up to date.".into() } } ResponsePayload::Communities(communities) => { if communities.is_empty() { "No communities.".into() } else { communities .iter() .map(|c| format!("{} ({})", c.title, c.name)) .collect::>() .join("\n") } } } } fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { match code { iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.", iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.", iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.", iota_ipc::IpcErrorCode::OmikronUnavailable => { "Omikron is unavailable; try reconnecting." } iota_ipc::IpcErrorCode::UnsupportedVersion => { "CLI and daemon versions are incompatible." } iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.", iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.", iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.", iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.", iota_ipc::IpcErrorCode::Unauthorized => { "The daemon rejected this operation as unauthorized." } iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.", } } pub async fn send_request(&self, request: LocalRequest) -> Result { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); let (response_tx, response_rx) = oneshot::channel(); { let mut pending = self.pending.lock().await; pending.insert(request_id, PendingRequest { response_tx }); } let envelope = RequestEnvelope { request_id, protocol_version: PROTOCOL_VERSION, request, }; if let Err(error) = self.send(ClientMessage::Request(envelope)).await { self.pending.lock().await.remove(&request_id); return Err(error); } match tokio::time::timeout(Duration::from_secs(30), response_rx).await { Ok(Ok(result)) => Ok(result), Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)), Err(_) => { self.pending.lock().await.remove(&request_id); Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout)) } } } /// Parse a legacy console command string into a typed request. /// Delegates to the shared parser in iota-ipc. pub fn parse_console_command(line: &str) -> Option { iota_ipc::text_commands::parse(line) } /// Legacy command interface: parse text command, send as typed request. pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> { let trimmed = line.trim_start_matches('/').trim(); // Handle ping as a direct Ping message (not a LocalRequest). if trimmed == "ping" || trimmed.starts_with("ping ") { let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed); if let Err(e) = self.send(ClientMessage::Ping { seq }).await { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Console".into(), message: format!("Failed to send ping: {}", e), is_error: true, }); return Err(e); } let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Console".into(), message: "Ping sent".into(), is_error: false, }); return Ok(()); } if let Some(request) = Self::parse_console_command(&line) { match self.send_request(request).await { Ok(result) => { let mut state = self.state.app.lock().await; let message = match &result { ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Command".into(), message, is_error: matches!(&result, ResponseResult::Error(_)), }); Ok(()) } Err(e) => { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Console".into(), message: format!("Failed to send: {}", e), is_error: true, }); Err(e) } } } else { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Console".into(), message: if trimmed == "help" { "Commands: status, tasks, ping, user add , user remove , user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components" .into() } else { format!("Unknown command: {}", line) }, is_error: false, }); Ok(()) } } async fn send(&self, message: ClientMessage) -> Result<()> { let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock()) .await .map_err(|_| { std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out acquiring IPC writer", ) })?; let active = writer_guard.as_mut().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotConnected, "IPC connection is not active", ) })?; let generation = active.generation; let write_result = tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message)) .await .map_err(|_| { std::io::Error::new( std::io::ErrorKind::TimedOut, "Timed out writing IPC message", ) }) .and_then(|result| result); drop(writer_guard); if write_result.is_err() { self.mark_disconnected(generation).await; } write_result } async fn apply(&self, message: DaemonMessage) { match message { DaemonMessage::LogEntry(entry) => { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: entry.timestamp_ms, sender: entry.sender, message: entry.message, is_error: entry.is_error, }); } DaemonMessage::StateUpdate(snapshot) => { // Never hold a watch borrow while sending to that same // channel: send waits for outstanding Ref guards. self.daemon_status.send_modify(|status| { status.startup_phase = Some(snapshot.startup_phase); status.degraded_reason = snapshot.degraded_reason.clone(); status.lifecycle = Some(snapshot.lifecycle); status.health = snapshot.overall_health; status.components = snapshot.components.clone(); }); let mut state = self.state.app.lock().await; state.cpu = snapshot.cpu; state.ram = snapshot.ram; state.ping = snapshot.ping; state.net_up = snapshot.net_up; state.net_down = snapshot.net_down; state.sys_info = snapshot.sys_info; } DaemonMessage::MetricSample(sample) => { let mut state = self.state.app.lock().await; if let Some(cpu) = sample.cpu { state.push_cpu((0.0, cpu)); } if let Some(ram) = sample.ram { state.push_ram((0.0, ram)); } if let Some(ping) = sample.ping { state.push_ping_val(ping); } if let Some(net_up) = sample.net_up { state.push_net_up((0.0, net_up)); } if let Some(net_down) = sample.net_down { state.push_net_down((0.0, net_down)); } } DaemonMessage::Response(response) => { let mut pending = self.pending.lock().await; if let Some(request) = pending.remove(&response.request_id) { let _ = request.response_tx.send(response.result); } else { let mut state = self.state.app.lock().await; let message = match &response.result { ResponseResult::Ok(payload) => Self::format_payload(payload), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Command".into(), message, is_error: matches!(&response.result, ResponseResult::Error(_)), }); } } DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack), DaemonMessage::Subscribed => {} DaemonMessage::Pong { .. } => {} DaemonMessage::LifecycleEvent(event) => match event { iota_ipc::LifecycleEvent::Shutdown { reason } => { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "Daemon".into(), message: format!("Daemon shutting down: {}", reason), is_error: true, }); } iota_ipc::LifecycleEvent::StateChanged(status) => { self.daemon_status.send_modify(|daemon_status| { daemon_status.degraded_reason = match status { iota_ipc::ConnectionStatus::Degraded => { Some("A daemon dependency is degraded".into()) } _ => None, }; }); } }, DaemonMessage::Gap { skipped } => { let mut state = self.state.app.lock().await; state.push_log(UiLogEntry { timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(), sender: "System".into(), message: format!("Skipped {} messages, resynchronizing", skipped), is_error: false, }); } } } }