Skip to main content

microsandbox_protocol/
core.rs

1//! Core protocol message payloads.
2
3use microsandbox_types::ExecLimits;
4use serde::{Deserialize, Serialize};
5
6use crate::transport::{BulkTransportReady, LocalTransportReady, RelayLeaseReady};
7
8//--------------------------------------------------------------------------------------------------
9// Constants
10//--------------------------------------------------------------------------------------------------
11
12/// Complete-frame workload barrier with logical control/data admission classes.
13///
14/// Version 1 was an unreleased development contract that charged stdin to control. Its captured
15/// debt cannot be reinterpreted by this contract; full restore rejects that development state.
16pub const WORKLOAD_TRANSPORT_BARRIER_VERSION: u8 = 2;
17/// Maximum outstanding command/control wire bytes, including frame headers.
18pub const WORKLOAD_TRANSPORT_CONTROL_BYTES: u64 = 8 * 1024 * 1024;
19/// Maximum outstanding command/control frames, excluding retained workload payloads.
20pub const WORKLOAD_TRANSPORT_CONTROL_FRAMES: u64 = 256;
21/// Maximum outstanding data wire bytes, including raw bulk, stdin and inline FS/TCP payloads.
22pub const WORKLOAD_TRANSPORT_BULK_BYTES: u64 = 32 * 1024 * 1024;
23/// Maximum outstanding data records/messages, including ordered empty EOF messages.
24///
25/// Together with control frames, this fits the existing 512-entry guest input
26/// queues even when all admitted traffic targets one stalled consumer.
27pub const WORKLOAD_TRANSPORT_BULK_FRAMES: u64 = 256;
28
29//--------------------------------------------------------------------------------------------------
30// Types
31//--------------------------------------------------------------------------------------------------
32
33/// Payload for `core.ready` messages.
34///
35/// Sent by the guest agent to signal that it has finished initialization
36/// and is ready to receive commands. Includes timing data for boot
37/// performance measurement.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct Ready {
40    /// `CLOCK_BOOTTIME` nanoseconds captured at the start of `main()`.
41    ///
42    /// Represents how long the kernel took to boot before userspace started.
43    pub boot_time_ns: u64,
44
45    /// Nanoseconds spent in `init::init()` (mounting filesystems).
46    pub init_time_ns: u64,
47
48    /// `CLOCK_BOOTTIME` nanoseconds captured just before sending this message.
49    ///
50    /// Represents total time from kernel boot to agent readiness.
51    pub ready_time_ns: u64,
52
53    /// The agent's package version (`CARGO_PKG_VERSION`), for diagnostics.
54    ///
55    /// Additive and optional: an older agent that predates this field decodes to
56    /// an empty string, and an older host ignores it. Empty means unknown. This
57    /// is the runtime's self-reported product version; the protocol generation is
58    /// carried separately in the message envelope's `v`.
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub agent_version: String,
61
62    /// Bound internal data-plane topology, when agentd negotiated one at boot.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub bulk_transport: Option<BulkTransportReady>,
65
66    /// Optional topology-independent relay correlation-range lease capability.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub relay_lease: Option<RelayLeaseReady>,
69
70    /// Optional SDK-to-runtime transport capability injected by a local Unix relay.
71    ///
72    /// Agentd leaves this absent because local shared memory is below the guest protocol.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub local_transport: Option<LocalTransportReady>,
75
76    /// Internal host-to-guest complete-frame barriers and aggregate input credit.
77    ///
78    /// Absence does not change ordinary generation-8 clients. Full capture and
79    /// pause require the supported contract instead of assuming frame safety.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub workload_transport_barrier_version: Option<u8>,
82
83    /// Runtime-authorized sandbox execution policy, absent on released older peers.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub exec_limits: Option<ExecLimits>,
86}
87
88/// Payload for `core.clock.sync` messages.
89///
90/// Sent by the host to ask the guest agent to step `CLOCK_REALTIME` to the
91/// host's current wall-clock time.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ClockSync {
94    /// Host Unix timestamp in nanoseconds.
95    pub unix_time_nanos: u64,
96}
97
98/// Payload for `core.ping` messages.
99///
100/// Sent by the host to verify that agentd is reachable. A ping is maintenance
101/// traffic and does not refresh the sandbox idle timer.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Ping {}
104
105/// Payload for `core.pong` messages.
106///
107/// Sent by agentd in response to `core.ping`.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Pong {}
110
111/// Payload for `core.touch` messages.
112///
113/// Sent by the host to explicitly refresh the sandbox idle timer without
114/// starting an exec, filesystem, or TCP session.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Touch {}
117
118/// Payload for `core.touched` messages.
119///
120/// Sent by agentd in response to `core.touch`.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct Touched {
123    /// Activity sequence after the explicit touch was recorded.
124    pub activity_seq: u64,
125}
126
127/// Payload for `core.workload.freeze` messages.
128///
129/// The attempt identity makes retries idempotent and prevents one checkpoint
130/// operation from accidentally releasing another operation's freeze.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct WorkloadFreeze {
133    /// External virtiofs tags whose writeback boundary must be proved by the guest.
134    #[serde(default)]
135    pub external_mount_tags: Vec<String>,
136    /// Stable checkpoint attempt identity selected by the host.
137    pub attempt_id: String,
138    /// Complete ordinary frames admitted by the host before gating user input.
139    pub host_input: WorkloadTransportPosition,
140}
141
142/// Payload for `core.workload.frozen` messages.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct WorkloadFrozen {
145    /// Attempt identity whose workload boundary is now frozen.
146    pub attempt_id: String,
147    /// Complete dedicated bulk wire bytes emitted before the guest writer parked.
148    ///
149    /// Zero for combined transport, whose primary stream already orders output
150    /// before this acknowledgement. The host drains to this cut before pausing.
151    pub guest_bulk_bytes_target: u64,
152    /// Absolute input limits captured with this boundary, not a fresh window.
153    pub input_credit: WorkloadTransportCredit,
154    /// Guest virtiofs dirty pages reached backing storage after the workload freeze.
155    /// Older guests omit this evidence and cannot capture external mounts safely.
156    #[serde(default)]
157    pub external_mounts_synced: bool,
158}
159
160/// Cumulative ordinary input admitted at complete frame or record boundaries.
161///
162/// These counters survive restore. Guest-accepted input is captured guest state;
163/// host-queued input that has not been admitted remains source-owned.
164#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
165pub struct WorkloadTransportPosition {
166    /// Command/control wire bytes admitted, including length/header bytes.
167    /// Payload-bearing messages and raw bulk use `bulk_bytes` on either physical port.
168    pub control_bytes: u64,
169    /// Command/control frames admitted, excluding payload messages and raw bulk.
170    pub control_frames: u64,
171    /// Data wire bytes admitted, including stdin, inline payloads, and complete raw bulk headers.
172    pub bulk_bytes: u64,
173    /// Data records/messages admitted, including ordered EOF.
174    pub bulk_frames: u64,
175}
176
177/// Absolute aggregate input grants in `core.workload.transport.credit`.
178///
179/// Grants advance only as guest consumers release admitted input. Updates may be
180/// coalesced; applying one twice never grants additional capacity. Both byte and
181/// frame limits bound retained data without making lifecycle progress depend on
182/// a workload consuming stdin or a network socket becoming writable.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184pub struct WorkloadTransportCredit {
185    /// Cumulative command/control wire-byte limit.
186    pub control_bytes: u64,
187    /// Cumulative command/control frame limit.
188    pub control_frames: u64,
189    /// Cumulative data wire-byte limit across both physical ports.
190    pub bulk_bytes: u64,
191    /// Cumulative data record/message limit across both physical ports.
192    pub bulk_frames: u64,
193}
194
195/// Payload for `core.workload.thaw` messages.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct WorkloadThaw {
198    /// Attempt identity that established the freeze being released.
199    pub attempt_id: String,
200    /// Continue the source, or activate a restored guest with fresh host-client ownership.
201    pub mode: WorkloadThawMode,
202}
203
204/// How a captured workload returns to execution.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum WorkloadThawMode {
208    /// Continue the source without changing any connected client or stream.
209    Continue,
210    /// Detach inherited host clients without killing their captured processes.
211    Restore,
212}
213
214/// Payload for `core.workload.thawed` messages.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkloadThawed {
217    /// Attempt identity whose workload boundary is now runnable.
218    pub attempt_id: String,
219}
220
221/// Root disk growth target, in bytes, used for preflight and apply.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RootDiskGrow {
224    /// Desired ext4 size; must be an aligned, nondecreasing target.
225    pub size_bytes: u64,
226}
227
228/// Observed capacities of the guest root filesystem and its block device.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct RootDiskState {
231    /// ext4 superblock size, including filesystem metadata.
232    pub filesystem_bytes: u64,
233    /// Capacity observed by the guest block driver.
234    pub device_bytes: u64,
235}
236
237/// Payload for `core.error` messages.
238///
239/// Sent when a peer can identify a recoverable protocol error for a specific
240/// correlation ID. Unrecoverable frame-level errors, such as stream
241/// desynchronization or impossible frame lengths, should close the transport
242/// instead. Before `core.ready`, agentd may send `InitializationFailed` with
243/// id=0 and the terminal flag. The host saves this fatal startup error before sending
244/// `core.init.ack` with `failure=true`, allowing the guest to exit.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct CoreError {
247    /// Machine-readable error kind.
248    pub kind: CoreErrorKind,
249
250    /// Human-readable diagnostic message.
251    pub message: String,
252
253    /// Wire message type involved in the error, when it could be determined.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub offending_type: Option<String>,
256
257    /// Typed reason for a fatal guest initialization failure, when available.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub init_failure: Option<InitFailureReason>,
260
261    /// Attempt-scoped freezer disposition. Absence is ambiguous, not proof that no work froze.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub workload_failure: Option<WorkloadFailure>,
264}
265
266/// Guest initialization failures with actionable configuration guidance.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum InitFailureReason {
270    /// The configured named user does not exist in the guest image.
271    UserNotFound,
272    /// The configured named group does not exist in the guest image.
273    GroupNotFound,
274    /// A reason introduced by a newer guest; no specific hint is available.
275    #[serde(other)]
276    Unknown,
277}
278
279/// Additional recovery information for a workload control error.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct WorkloadFailure {
282    /// Attempt whose request failed.
283    pub attempt_id: String,
284    /// Whether a freeze was rejected before any freezer operation or needs recovery.
285    pub disposition: WorkloadFailureDisposition,
286}
287
288/// Freezer failure dispositions; unknown future values never authorize a fallback.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum WorkloadFailureDisposition {
292    /// No freezer exists and no freeze was attempted.
293    Unavailable,
294    /// The caller must obtain a confirmed thaw before treating the workload as running.
295    RecoveryRequired,
296    /// Unrecognized additional information from a newer agent.
297    #[serde(other)]
298    Unknown,
299}
300
301/// Machine-readable `core.error` categories.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum CoreErrorKind {
305    /// Guest initialization failed before the agent became ready.
306    InitializationFailed,
307
308    /// The protocol message envelope could not be decoded.
309    MalformedMessage,
310
311    /// The message type is unknown to the peer.
312    UnsupportedMessageType,
313
314    /// The message requires a newer protocol generation than the peer supports.
315    UnsupportedProtocolGeneration,
316
317    /// The frame flags do not match the message type.
318    InvalidFlags,
319
320    /// The message payload could not be decoded or failed validation.
321    InvalidPayload,
322
323    /// The message refers to an unknown, closed, or incompatible session.
324    InvalidSession,
325
326    /// The peer understands the request but the runtime cannot provide its capability.
327    CapabilityUnavailable,
328}
329
330/// Payload for `core.init.resolved` messages.
331///
332/// Sent by agentd after the guest rootfs is ready to resolve init-time facts,
333/// but before user volume mounts are attached. The host uses this to install
334/// early runtime state that depends on guest-resolved values.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct InitResolved {
337    /// Default guest user for sandbox commands.
338    pub default_user: ResolvedUser,
339}
340
341/// A guest user and group resolved by agentd.
342#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
343pub struct ResolvedUser {
344    /// Effective default guest user id for sandbox commands.
345    pub uid: u32,
346
347    /// Effective default guest group id for sandbox commands.
348    pub gid: u32,
349}
350
351/// Payload for `core.init.ack` messages.
352///
353/// Sent by the host after it has consumed the init context and completed any
354/// dependent setup, or saved a fatal guest initialization error.
355#[derive(Debug, Default, Clone, Serialize, Deserialize)]
356pub struct InitAck {
357    /// True only when acknowledging a persisted initialization failure.
358    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
359    pub failure: bool,
360}
361
362/// Payload for `core.relay.client.disconnected` messages.
363///
364/// Sent by the host relay when one SDK client socket disconnects. The
365/// guest agent uses the assigned correlation ID range to clean up resources
366/// owned by that client, such as open filesystem handles.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct RelayClientDisconnected {
369    /// First correlation ID assigned to the disconnected client.
370    pub id_start: u32,
371
372    /// Exclusive upper bound of the disconnected client's ID range.
373    pub id_end_exclusive: u32,
374
375    /// Exact leased range owner being removed. Absent only for legacy unleased peers.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub incarnation: Option<[u8; 16]>,
378}
379
380//--------------------------------------------------------------------------------------------------
381// Tests
382//--------------------------------------------------------------------------------------------------
383
384#[cfg(test)]
385mod tests {
386    use microsandbox_types::ExecLimits;
387    use serde::{Deserialize, Serialize};
388
389    use super::{Ready, RelayClientDisconnected};
390
391    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
392    struct LegacyReady {
393        boot_time_ns: u64,
394        init_time_ns: u64,
395        ready_time_ns: u64,
396        #[serde(default, skip_serializing_if = "String::is_empty")]
397        agent_version: String,
398    }
399
400    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
401    struct LegacyRelayClientDisconnected {
402        id_start: u32,
403        id_end_exclusive: u32,
404    }
405
406    #[test]
407    fn ready_without_transport_capabilities_is_byte_compatible() {
408        let legacy = LegacyReady {
409            boot_time_ns: 11,
410            init_time_ns: 22,
411            ready_time_ns: 33,
412            agent_version: "0.6.8".into(),
413        };
414        let current = Ready {
415            exec_limits: None,
416            boot_time_ns: legacy.boot_time_ns,
417            init_time_ns: legacy.init_time_ns,
418            ready_time_ns: legacy.ready_time_ns,
419            agent_version: legacy.agent_version.clone(),
420            bulk_transport: None,
421            relay_lease: None,
422            local_transport: None,
423            workload_transport_barrier_version: None,
424        };
425        let mut legacy_bytes = Vec::new();
426        ciborium::into_writer(&legacy, &mut legacy_bytes).unwrap();
427        let mut current_bytes = Vec::new();
428        ciborium::into_writer(&current, &mut current_bytes).unwrap();
429
430        assert_eq!(current_bytes, legacy_bytes);
431        let decoded: Ready = ciborium::from_reader(legacy_bytes.as_slice()).unwrap();
432        assert!(decoded.bulk_transport.is_none());
433        assert!(decoded.relay_lease.is_none());
434        assert!(decoded.local_transport.is_none());
435        assert!(decoded.workload_transport_barrier_version.is_none());
436        assert!(decoded.exec_limits.is_none());
437
438        let advertised = Ready {
439            exec_limits: Some(ExecLimits::default()),
440            ..current
441        };
442        let mut advertised_bytes = Vec::new();
443        ciborium::into_writer(&advertised, &mut advertised_bytes).unwrap();
444        let legacy_decoded: LegacyReady =
445            ciborium::from_reader(advertised_bytes.as_slice()).unwrap();
446        assert_eq!(
447            legacy_decoded, legacy,
448            "older clients must ignore the new policy field"
449        );
450    }
451
452    #[test]
453    fn relay_disconnect_without_incarnation_is_byte_compatible() {
454        let legacy = LegacyRelayClientDisconnected {
455            id_start: 1,
456            id_end_exclusive: 1024,
457        };
458        let current = RelayClientDisconnected {
459            id_start: legacy.id_start,
460            id_end_exclusive: legacy.id_end_exclusive,
461            incarnation: None,
462        };
463        let mut legacy_bytes = Vec::new();
464        ciborium::into_writer(&legacy, &mut legacy_bytes).unwrap();
465        let mut current_bytes = Vec::new();
466        ciborium::into_writer(&current, &mut current_bytes).unwrap();
467
468        assert_eq!(current_bytes, legacy_bytes);
469        let decoded: RelayClientDisconnected =
470            ciborium::from_reader(legacy_bytes.as_slice()).unwrap();
471        assert_eq!(decoded.id_start, current.id_start);
472        assert_eq!(decoded.id_end_exclusive, current.id_end_exclusive);
473        assert_eq!(decoded.incarnation, None);
474    }
475}
476
477//--------------------------------------------------------------------------------------------------
478// Tests
479//--------------------------------------------------------------------------------------------------
480
481#[cfg(test)]
482mod workload_tests {
483    use super::*;
484
485    #[test]
486    fn workload_barrier_payloads_roundtrip_without_resetting_counters() {
487        let position = WorkloadTransportPosition {
488            control_bytes: 73 * WORKLOAD_TRANSPORT_CONTROL_BYTES,
489            control_frames: 20_000,
490            bulk_bytes: 91 * WORKLOAD_TRANSPORT_BULK_BYTES,
491            bulk_frames: 30_000,
492        };
493        let freeze = WorkloadFreeze {
494            external_mount_tags: Vec::new(),
495            attempt_id: "captured-generation".into(),
496            host_input: position,
497        };
498        let mut bytes = Vec::new();
499        ciborium::into_writer(&freeze, &mut bytes).unwrap();
500        let decoded: WorkloadFreeze = ciborium::from_reader(bytes.as_slice()).unwrap();
501        assert_eq!(decoded, freeze);
502
503        // A restored guest may still own most of the window as pending stdin.
504        // Carry absolute grants, not a reset that would admit that much again.
505        let frozen = WorkloadFrozen {
506            external_mounts_synced: false,
507            attempt_id: freeze.attempt_id,
508            guest_bulk_bytes_target: 987_654_321,
509            input_credit: WorkloadTransportCredit {
510                control_bytes: position.control_bytes + 100,
511                control_frames: position.control_frames + 2,
512                bulk_bytes: position.bulk_bytes + 200,
513                bulk_frames: position.bulk_frames + 3,
514            },
515        };
516        bytes.clear();
517        ciborium::into_writer(&frozen, &mut bytes).unwrap();
518        let decoded: WorkloadFrozen = ciborium::from_reader(bytes.as_slice()).unwrap();
519        assert_eq!(decoded, frozen);
520    }
521
522    #[test]
523    fn superseded_development_freeze_payloads_do_not_imply_safe_boundaries() {
524        let old = serde_json::json!({"attempt_id":"old-development-capture"});
525        assert!(serde_json::from_value::<WorkloadFreeze>(old.clone()).is_err());
526        assert!(serde_json::from_value::<WorkloadFrozen>(old).is_err());
527    }
528
529    #[test]
530    fn unknown_barrier_capability_is_preserved_for_explicit_negotiation() {
531        let ready = Ready {
532            workload_transport_barrier_version: Some(99),
533            ..Ready::default()
534        };
535        let mut bytes = Vec::new();
536        ciborium::into_writer(&ready, &mut bytes).unwrap();
537        let decoded: Ready = ciborium::from_reader(bytes.as_slice()).unwrap();
538        assert_eq!(decoded.workload_transport_barrier_version, Some(99));
539        assert_ne!(
540            decoded.workload_transport_barrier_version,
541            Some(WORKLOAD_TRANSPORT_BARRIER_VERSION)
542        );
543    }
544
545    #[test]
546    fn input_window_fits_a_maximum_primary_frame() {
547        let credit = WorkloadTransportCredit {
548            control_bytes: WORKLOAD_TRANSPORT_CONTROL_BYTES,
549            control_frames: WORKLOAD_TRANSPORT_CONTROL_FRAMES,
550            bulk_bytes: WORKLOAD_TRANSPORT_BULK_BYTES,
551            bulk_frames: WORKLOAD_TRANSPORT_BULK_FRAMES,
552        };
553        assert!(credit.control_bytes >= crate::codec::MAX_FRAME_SIZE as u64 + 4);
554        assert!(credit.control_frames > 0);
555        assert!(credit.bulk_frames > 0);
556    }
557
558    #[test]
559    fn freezer_error_details_are_additive_and_unknown_details_are_not_unavailable() {
560        let old = serde_json::json!({"kind":"capability_unavailable", "message":"freezer failed"});
561        let decoded: CoreError = serde_json::from_value(old.clone()).unwrap();
562        assert!(decoded.workload_failure.is_none());
563        let mut new = old;
564        new["workload_failure"] =
565            serde_json::json!({"attempt_id":"a", "disposition":"future_state"});
566        let decoded: CoreError = serde_json::from_value(new.clone()).unwrap();
567        assert_eq!(
568            decoded.workload_failure.unwrap().disposition,
569            WorkloadFailureDisposition::Unknown
570        );
571
572        #[derive(Deserialize)]
573        struct OldCoreError {
574            kind: CoreErrorKind,
575            message: String,
576        }
577        let old_reader: OldCoreError = serde_json::from_value(new).unwrap();
578        assert_eq!(old_reader.kind, CoreErrorKind::CapabilityUnavailable);
579        assert_eq!(old_reader.message, "freezer failed");
580    }
581}