Skip to main content

microsandbox_protocol/
core.rs

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