Skip to main content

microsandbox_agentd/
agent.rs

1//! Main agent loop: serial I/O, session management, heartbeat.
2
3use std::collections::HashMap;
4use std::env;
5use std::fs::{File, OpenOptions};
6use std::os::fd::AsRawFd;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Instant;
10
11use chrono::Utc;
12use tokio::io::unix::AsyncFd;
13use tokio::sync::{mpsc, watch};
14use tokio::time::{self, Duration};
15
16use microsandbox_protocol::HANDOFF_POWEROFF_TIMEOUT;
17use microsandbox_protocol::codec::{self, MAX_FRAME_SIZE};
18use microsandbox_protocol::core::{
19    ClockSync, CoreError, CoreErrorKind, InitAck, InitResolved, Ping, Pong, Ready,
20    RelayClientDisconnected, ResolvedUser, Touch, Touched,
21};
22use microsandbox_protocol::exec::{
23    ExecExited, ExecFailed, ExecFailureKind, ExecRequest, ExecResize, ExecSignal, ExecStarted,
24    ExecStderr, ExecStdin, ExecStdinError, ExecStdout,
25};
26use microsandbox_protocol::fs::{FsData, FsRequest};
27use microsandbox_protocol::heartbeat::{ActivityCounters, Heartbeat};
28use microsandbox_protocol::message::{Message, MessageType};
29use microsandbox_protocol::tcp::{TcpClose, TcpConnect, TcpData, TcpEof, TcpFailed};
30
31use crate::config::AgentdConfig;
32use crate::error::{AgentdError, AgentdResult};
33use crate::fs::{FsReadSession, FsState, FsStreamSession, FsWriteSession};
34use crate::serial::AGENT_PORT_NAME;
35use crate::session::{
36    ExecSession, RawActivity, RawSessionCompletion, SessionOutput, resolve_default_user,
37};
38use crate::tcp::TcpSession;
39use crate::{clock, fs, handoff, heartbeat, serial};
40
41//--------------------------------------------------------------------------------------------------
42// Constants
43//--------------------------------------------------------------------------------------------------
44
45/// Heartbeat interval in seconds.
46///
47/// Keep this short so small idle timeouts (for example `--idle-timeout 1`)
48/// can be enforced without multi-second scheduling drift.
49const HEARTBEAT_INTERVAL_SECS: u64 = 1;
50
51/// Read buffer size for the serial port.
52const SERIAL_READ_BUF_SIZE: usize = 64 * 1024;
53
54/// Maximum allowed input buffer size (frame size limit + 4 bytes for length prefix).
55const MAX_INPUT_BUF_SIZE: usize = MAX_FRAME_SIZE as usize + 4;
56
57/// Maximum time to wait for the host to acknowledge the init context.
58const INIT_ACK_TIMEOUT_SECS: u64 = 60;
59
60//--------------------------------------------------------------------------------------------------
61// Types
62//--------------------------------------------------------------------------------------------------
63
64#[derive(Default)]
65struct AgentState {
66    sessions: HashMap<u32, ExecSession>,
67    write_sessions: HashMap<u32, FsWriteSession>,
68    read_sessions: HashMap<u32, FsReadSession>,
69    tcp_sessions: HashMap<u32, TcpSession>,
70    fs: FsState,
71}
72
73struct ActivityTracker {
74    activity_seq: u64,
75    counters: ActivityCounters,
76}
77
78#[derive(Clone)]
79struct HeartbeatSnapshot {
80    activity_seq: u64,
81    active_exec_sessions: u32,
82    active_fs_streams: u32,
83    active_tcp_streams: u32,
84    counters: ActivityCounters,
85}
86
87//--------------------------------------------------------------------------------------------------
88// Methods
89//--------------------------------------------------------------------------------------------------
90
91impl ActivityTracker {
92    fn new() -> Self {
93        Self {
94            activity_seq: 0,
95            counters: ActivityCounters::default(),
96        }
97    }
98
99    fn record_host_message(&mut self) {
100        self.touch();
101        self.counters.host_messages = self.counters.host_messages.saturating_add(1);
102    }
103
104    fn record_guest_message(&mut self) {
105        self.touch();
106        self.counters.guest_messages = self.counters.guest_messages.saturating_add(1);
107    }
108
109    fn add_exec_output_bytes(&mut self, len: usize) {
110        self.counters.exec_output_bytes =
111            self.counters.exec_output_bytes.saturating_add(len as u64);
112    }
113
114    fn add_fs_bytes(&mut self, len: usize) {
115        self.counters.fs_bytes = self.counters.fs_bytes.saturating_add(len as u64);
116    }
117
118    fn add_tcp_bytes(&mut self, len: usize) {
119        self.counters.tcp_bytes = self.counters.tcp_bytes.saturating_add(len as u64);
120    }
121
122    fn touch(&mut self) {
123        self.activity_seq = self.activity_seq.saturating_add(1);
124    }
125}
126
127//--------------------------------------------------------------------------------------------------
128// Functions
129//--------------------------------------------------------------------------------------------------
130
131/// Runs the main agent loop.
132///
133/// Reuses the already-open virtio serial port, sends `core.ready` with boot timing data,
134/// then enters the main select loop handling serial I/O, process output, and heartbeat.
135///
136/// - `boot_time_ns`: `CLOCK_BOOTTIME` at `main()` start (kernel boot duration).
137/// - `init_time_ns`: nanoseconds spent in `init::init()`.
138pub async fn run(
139    boot_time_ns: u64,
140    init_time_ns: u64,
141    config: &AgentdConfig,
142    port_file: File,
143) -> AgentdResult<()> {
144    // Set non-blocking for async I/O. Early boot handshakes use the same fd
145    // in blocking mode before it is moved into the async loop.
146    let port_fd = port_file.as_raw_fd();
147    set_nonblocking(port_fd)?;
148
149    // A single AsyncFd tracks both readable and writable readiness.
150    let async_port = AsyncFd::new(port_file)?;
151
152    // Buffer for serial reads.
153    let mut read_buf = vec![0u8; SERIAL_READ_BUF_SIZE];
154    let mut serial_in_buf = Vec::new();
155    let mut serial_out_buf = Vec::new();
156
157    let mut state = AgentState::default();
158
159    // Channel for session output events.
160    let (session_tx, mut session_rx) = mpsc::unbounded_channel::<(u32, SessionOutput)>();
161
162    // Heartbeat/activity state.
163    let mut activity = ActivityTracker::new();
164    let (heartbeat_tx, heartbeat_rx) = watch::channel(heartbeat_snapshot(&state, &activity));
165    // The liveness pulse runs on a dedicated OS thread, NOT a Tokio task. On the
166    // single-threaded agent runtime a flood of exec output can monopolize the
167    // executor and starve a heartbeat *task*, freezing the pulse even though the
168    // agent is alive — which makes the host wrongly declare it unresponsive and
169    // kill the sandbox. A plain OS thread is scheduled by the guest kernel
170    // independently of the async runtime, so the pulse keeps ticking under load.
171    let heartbeat_shutdown = Arc::new(AtomicBool::new(false));
172    let heartbeat_thread = spawn_heartbeat_thread(heartbeat_rx, Arc::clone(&heartbeat_shutdown));
173
174    // Send core.ready with boot timing data.
175    let ready_time_ns = clock::boottime_ns();
176    let ready_msg = Message::with_payload(
177        MessageType::Ready,
178        0,
179        &Ready {
180            boot_time_ns,
181            init_time_ns,
182            ready_time_ns,
183            agent_version: env!("CARGO_PKG_VERSION").to_string(),
184        },
185    )
186    .map_err(|e| AgentdError::ExecSession(format!("encode ready: {e}")))?;
187    codec::encode_to_buf(&ready_msg, &mut serial_out_buf)
188        .map_err(|e| AgentdError::ExecSession(format!("encode ready frame: {e}")))?;
189    flush_write_buf(&async_port, &mut serial_out_buf).await?;
190
191    // Main loop.
192    'agent: loop {
193        tokio::select! {
194            // Read from serial port.
195            result = async_port.readable() => {
196                let Ok(mut guard) = result else {
197                    break;
198                };
199
200                loop {
201                    match guard.try_io(|inner| read_from_fd(inner.get_ref().as_raw_fd(), &mut read_buf)) {
202                        Ok(Ok(0)) => {
203                            // EOF on serial — host disconnected.
204                            if !handoff::is_pid_1() {
205                                guard.clear_ready();
206                                drop(guard);
207                                time::sleep(Duration::from_millis(100)).await;
208                                break;
209                            }
210                            break 'agent;
211                        }
212                        Ok(Ok(n)) => {
213                            serial_in_buf.extend_from_slice(&read_buf[..n]);
214
215                            // Guard against unbounded buffer growth.
216                            if serial_in_buf.len() > MAX_INPUT_BUF_SIZE {
217                                return Err(AgentdError::ExecSession(
218                                    "serial input buffer exceeded maximum size".into(),
219                                ));
220                            }
221
222                            // Try to parse complete frames. Recoverable
223                            // message-level failures are reported on the same
224                            // correlation ID with `core.error`; unrecoverable
225                            // frame-level failures still close the agent loop.
226                            while let Some(frame) = codec::try_decode_raw_from_buf(&mut serial_in_buf)
227                                .map_err(|e| AgentdError::ExecSession(format!("decode frame: {e}")))?
228                            {
229                                let id = frame.id;
230                                let msg = match codec::raw_frame_to_message(frame) {
231                                    Ok(msg) => msg,
232                                    Err(e) => {
233                                        return Err(AgentdError::ExecSession(format!(
234                                            "decode message for id {id}: {e}"
235                                        )));
236                                    }
237                                };
238
239                                if msg.flags != msg.t.flags() {
240                                    let out_before = serial_out_buf.len();
241                                    encode_core_error_if_supported(
242                                        &msg,
243                                        msg.id,
244                                        CoreErrorKind::InvalidFlags,
245                                        format!(
246                                            "invalid flags for {}: got {}, expected {}",
247                                            msg.t.as_str(),
248                                            msg.flags,
249                                            msg.t.flags()
250                                        ),
251                                        Some(msg.t.as_str().to_string()),
252                                        &mut serial_out_buf,
253                                    )?;
254                                    record_encoded_guest_messages(
255                                        &serial_out_buf,
256                                        out_before,
257                                        &mut activity,
258                                    );
259                                    publish_heartbeat_snapshot(&heartbeat_tx, &state, &activity);
260                                    continue;
261                                }
262
263                                if message_refreshes_idle_timer(&msg.t) {
264                                    activity.record_host_message();
265                                    publish_heartbeat_snapshot(&heartbeat_tx, &state, &activity);
266                                }
267
268                                let out_before = serial_out_buf.len();
269                                handle_message(
270                                    msg,
271                                    &mut state,
272                                    &mut activity,
273                                    &session_tx,
274                                    &mut serial_out_buf,
275                                    config,
276                                ).await?;
277                                record_encoded_guest_messages(
278                                    &serial_out_buf,
279                                    out_before,
280                                    &mut activity,
281                                );
282                                publish_heartbeat_snapshot(&heartbeat_tx, &state, &activity);
283                            }
284
285                            // Flush any outgoing messages.
286                            if !serial_out_buf.is_empty() {
287                                flush_write_buf(&async_port, &mut serial_out_buf).await?;
288                            }
289                        }
290                        Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => continue,
291                        Ok(Err(_)) if !handoff::is_pid_1() => {
292                            guard.clear_ready();
293                            drop(guard);
294                            time::sleep(Duration::from_millis(100)).await;
295                            break;
296                        }
297                        Ok(Err(e)) => return Err(e.into()),
298                        Err(_would_block) => break,
299                    }
300                }
301            }
302
303            // Receive output events from session reader tasks.
304            Some((id, output)) = session_rx.recv() => {
305                match output {
306                    SessionOutput::Stdout(data) => {
307                        let len = data.len();
308                        let msg = Message::with_payload(MessageType::ExecStdout, id, &ExecStdout { data })
309                            .map_err(|e| AgentdError::ExecSession(format!("encode stdout: {e}")))?;
310                        codec::encode_to_buf(&msg, &mut serial_out_buf)
311                            .map_err(|e| AgentdError::ExecSession(format!("encode stdout frame: {e}")))?;
312                        activity.record_guest_message();
313                        activity.add_exec_output_bytes(len);
314                    }
315                    SessionOutput::Stderr(data) => {
316                        let len = data.len();
317                        let msg = Message::with_payload(MessageType::ExecStderr, id, &ExecStderr { data })
318                            .map_err(|e| AgentdError::ExecSession(format!("encode stderr: {e}")))?;
319                        codec::encode_to_buf(&msg, &mut serial_out_buf)
320                            .map_err(|e| AgentdError::ExecSession(format!("encode stderr frame: {e}")))?;
321                        activity.record_guest_message();
322                        activity.add_exec_output_bytes(len);
323                    }
324                    SessionOutput::Exited(code) => {
325                        let msg = Message::with_payload(MessageType::ExecExited, id, &ExecExited { code })
326                            .map_err(|e| AgentdError::ExecSession(format!("encode exited: {e}")))?;
327                        codec::encode_to_buf(&msg, &mut serial_out_buf)
328                            .map_err(|e| AgentdError::ExecSession(format!("encode exited frame: {e}")))?;
329                        state.sessions.remove(&id);
330                        activity.record_guest_message();
331                    }
332                    SessionOutput::Raw(output) => {
333                        apply_raw_activity(output.activity, &mut activity);
334                        complete_raw_session(
335                            id,
336                            output.completion,
337                            &mut state.read_sessions,
338                            &mut state.tcp_sessions,
339                        );
340                        // Pre-encoded frame — write directly to output buffer.
341                        serial_out_buf.extend_from_slice(&output.frame);
342                    }
343                }
344                publish_heartbeat_snapshot(&heartbeat_tx, &state, &activity);
345
346                if !serial_out_buf.is_empty() {
347                    flush_write_buf(&async_port, &mut serial_out_buf).await?;
348                }
349            }
350        }
351    }
352
353    heartbeat_shutdown.store(true, Ordering::Relaxed);
354    let _ = heartbeat_thread.join();
355
356    Ok(())
357}
358
359/// Opens the agent virtio-serial port once for early boot handshakes and the agent loop.
360pub fn open_serial_port() -> AgentdResult<File> {
361    // Discover serial port.
362    let port_path = serial::find_serial_port(AGENT_PORT_NAME)?;
363
364    // Open the port once with read+write. Virtio-console multiport devices
365    // only allow a single open; a second open returns EBUSY.
366    Ok(OpenOptions::new().read(true).write(true).open(&port_path)?)
367}
368
369/// Reports init-time guest context to the host and waits for an acknowledgement.
370pub fn report_init_context(port_file: &File, default_user: Option<&str>) -> AgentdResult<()> {
371    let (uid, gid) = resolve_default_user(default_user)?;
372    let deadline = init_ack_deadline();
373    let fd = port_file.as_raw_fd();
374    set_nonblocking(fd)?;
375
376    let msg = Message::with_payload(
377        MessageType::InitResolved,
378        0,
379        &InitResolved {
380            default_user: ResolvedUser { uid, gid },
381        },
382    )
383    .map_err(|e| AgentdError::ExecSession(format!("encode init context: {e}")))?;
384
385    let mut out = Vec::new();
386    codec::encode_to_buf(&msg, &mut out)
387        .map_err(|e| AgentdError::ExecSession(format!("encode init context frame: {e}")))?;
388    write_all_to_fd(fd, &out, deadline)?;
389    wait_for_init_ack(fd, deadline)
390}
391
392//--------------------------------------------------------------------------------------------------
393// Functions: Helpers
394//--------------------------------------------------------------------------------------------------
395
396/// Handles a single incoming message from the host.
397async fn handle_message(
398    msg: Message,
399    state: &mut AgentState,
400    activity: &mut ActivityTracker,
401    session_tx: &mpsc::UnboundedSender<(u32, SessionOutput)>,
402    out_buf: &mut Vec<u8>,
403    config: &AgentdConfig,
404) -> AgentdResult<()> {
405    match msg.t {
406        MessageType::Ping => {
407            let Some(_) = decode_payload_or_core_error::<Ping>(&msg, out_buf)? else {
408                return Ok(());
409            };
410            let reply = Message::with_payload(MessageType::Pong, msg.id, &Pong {})
411                .map_err(|e| AgentdError::ExecSession(format!("encode pong: {e}")))?;
412            codec::encode_to_buf(&reply, out_buf)
413                .map_err(|e| AgentdError::ExecSession(format!("encode pong frame: {e}")))?;
414        }
415
416        MessageType::Touch => {
417            let Some(_) = decode_payload_or_core_error::<Touch>(&msg, out_buf)? else {
418                return Ok(());
419            };
420            activity.record_host_message();
421            let reply = Message::with_payload(
422                MessageType::Touched,
423                msg.id,
424                &Touched {
425                    activity_seq: activity.activity_seq,
426                },
427            )
428            .map_err(|e| AgentdError::ExecSession(format!("encode touched: {e}")))?;
429            codec::encode_to_buf(&reply, out_buf)
430                .map_err(|e| AgentdError::ExecSession(format!("encode touched frame: {e}")))?;
431        }
432
433        MessageType::ExecRequest => {
434            let Some(mut req) = decode_payload_or_core_error::<ExecRequest>(&msg, out_buf)? else {
435                return Ok(());
436            };
437            prepend_scripts_to_path(&mut req);
438            match ExecSession::spawn(
439                msg.id,
440                &req,
441                session_tx.clone(),
442                config.user.as_deref(),
443                config.security_profile,
444            ) {
445                Ok(session) => {
446                    let reply = Message::with_payload(
447                        MessageType::ExecStarted,
448                        msg.id,
449                        &ExecStarted { pid: session.pid() },
450                    )
451                    .map_err(|e| AgentdError::ExecSession(format!("encode started: {e}")))?;
452                    codec::encode_to_buf(&reply, out_buf).map_err(|e| {
453                        AgentdError::ExecSession(format!("encode started frame: {e}"))
454                    })?;
455                    state.sessions.insert(msg.id, session);
456                }
457                Err(e) => {
458                    // Send a typed `ExecFailed` so the host can render a
459                    // useful message + hint. `ExecSpawnFailed` already
460                    // carries the structured payload; other error
461                    // variants (free-form `ExecSession(_)` etc.) get
462                    // wrapped as `Other` with the message preserved.
463                    let payload = match &e {
464                        AgentdError::ExecSpawnFailed(p) => p.clone(),
465                        other => ExecFailed {
466                            kind: ExecFailureKind::Other,
467                            errno: None,
468                            errno_name: None,
469                            message: other.to_string(),
470                            stage: None,
471                        },
472                    };
473                    let reply = Message::with_payload(MessageType::ExecFailed, msg.id, &payload)
474                        .map_err(|e| AgentdError::ExecSession(format!("encode failed: {e}")))?;
475                    codec::encode_to_buf(&reply, out_buf).map_err(|e| {
476                        AgentdError::ExecSession(format!("encode failed frame: {e}"))
477                    })?;
478                    eprintln!("failed to spawn exec session {}: {e}", msg.id);
479                }
480            }
481        }
482
483        MessageType::ExecStdin => {
484            let Some(stdin) = decode_payload_or_core_error::<ExecStdin>(&msg, out_buf)? else {
485                return Ok(());
486            };
487            if let Some(session) = state.sessions.get_mut(&msg.id) {
488                if stdin.data.is_empty() {
489                    // Empty data signals EOF — close stdin.
490                    session.close_stdin();
491                } else if let Err(e) = session.write_stdin(&stdin.data).await {
492                    let payload = stdin_error_payload(&e);
493                    eprintln!("stdin write error on session {}: {e}", msg.id);
494                    let reply =
495                        Message::with_payload(MessageType::ExecStdinError, msg.id, &payload)
496                            .map_err(|e| {
497                                AgentdError::ExecSession(format!("encode stdin error: {e}"))
498                            })?;
499                    codec::encode_to_buf(&reply, out_buf).map_err(|e| {
500                        AgentdError::ExecSession(format!("encode stdin error frame: {e}"))
501                    })?;
502                }
503            }
504        }
505
506        MessageType::ExecResize => {
507            let Some(resize) = decode_payload_or_core_error::<ExecResize>(&msg, out_buf)? else {
508                return Ok(());
509            };
510            if let Some(session) = state.sessions.get(&msg.id) {
511                let _ = session.resize(resize.rows, resize.cols);
512            }
513        }
514
515        MessageType::ExecSignal => {
516            let Some(signal) = decode_payload_or_core_error::<ExecSignal>(&msg, out_buf)? else {
517                return Ok(());
518            };
519            if let Some(session) = state.sessions.get(&msg.id) {
520                let _ = session.send_signal(signal.signal);
521            }
522        }
523
524        MessageType::FsRequest => {
525            let Some(req) = decode_payload_or_core_error::<FsRequest>(&msg, out_buf)? else {
526                return Ok(());
527            };
528            match fs::handle_fs_request(msg.id, req, &mut state.fs, out_buf, session_tx).await {
529                Ok(Some(FsStreamSession::Read(rs))) => {
530                    state.read_sessions.insert(msg.id, rs);
531                }
532                Ok(Some(FsStreamSession::Write(ws))) => {
533                    state.write_sessions.insert(msg.id, ws);
534                }
535                Ok(None) => {}
536                Err(e) => {
537                    eprintln!("fs request error for {}: {e}", msg.id);
538                }
539            }
540        }
541
542        MessageType::FsData => {
543            let Some(data) = decode_payload_or_core_error::<FsData>(&msg, out_buf)? else {
544                return Ok(());
545            };
546            let len = data.data.len();
547            if let Some(session) = state.write_sessions.get_mut(&msg.id) {
548                match fs::handle_fs_data(msg.id, data, session, out_buf).await {
549                    Ok(true) => {
550                        // Session complete — remove it.
551                        state.write_sessions.remove(&msg.id);
552                    }
553                    Ok(false) => {
554                        activity.add_fs_bytes(len);
555                    }
556                    Err(e) => {
557                        eprintln!("fs data error for {}: {e}", msg.id);
558                        state.write_sessions.remove(&msg.id);
559                    }
560                }
561            } else {
562                // No write session for this ID — send error response.
563                let resp = microsandbox_protocol::fs::FsResponse {
564                    ok: false,
565                    error: Some(format!("unknown write session: {}", msg.id)),
566                    data: None,
567                };
568                let reply = Message::with_payload(MessageType::FsResponse, msg.id, &resp)
569                    .map_err(|e| AgentdError::ExecSession(format!("encode fs error: {e}")))?;
570                codec::encode_to_buf(&reply, out_buf)
571                    .map_err(|e| AgentdError::ExecSession(format!("encode fs error frame: {e}")))?;
572            }
573        }
574
575        MessageType::TcpConnect => {
576            let Some(req) = decode_payload_or_core_error::<TcpConnect>(&msg, out_buf)? else {
577                return Ok(());
578            };
579            // The connect runs inside the session task; the agent loop never
580            // blocks on it. Success or failure arrives later as a tcp frame.
581            let session = TcpSession::open(msg.id, req, session_tx);
582            state.tcp_sessions.insert(msg.id, session);
583        }
584
585        MessageType::TcpData => {
586            let Some(data) = decode_payload_or_core_error::<TcpData>(&msg, out_buf)? else {
587                return Ok(());
588            };
589            let len = data.data.len();
590            if let Some(session) = state.tcp_sessions.get(&msg.id) {
591                if let Err(e) = session.write_data(data.data).await {
592                    state.tcp_sessions.remove(&msg.id);
593                    encode_tcp_failed(msg.id, e, out_buf)?;
594                } else {
595                    activity.add_tcp_bytes(len);
596                }
597            } else {
598                encode_tcp_failed(msg.id, format!("unknown TCP session: {}", msg.id), out_buf)?;
599            }
600        }
601
602        MessageType::TcpEof => {
603            let Some(_) = decode_payload_or_core_error::<TcpEof>(&msg, out_buf)? else {
604                return Ok(());
605            };
606            if let Some(session) = state.tcp_sessions.get(&msg.id)
607                && let Err(e) = session.close_write().await
608            {
609                state.tcp_sessions.remove(&msg.id);
610                encode_tcp_failed(msg.id, e, out_buf)?;
611            }
612        }
613
614        MessageType::TcpClose => {
615            let Some(_) = decode_payload_or_core_error::<TcpClose>(&msg, out_buf)? else {
616                return Ok(());
617            };
618            if let Some(session) = state.tcp_sessions.remove(&msg.id) {
619                session.close();
620            }
621        }
622
623        MessageType::RelayClientDisconnected => {
624            let Some(disconnected) =
625                decode_payload_or_core_error::<RelayClientDisconnected>(&msg, out_buf)?
626            else {
627                return Ok(());
628            };
629            state
630                .fs
631                .close_owner_range(disconnected.id_start, disconnected.id_end_exclusive);
632            abort_read_sessions_in_owner_range(
633                &mut state.read_sessions,
634                disconnected.id_start,
635                disconnected.id_end_exclusive,
636            );
637            state.write_sessions.retain(|_, session| {
638                let owner_id = session.owner_id();
639                owner_id < disconnected.id_start || owner_id >= disconnected.id_end_exclusive
640            });
641            close_tcp_sessions_in_owner_range(
642                &mut state.tcp_sessions,
643                disconnected.id_start,
644                disconnected.id_end_exclusive,
645            );
646        }
647
648        MessageType::ClockSync => {
649            let Some(sync) = decode_payload_or_core_error::<ClockSync>(&msg, out_buf)? else {
650                return Ok(());
651            };
652            if let Err(e) = clock::sync_realtime_unix_nanos(sync.unix_time_nanos) {
653                eprintln!("clock: failed to sync realtime clock: {e}");
654            }
655        }
656
657        MessageType::Shutdown => {
658            // Graceful shutdown — signal all sessions, then ask the guest
659            // kernel to power off so block-root filesystems can shut down
660            // cleanly instead of leaving ext4 journal recovery pending.
661            for (_, session) in state.sessions.drain() {
662                let _ = session.send_signal(15); // SIGTERM
663            }
664            state.write_sessions.clear();
665            for (_, session) in state.tcp_sessions.drain() {
666                session.close();
667            }
668            state.fs.clear();
669
670            request_guest_poweroff()?;
671            return Err(AgentdError::Shutdown);
672        }
673
674        _ => {
675            // Ignore unknown or unexpected message types.
676        }
677    }
678
679    Ok(())
680}
681
682/// Prepends `/.msb/scripts` to PATH in the exec request's environment.
683///
684/// If the request already has a PATH entry, prepends to it. Otherwise
685/// inherits from agentd's environment and prepends.
686/// Default PATH for the guest when no PATH is inherited.
687const DEFAULT_GUEST_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
688
689/// Returns whether a host message should refresh the sandbox idle timer.
690///
691/// Maintenance traffic such as clock synchronization and reachability checks
692/// must not count as user activity, otherwise periodic host tasks would keep an
693/// idle sandbox alive. `core.touch` is excluded here too because it refreshes
694/// idleness explicitly in its handler, after its payload has been validated.
695fn message_refreshes_idle_timer(t: &MessageType) -> bool {
696    !matches!(
697        t,
698        MessageType::ClockSync | MessageType::Ping | MessageType::Touch
699    )
700}
701
702/// Returns whether an agent reply should refresh the sandbox idle timer.
703///
704/// Most guest output still represents useful sandbox activity. Maintenance
705/// replies to `core.ping` and `core.touch` are excluded so `ping` is a pure
706/// health check and `touch` advances activity exactly once. `core.error` is
707/// also excluded because valid work already records activity on the incoming
708/// request, while malformed maintenance traffic should not become a keepalive.
709fn guest_message_refreshes_idle_timer(t: &MessageType) -> bool {
710    !matches!(
711        t,
712        MessageType::Pong | MessageType::Touched | MessageType::CoreError
713    )
714}
715
716/// Spawns the heartbeat pulse on a dedicated OS thread.
717///
718/// This thread is intentionally outside the Tokio runtime: it reads the latest
719/// [`HeartbeatSnapshot`] (a lock-free `watch` borrow) and writes the heartbeat
720/// file with blocking `std::fs` once per [`HEARTBEAT_INTERVAL_SECS`]. Because it
721/// is an ordinary kernel-scheduled thread, a CPU-bound or I/O-saturated async
722/// runtime cannot delay the pulse — which is exactly the starvation that made
723/// the host kill busy-but-healthy sandboxes. The sleep is chunked so the thread
724/// observes the shutdown flag promptly when the agent loop exits.
725fn spawn_heartbeat_thread(
726    snapshot_rx: watch::Receiver<HeartbeatSnapshot>,
727    shutdown: Arc<AtomicBool>,
728) -> std::thread::JoinHandle<()> {
729    std::thread::Builder::new()
730        .name("agentd-heartbeat".to_string())
731        .spawn(move || {
732            let mut heartbeat_seq = 0u64;
733            let mut last_activity_seq = snapshot_rx.borrow().activity_seq;
734            let mut last_activity = Utc::now();
735
736            let interval = Duration::from_secs(HEARTBEAT_INTERVAL_SECS);
737            let step = Duration::from_millis(100);
738
739            while !shutdown.load(Ordering::Relaxed) {
740                let mut slept = Duration::ZERO;
741                while slept < interval {
742                    if shutdown.load(Ordering::Relaxed) {
743                        return;
744                    }
745                    std::thread::sleep(step);
746                    slept += step;
747                }
748
749                if !heartbeat::heartbeat_dir_exists() {
750                    continue;
751                }
752
753                heartbeat_seq = heartbeat_seq.saturating_add(1);
754                let snapshot = snapshot_rx.borrow().clone();
755                let timestamp = Utc::now();
756                if snapshot.activity_seq != last_activity_seq {
757                    last_activity_seq = snapshot.activity_seq;
758                    last_activity = timestamp;
759                }
760                let heartbeat = Heartbeat {
761                    heartbeat_seq,
762                    activity_seq: snapshot.activity_seq,
763                    timestamp,
764                    last_activity,
765                    active_exec_sessions: snapshot.active_exec_sessions,
766                    active_fs_streams: snapshot.active_fs_streams,
767                    active_tcp_streams: snapshot.active_tcp_streams,
768                    activity_counters: snapshot.counters,
769                };
770                let _ = heartbeat::write_heartbeat(&heartbeat);
771            }
772        })
773        .expect("failed to spawn agentd heartbeat thread")
774}
775
776fn heartbeat_snapshot(state: &AgentState, activity: &ActivityTracker) -> HeartbeatSnapshot {
777    HeartbeatSnapshot {
778        activity_seq: activity.activity_seq,
779        active_exec_sessions: state.sessions.len() as u32,
780        active_fs_streams: state
781            .read_sessions
782            .len()
783            .saturating_add(state.write_sessions.len()) as u32,
784        active_tcp_streams: state.tcp_sessions.len() as u32,
785        counters: activity.counters,
786    }
787}
788
789fn publish_heartbeat_snapshot(
790    heartbeat_tx: &watch::Sender<HeartbeatSnapshot>,
791    state: &AgentState,
792    activity: &ActivityTracker,
793) {
794    let _ = heartbeat_tx.send(heartbeat_snapshot(state, activity));
795}
796
797fn record_encoded_guest_messages(out_buf: &[u8], start: usize, activity: &mut ActivityTracker) {
798    let mut offset = start;
799    while offset + 4 <= out_buf.len() {
800        let frame_len = u32::from_be_bytes([
801            out_buf[offset],
802            out_buf[offset + 1],
803            out_buf[offset + 2],
804            out_buf[offset + 3],
805        ]) as usize;
806        let total = 4usize.saturating_add(frame_len);
807        if offset.saturating_add(total) > out_buf.len() {
808            break;
809        }
810
811        if encoded_guest_message_refreshes_idle_timer(out_buf, offset, frame_len) {
812            activity.record_guest_message();
813        }
814        offset += total;
815    }
816}
817
818fn encoded_guest_message_refreshes_idle_timer(
819    out_buf: &[u8],
820    offset: usize,
821    frame_len: usize,
822) -> bool {
823    if frame_len < microsandbox_protocol::message::FRAME_HEADER_SIZE {
824        return true;
825    }
826
827    let id_start = offset + 4;
828    let flags_index = id_start + 4;
829    let body_start = flags_index + 1;
830    let body_end = offset + 4 + frame_len;
831    if body_end > out_buf.len() || body_start > body_end {
832        return true;
833    }
834
835    let id = u32::from_be_bytes([
836        out_buf[id_start],
837        out_buf[id_start + 1],
838        out_buf[id_start + 2],
839        out_buf[id_start + 3],
840    ]);
841    let frame = codec::RawFrame {
842        id,
843        flags: out_buf[flags_index],
844        body: out_buf[body_start..body_end].to_vec(),
845    };
846
847    codec::raw_frame_to_message(frame)
848        .map(|msg| guest_message_refreshes_idle_timer(&msg.t))
849        .unwrap_or(true)
850}
851
852fn apply_raw_activity(raw: RawActivity, activity: &mut ActivityTracker) {
853    if raw.guest_message {
854        activity.record_guest_message();
855    }
856    if raw.fs_bytes > 0 {
857        activity.add_fs_bytes(raw.fs_bytes);
858    }
859    if raw.tcp_bytes > 0 {
860        activity.add_tcp_bytes(raw.tcp_bytes);
861    }
862}
863
864fn complete_raw_session(
865    id: u32,
866    completion: Option<RawSessionCompletion>,
867    read_sessions: &mut HashMap<u32, FsReadSession>,
868    tcp_sessions: &mut HashMap<u32, TcpSession>,
869) {
870    match completion {
871        Some(RawSessionCompletion::FsRead) => {
872            read_sessions.remove(&id);
873        }
874        Some(RawSessionCompletion::Tcp) => {
875            tcp_sessions.remove(&id);
876        }
877        None => {}
878    }
879}
880
881fn abort_read_sessions_in_owner_range(
882    read_sessions: &mut HashMap<u32, FsReadSession>,
883    id_start: u32,
884    id_end_exclusive: u32,
885) {
886    let mut retained = HashMap::new();
887    for (id, session) in read_sessions.drain() {
888        let owner_id = session.owner_id();
889        if owner_id >= id_start && owner_id < id_end_exclusive {
890            session.abort();
891        } else {
892            retained.insert(id, session);
893        }
894    }
895    *read_sessions = retained;
896}
897
898fn close_tcp_sessions_in_owner_range(
899    tcp_sessions: &mut HashMap<u32, TcpSession>,
900    id_start: u32,
901    id_end_exclusive: u32,
902) {
903    let mut retained = HashMap::new();
904    for (id, session) in tcp_sessions.drain() {
905        let owner_id = session.owner_id();
906        if owner_id >= id_start && owner_id < id_end_exclusive {
907            session.close();
908        } else {
909            retained.insert(id, session);
910        }
911    }
912    *tcp_sessions = retained;
913}
914
915fn encode_tcp_failed(id: u32, error: String, out_buf: &mut Vec<u8>) -> AgentdResult<()> {
916    let reply = Message::with_payload(MessageType::TcpFailed, id, &TcpFailed { error })
917        .map_err(|e| AgentdError::ExecSession(format!("encode tcp failed: {e}")))?;
918    codec::encode_to_buf(&reply, out_buf)
919        .map_err(|e| AgentdError::ExecSession(format!("encode tcp failed frame: {e}")))?;
920    Ok(())
921}
922
923fn encode_core_error_if_supported(
924    source: &Message,
925    id: u32,
926    kind: CoreErrorKind,
927    message: String,
928    offending_type: Option<String>,
929    out_buf: &mut Vec<u8>,
930) -> AgentdResult<()> {
931    if !MessageType::CoreError.is_available_at(source.v) {
932        return Err(AgentdError::ExecSession(format!(
933            "cannot send core.error to protocol generation {}",
934            source.v
935        )));
936    }
937
938    encode_core_error(id, kind, message, offending_type, out_buf)
939}
940
941fn encode_core_error(
942    id: u32,
943    kind: CoreErrorKind,
944    message: String,
945    offending_type: Option<String>,
946    out_buf: &mut Vec<u8>,
947) -> AgentdResult<()> {
948    let reply = Message::with_payload(
949        MessageType::CoreError,
950        id,
951        &CoreError {
952            kind,
953            message,
954            offending_type,
955        },
956    )
957    .map_err(|e| AgentdError::ExecSession(format!("encode core error: {e}")))?;
958    codec::encode_to_buf(&reply, out_buf)
959        .map_err(|e| AgentdError::ExecSession(format!("encode core error frame: {e}")))?;
960    Ok(())
961}
962
963fn decode_payload_or_core_error<T>(msg: &Message, out_buf: &mut Vec<u8>) -> AgentdResult<Option<T>>
964where
965    T: serde::de::DeserializeOwned,
966{
967    match msg.payload::<T>() {
968        Ok(payload) => Ok(Some(payload)),
969        Err(error) => {
970            encode_core_error_if_supported(
971                msg,
972                msg.id,
973                CoreErrorKind::InvalidPayload,
974                format!("decode payload for {}: {error}", msg.t.as_str()),
975                Some(msg.t.as_str().to_string()),
976                out_buf,
977            )?;
978            Ok(None)
979        }
980    }
981}
982
983/// Build an `ExecStdinError` payload from a failed `write_stdin` result.
984fn stdin_error_payload(err: &AgentdError) -> ExecStdinError {
985    let io_err = match err {
986        AgentdError::Io(e) => Some(e),
987        _ => None,
988    };
989    let errno = io_err.and_then(|e| e.raw_os_error());
990    ExecStdinError {
991        errno,
992        errno_name: errno.and_then(errno_name),
993        message: err.to_string(),
994    }
995}
996
997/// Map common errno values to their standard names. Returns `None` for
998/// codes we don't recognize; callers fall back to the numeric `errno`.
999fn errno_name(code: i32) -> Option<String> {
1000    let name = match code {
1001        libc::EPIPE => "EPIPE",
1002        libc::EBADF => "EBADF",
1003        libc::EINVAL => "EINVAL",
1004        libc::EIO => "EIO",
1005        libc::ENOSPC => "ENOSPC",
1006        libc::EFBIG => "EFBIG",
1007        _ => return None,
1008    };
1009    Some(name.to_string())
1010}
1011
1012fn prepend_scripts_to_path(req: &mut microsandbox_protocol::exec::ExecRequest) {
1013    let scripts = microsandbox_protocol::SCRIPTS_PATH;
1014
1015    // Check if the request already specifies PATH.
1016    if let Some(entry) = req.env.iter_mut().find(|e| e.starts_with("PATH=")) {
1017        let existing = &entry["PATH=".len()..];
1018        *entry = format!("PATH={scripts}:{existing}");
1019    } else {
1020        // Inherit from agentd's process environment, falling back to a
1021        // sensible default since PID 1 in a minimal guest may not have PATH.
1022        let inherited = env::var("PATH").unwrap_or_else(|_| DEFAULT_GUEST_PATH.to_string());
1023        req.env.push(format!("PATH={scripts}:{inherited}"));
1024    }
1025}
1026
1027/// Sets a file descriptor to non-blocking mode.
1028fn set_nonblocking(fd: i32) -> AgentdResult<()> {
1029    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
1030    if flags < 0 {
1031        return Err(std::io::Error::last_os_error().into());
1032    }
1033    let ret = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
1034    if ret < 0 {
1035        return Err(std::io::Error::last_os_error().into());
1036    }
1037    Ok(())
1038}
1039
1040fn init_ack_deadline() -> Instant {
1041    Instant::now() + std::time::Duration::from_secs(INIT_ACK_TIMEOUT_SECS)
1042}
1043
1044fn init_ack_timeout() -> AgentdError {
1045    AgentdError::ExecSession("timed out waiting for init ack".into())
1046}
1047
1048fn wait_for_init_ack(fd: i32, deadline: Instant) -> AgentdResult<()> {
1049    let mut serial_in_buf = Vec::new();
1050    let mut read_buf = [0u8; 4096];
1051
1052    loop {
1053        if let Some(msg) = codec::try_decode_from_buf(&mut serial_in_buf)
1054            .map_err(|e| AgentdError::ExecSession(format!("decode init ack: {e}")))?
1055        {
1056            if msg.t == MessageType::InitAck {
1057                let _: InitAck = msg.payload().map_err(|e| {
1058                    AgentdError::ExecSession(format!("decode init ack payload: {e}"))
1059                })?;
1060                return Ok(());
1061            }
1062
1063            return Err(AgentdError::ExecSession(format!(
1064                "expected core.init.ack, got {}",
1065                msg.t.as_str()
1066            )));
1067        }
1068
1069        if serial_in_buf.len() > MAX_INPUT_BUF_SIZE {
1070            return Err(AgentdError::ExecSession(
1071                "serial input buffer exceeded maximum size while waiting for init ack".into(),
1072            ));
1073        }
1074
1075        if !poll_fd_until(fd, libc::POLLIN, deadline)? {
1076            return Err(init_ack_timeout());
1077        }
1078
1079        let n = match read_from_fd(fd, &mut read_buf) {
1080            Ok(n) => n,
1081            Err(e)
1082                if matches!(
1083                    e.kind(),
1084                    std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
1085                ) =>
1086            {
1087                continue;
1088            }
1089            Err(e) => return Err(e.into()),
1090        };
1091        if n == 0 {
1092            return Err(AgentdError::ExecSession(
1093                "serial port closed while waiting for init ack".into(),
1094            ));
1095        }
1096        serial_in_buf.extend_from_slice(&read_buf[..n]);
1097    }
1098}
1099
1100fn poll_fd_until(fd: i32, events: i16, deadline: Instant) -> AgentdResult<bool> {
1101    loop {
1102        let remaining = deadline.saturating_duration_since(Instant::now());
1103        if remaining.is_zero() {
1104            return Ok(false);
1105        }
1106
1107        let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
1108        let timeout_ms = if timeout_ms == 0 { 1 } else { timeout_ms };
1109        let mut pfd = libc::pollfd {
1110            fd,
1111            events,
1112            revents: 0,
1113        };
1114        let ret = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
1115        if ret > 0 {
1116            return Ok(true);
1117        }
1118        if ret == 0 {
1119            return Ok(false);
1120        }
1121        let err = std::io::Error::last_os_error();
1122        if err.raw_os_error() == Some(libc::EINTR) {
1123            continue;
1124        }
1125        return Err(err.into());
1126    }
1127}
1128
1129/// Reads from a raw fd (non-blocking).
1130fn read_from_fd(fd: i32, buf: &mut [u8]) -> std::io::Result<usize> {
1131    let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
1132    if n < 0 {
1133        Err(std::io::Error::last_os_error())
1134    } else {
1135        Ok(n as usize)
1136    }
1137}
1138
1139fn write_all_to_fd(fd: i32, mut buf: &[u8], deadline: Instant) -> AgentdResult<()> {
1140    while !buf.is_empty() {
1141        match write_to_fd(fd, buf) {
1142            Ok(0) => return Err(std::io::Error::from(std::io::ErrorKind::WriteZero).into()),
1143            Ok(n) => buf = &buf[n..],
1144            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1145            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1146                if !poll_fd_until(fd, libc::POLLOUT, deadline)? {
1147                    return Err(init_ack_timeout());
1148                }
1149            }
1150            Err(e) => return Err(e.into()),
1151        }
1152    }
1153
1154    Ok(())
1155}
1156
1157/// Flushes the write buffer to the async fd.
1158async fn flush_write_buf(fd: &AsyncFd<std::fs::File>, buf: &mut Vec<u8>) -> AgentdResult<()> {
1159    while !buf.is_empty() {
1160        let mut guard = fd.writable().await?;
1161        match guard.try_io(|inner| write_to_fd(inner.get_ref().as_raw_fd(), buf)) {
1162            Ok(Ok(n)) => {
1163                buf.drain(..n);
1164            }
1165            Ok(Err(e)) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1166            Ok(Err(e)) => return Err(e.into()),
1167            Err(_would_block) => continue,
1168        }
1169    }
1170    Ok(())
1171}
1172
1173/// Writes to a raw fd (non-blocking).
1174fn write_to_fd(fd: i32, buf: &[u8]) -> std::io::Result<usize> {
1175    let n = unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()) };
1176    if n < 0 {
1177        Err(std::io::Error::last_os_error())
1178    } else {
1179        Ok(n as usize)
1180    }
1181}
1182
1183fn request_guest_poweroff() -> AgentdResult<()> {
1184    if crate::handoff::is_pid_1() {
1185        // PID 1 mode (no handoff): tear down filesystems so block-backed
1186        // mounts reach a clean terminal state, then power the kernel off.
1187        crate::teardown::teardown_filesystems(true);
1188        let ret = unsafe { libc::reboot(libc::RB_POWER_OFF) };
1189        if ret != 0 {
1190            return Err(std::io::Error::last_os_error().into());
1191        }
1192        return Ok(());
1193    }
1194
1195    unsafe {
1196        libc::sync();
1197    }
1198
1199    // Handoff mode: ask the new init (PID 1) to shut down.
1200    // SIGRTMIN+4 is systemd's poweroff signal; sysvinit-derived inits
1201    // typically default-handle it as a clean exit. Either way, PID 1
1202    // exiting causes the kernel to panic the guest, which the VMM
1203    // observes as a clean shutdown.
1204    if crate::handoff::signal_init_shutdown().is_ok() {
1205        std::thread::sleep(HANDOFF_POWEROFF_TIMEOUT);
1206    }
1207
1208    // Reaching this point means the init ignored the poweroff request, so
1209    // the guest is going down hard (SIGTERM fallback, then the host's
1210    // VMM-process kill as backstop). Force filesystems toward a clean
1211    // terminal state first — without the process sweep, since the foreign
1212    // init's services are not ours to kill.
1213    crate::teardown::teardown_filesystems(false);
1214
1215    let _ = crate::handoff::signal_init_term();
1216    Ok(())
1217}
1218
1219//--------------------------------------------------------------------------------------------------
1220// Tests
1221//--------------------------------------------------------------------------------------------------
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    #[test]
1228    fn record_encoded_guest_messages_counts_only_appended_frames() {
1229        let mut out_buf = Vec::new();
1230        let existing =
1231            Message::with_payload(MessageType::ExecStarted, 1, &ExecStarted { pid: 123 }).unwrap();
1232        codec::encode_to_buf(&existing, &mut out_buf).unwrap();
1233        let start = out_buf.len();
1234
1235        let appended =
1236            Message::with_payload(MessageType::ExecStarted, 2, &ExecStarted { pid: 456 }).unwrap();
1237        codec::encode_to_buf(&appended, &mut out_buf).unwrap();
1238
1239        let mut activity = ActivityTracker::new();
1240        record_encoded_guest_messages(&out_buf, start, &mut activity);
1241
1242        assert_eq!(activity.activity_seq, 1);
1243        assert_eq!(activity.counters.guest_messages, 1);
1244    }
1245
1246    #[test]
1247    fn apply_raw_activity_updates_guest_and_byte_counters() {
1248        let mut activity = ActivityTracker::new();
1249
1250        apply_raw_activity(RawActivity::fs_bytes(42), &mut activity);
1251        apply_raw_activity(RawActivity::tcp_bytes(7), &mut activity);
1252
1253        assert_eq!(activity.activity_seq, 2);
1254        assert_eq!(activity.counters.guest_messages, 2);
1255        assert_eq!(activity.counters.fs_bytes, 42);
1256        assert_eq!(activity.counters.tcp_bytes, 7);
1257    }
1258
1259    #[test]
1260    fn maintenance_messages_do_not_implicitly_refresh_idle_timer() {
1261        assert!(!message_refreshes_idle_timer(&MessageType::ClockSync));
1262        assert!(!message_refreshes_idle_timer(&MessageType::Ping));
1263        assert!(!message_refreshes_idle_timer(&MessageType::Touch));
1264        assert!(message_refreshes_idle_timer(&MessageType::ExecRequest));
1265    }
1266
1267    #[test]
1268    fn maintenance_replies_do_not_refresh_idle_timer() {
1269        assert!(!guest_message_refreshes_idle_timer(&MessageType::Pong));
1270        assert!(!guest_message_refreshes_idle_timer(&MessageType::Touched));
1271        assert!(!guest_message_refreshes_idle_timer(&MessageType::CoreError));
1272        assert!(guest_message_refreshes_idle_timer(&MessageType::ExecStdout));
1273    }
1274
1275    #[test]
1276    fn record_encoded_guest_messages_ignores_pong_and_touched() {
1277        let mut out_buf = Vec::new();
1278        let pong = Message::with_payload(MessageType::Pong, 1, &Pong {}).unwrap();
1279        codec::encode_to_buf(&pong, &mut out_buf).unwrap();
1280
1281        let touched =
1282            Message::with_payload(MessageType::Touched, 2, &Touched { activity_seq: 42 }).unwrap();
1283        codec::encode_to_buf(&touched, &mut out_buf).unwrap();
1284
1285        let mut activity = ActivityTracker::new();
1286        record_encoded_guest_messages(&out_buf, 0, &mut activity);
1287
1288        assert_eq!(activity.activity_seq, 0);
1289        assert_eq!(activity.counters.guest_messages, 0);
1290    }
1291}