rust_supervisor/dashboard/
runtime.rs1use crate::config::audit::AuditConfig;
7use crate::control::handle::SupervisorHandle;
8use crate::dashboard::config::ValidatedDashboardIpcConfig;
9use crate::dashboard::error::DashboardError;
10use crate::dashboard::ipc_server::{DashboardIpcService, bind_dashboard_listener};
11use crate::dashboard::protocol::{IpcResponse, parse_request_line, response_to_line};
12use crate::dashboard::registration::run_registration_heartbeat;
13use crate::dashboard::state::declared_state_from_spec;
14use crate::ipc::security::IpcSecurityPipeline;
15use crate::ipc::security::peer_identity::{PeerIdentity, extract_peer_identity};
16use crate::journal::ring::EventJournal;
17use crate::spec::supervisor::SupervisorSpec;
18use std::fmt;
19use std::os::unix::io::AsRawFd;
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24use tokio::net::{UnixListener, UnixStream};
25use tokio::task::{JoinHandle, JoinSet};
26
27pub(crate) const DEFAULT_MAX_FRAME_BYTES: usize = 1_048_576;
29
30const MAX_CONCURRENT_CONNECTIONS: usize = 64;
32
33const CONNECTION_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
36
37static CONNECTION_COUNTER: AtomicU64 = AtomicU64::new(0);
39
40pub struct DashboardIpcRuntimeGuard {
42 ipc_path: PathBuf,
44 ipc_task: JoinHandle<()>,
46 heartbeat_task: Option<JoinHandle<()>>,
48}
49
50impl fmt::Debug for DashboardIpcRuntimeGuard {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter
54 .debug_struct("DashboardIpcRuntimeGuard")
55 .field("ipc_path", &self.ipc_path)
56 .field("has_heartbeat_task", &self.heartbeat_task.is_some())
57 .finish_non_exhaustive()
58 }
59}
60
61impl Drop for DashboardIpcRuntimeGuard {
62 fn drop(&mut self) {
64 self.ipc_task.abort();
65 if let Some(task) = self.heartbeat_task.as_ref() {
66 task.abort();
67 }
68 if let Err(error) = std::fs::remove_file(&self.ipc_path)
69 && error.kind() != std::io::ErrorKind::NotFound
70 {
71 tracing::warn!(
72 ipc_path = %self.ipc_path.display(),
73 ?error,
74 "failed to remove dashboard IPC socket"
75 );
76 }
77 }
78}
79
80pub fn start_dashboard_ipc_runtime(
93 config: ValidatedDashboardIpcConfig,
94 audit_config: AuditConfig,
95 spec: SupervisorSpec,
96 handle: SupervisorHandle,
97) -> Result<Arc<DashboardIpcRuntimeGuard>, DashboardError> {
98 let listener = bind_dashboard_listener(&config)?;
99 let ipc_path = config.path.clone();
100 let target_id = config.target_id.clone();
101 let service = dashboard_service(config.clone(), audit_config, spec, handle);
102 let ipc_task = tokio::spawn(run_accept_loop(listener, service, target_id));
103 let heartbeat_task = start_heartbeat_task(config);
104
105 Ok(Arc::new(DashboardIpcRuntimeGuard {
106 ipc_path,
107 ipc_task,
108 heartbeat_task,
109 }))
110}
111
112fn dashboard_service(
118 config: ValidatedDashboardIpcConfig,
119 audit_config: AuditConfig,
120 spec: SupervisorSpec,
121 handle: SupervisorHandle,
122) -> Arc<DashboardIpcService> {
123 let state = declared_state_from_spec(&spec);
124 let journal = EventJournal::new(spec.event_channel_capacity);
125 let mut service =
126 DashboardIpcService::new(config.clone(), spec, state, journal).with_handle(handle);
127 if let Some(security_config) = config.security_config {
128 let pipeline = IpcSecurityPipeline::new(security_config, audit_config);
129 service = service.with_security_pipeline(pipeline);
130 }
131 Arc::new(service)
132}
133
134fn start_heartbeat_task(config: ValidatedDashboardIpcConfig) -> Option<JoinHandle<()>> {
136 config.registration.as_ref()?;
137 Some(tokio::spawn(async move {
138 if let Err(error) = run_registration_heartbeat(config).await {
139 tracing::warn!(?error, "dashboard registration heartbeat stopped");
140 }
141 }))
142}
143
144async fn run_accept_loop(
150 listener: UnixListener,
151 service: Arc<DashboardIpcService>,
152 target_id: String,
153) {
154 let mut connections = JoinSet::new();
155 loop {
156 tokio::select! {
157 accepted = listener.accept() => {
158 match accepted {
159 Ok((stream, _)) => {
160 if connections.len() >= MAX_CONCURRENT_CONNECTIONS {
161 tracing::warn!(
162 target: "rust_supervisor::dashboard::ipc",
163 "dashboard IPC connection limit reached ({MAX_CONCURRENT_CONNECTIONS}), dropping new connection"
164 );
165 drop(stream);
166 continue;
167 }
168 let service = Arc::clone(&service);
169 let target_id = target_id.clone();
170 connections.spawn(async move {
171 handle_connection(stream, service, target_id).await
172 });
173 }
174 Err(error) => {
175 tracing::warn!(?error, "dashboard IPC accept loop stopped");
176 break;
177 }
178 }
179 }
180 Some(joined) = connections.join_next() => {
181 match joined {
182 Ok(Ok(())) => {}
183 Ok(Err(error)) => {
184 tracing::warn!(?error, "dashboard IPC connection ended with error");
185 }
186 Err(error) => {
187 tracing::warn!(?error, "dashboard IPC connection task failed");
188 }
189 }
190 }
191 }
192 }
193}
194
195async fn handle_connection(
199 stream: UnixStream,
200 service: Arc<DashboardIpcService>,
201 target_id: String,
202) -> Result<(), DashboardError> {
203 let std_stream = stream.into_std().map_err(|error| {
205 io_error(
206 "ipc_into_std_failed",
207 "ipc_connect",
208 Some(target_id.clone()),
209 error,
210 )
211 })?;
212 let peer = extract_peer_identity(&std_stream)?;
213 let raw_fd = std_stream.as_raw_fd();
214 let connection_id = format!(
215 "conn-{raw_fd}-{}",
216 CONNECTION_COUNTER.fetch_add(1, Ordering::Relaxed)
217 );
218 let stream = UnixStream::from_std(std_stream).map_err(|error| {
219 io_error(
220 "ipc_from_std_failed",
221 "ipc_connect",
222 Some(target_id.clone()),
223 error,
224 )
225 })?;
226
227 let max_frame_bytes = service.max_frame_bytes();
230 let mut reader = BoundedFrameReader::new(stream, max_frame_bytes);
231 loop {
232 let frame = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, reader.read_frame()).await;
235 match frame {
236 Ok(Ok(Some(raw_frame))) => {
237 let raw_body_len = raw_frame.len();
238 let response =
239 response_for_line(&service, &raw_frame, &peer, &connection_id, raw_body_len)
240 .await;
241 write_response(&mut reader, &response, &target_id).await?;
242 }
243 Ok(Ok(None)) => {
244 return Ok(());
246 }
247 Ok(Err(error)) => {
248 return Err(error);
249 }
250 Err(_elapsed) => {
251 tracing::warn!(
252 target: "rust_supervisor::dashboard::ipc",
253 %connection_id,
254 "dashboard IPC connection idle timeout after {}s",
255 CONNECTION_IDLE_TIMEOUT.as_secs(),
256 );
257 return Err(DashboardError::new(
258 "ipc_idle_timeout",
259 "ipc_read",
260 Some(target_id),
261 "connection idle timeout".to_owned(),
262 false,
263 ));
264 }
265 }
266 }
267}
268
269struct BoundedFrameReader {
272 stream: UnixStream,
274 max_bytes: usize,
276 buf: Vec<u8>,
278}
279
280impl BoundedFrameReader {
281 fn new(stream: UnixStream, max_bytes: usize) -> Self {
283 Self {
284 stream,
285 max_bytes,
286 buf: Vec::with_capacity(max_bytes.min(4096)),
287 }
288 }
289
290 async fn read_frame(&mut self) -> Result<Option<String>, DashboardError> {
296 self.buf.clear();
297 loop {
298 let mut byte = [0u8; 1];
299 match self.stream.read_exact(&mut byte).await {
300 Ok(_bytes_read) => {
301 if byte[0] == b'\n' {
302 let frame = String::from_utf8(self.buf.clone()).map_err(|_| {
303 DashboardError::new(
304 "invalid_utf8",
305 "ipc_read",
306 None,
307 "frame is not valid UTF-8".to_owned(),
308 false,
309 )
310 })?;
311 return Ok(Some(frame));
312 }
313 self.buf.push(byte[0]);
314 if self.buf.len() > self.max_bytes {
315 return Err(DashboardError::new(
316 "frame_too_large",
317 "ipc_read",
318 None,
319 format!(
320 "frame exceeded maximum size of {max} bytes",
321 max = self.max_bytes
322 ),
323 false,
324 ));
325 }
326 }
327 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
328 if self.buf.is_empty() {
329 return Ok(None);
330 }
331 return Err(DashboardError::new(
332 "incomplete_frame",
333 "ipc_read",
334 None,
335 "connection closed before newline delimiter".to_owned(),
336 false,
337 ));
338 }
339 Err(err) => {
340 return Err(io_error("ipc_read_failed", "ipc_read", None, err));
341 }
342 }
343 }
344 }
345
346 fn stream_mut(&mut self) -> &mut UnixStream {
348 &mut self.stream
349 }
350}
351
352impl std::os::unix::io::AsRawFd for BoundedFrameReader {
353 fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
355 self.stream.as_raw_fd()
356 }
357}
358
359async fn response_for_line(
361 service: &DashboardIpcService,
362 line: &str,
363 peer: &PeerIdentity,
364 connection_id: &str,
365 raw_body_len: usize,
366) -> IpcResponse {
367 match parse_request_line(line) {
368 Ok(request) => {
369 service
370 .handle_request(request, peer, connection_id, raw_body_len)
371 .await
372 }
373 Err(error) => IpcResponse::error("invalid-request", error),
374 }
375}
376
377async fn write_response(
379 reader: &mut BoundedFrameReader,
380 response: &IpcResponse,
381 target_id: &str,
382) -> Result<(), DashboardError> {
383 let line = response_to_line(response)?;
384 reader
385 .stream_mut()
386 .write_all(line.as_bytes())
387 .await
388 .map_err(|error| {
389 io_error(
390 "ipc_write_failed",
391 "ipc_write",
392 Some(target_id.to_owned()),
393 error,
394 )
395 })
396}
397
398fn io_error(
400 code: &str,
401 stage: &str,
402 target_id: Option<String>,
403 error: std::io::Error,
404) -> DashboardError {
405 DashboardError::new(code, stage, target_id, error.to_string(), true)
406}