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. Before `core.ready`, agentd may send `InitializationFailed` with
238/// id=0 and the terminal flag. The host saves this fatal startup error before sending
239/// `core.init.ack` with `failure=true`, allowing the guest to exit.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct CoreError {
242 /// Machine-readable error kind.
243 pub kind: CoreErrorKind,
244
245 /// Human-readable diagnostic message.
246 pub message: String,
247
248 /// Wire message type involved in the error, when it could be determined.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub offending_type: Option<String>,
251
252 /// Typed reason for a fatal guest initialization failure, when available.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub init_failure: Option<InitFailureReason>,
255
256 /// Attempt-scoped freezer disposition. Absence is ambiguous, not proof that no work froze.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub workload_failure: Option<WorkloadFailure>,
259}
260
261/// Guest initialization failures with actionable configuration guidance.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum InitFailureReason {
265 /// The configured named user does not exist in the guest image.
266 UserNotFound,
267 /// The configured named group does not exist in the guest image.
268 GroupNotFound,
269 /// A reason introduced by a newer guest; no specific hint is available.
270 #[serde(other)]
271 Unknown,
272}
273
274/// Additional recovery information for a workload control error.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct WorkloadFailure {
277 /// Attempt whose request failed.
278 pub attempt_id: String,
279 /// Whether a freeze was rejected before any freezer operation or needs recovery.
280 pub disposition: WorkloadFailureDisposition,
281}
282
283/// Freezer failure dispositions; unknown future values never authorize a fallback.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum WorkloadFailureDisposition {
287 /// No freezer exists and no freeze was attempted.
288 Unavailable,
289 /// The caller must obtain a confirmed thaw before treating the workload as running.
290 RecoveryRequired,
291 /// Unrecognized additional information from a newer agent.
292 #[serde(other)]
293 Unknown,
294}
295
296/// Machine-readable `core.error` categories.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum CoreErrorKind {
300 /// Guest initialization failed before the agent became ready.
301 InitializationFailed,
302
303 /// The protocol message envelope could not be decoded.
304 MalformedMessage,
305
306 /// The message type is unknown to the peer.
307 UnsupportedMessageType,
308
309 /// The message requires a newer protocol generation than the peer supports.
310 UnsupportedProtocolGeneration,
311
312 /// The frame flags do not match the message type.
313 InvalidFlags,
314
315 /// The message payload could not be decoded or failed validation.
316 InvalidPayload,
317
318 /// The message refers to an unknown, closed, or incompatible session.
319 InvalidSession,
320
321 /// The peer understands the request but the runtime cannot provide its capability.
322 CapabilityUnavailable,
323}
324
325/// Payload for `core.init.resolved` messages.
326///
327/// Sent by agentd after the guest rootfs is ready to resolve init-time facts,
328/// but before user volume mounts are attached. The host uses this to install
329/// early runtime state that depends on guest-resolved values.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct InitResolved {
332 /// Default guest user for sandbox commands.
333 pub default_user: ResolvedUser,
334}
335
336/// A guest user and group resolved by agentd.
337#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
338pub struct ResolvedUser {
339 /// Effective default guest user id for sandbox commands.
340 pub uid: u32,
341
342 /// Effective default guest group id for sandbox commands.
343 pub gid: u32,
344}
345
346/// Payload for `core.init.ack` messages.
347///
348/// Sent by the host after it has consumed the init context and completed any
349/// dependent setup, or saved a fatal guest initialization error.
350#[derive(Debug, Default, Clone, Serialize, Deserialize)]
351pub struct InitAck {
352 /// True only when acknowledging a persisted initialization failure.
353 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
354 pub failure: bool,
355}
356
357/// Payload for `core.relay.client.disconnected` messages.
358///
359/// Sent by the host relay when one SDK client socket disconnects. The
360/// guest agent uses the assigned correlation ID range to clean up resources
361/// owned by that client, such as open filesystem handles.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct RelayClientDisconnected {
364 /// First correlation ID assigned to the disconnected client.
365 pub id_start: u32,
366
367 /// Exclusive upper bound of the disconnected client's ID range.
368 pub id_end_exclusive: u32,
369
370 /// Exact leased range owner being removed. Absent only for legacy unleased peers.
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub incarnation: Option<[u8; 16]>,
373}
374
375//--------------------------------------------------------------------------------------------------
376// Tests
377//--------------------------------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests {
381 use serde::{Deserialize, Serialize};
382
383 use super::{Ready, RelayClientDisconnected};
384
385 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
386 struct LegacyReady {
387 boot_time_ns: u64,
388 init_time_ns: u64,
389 ready_time_ns: u64,
390 #[serde(default, skip_serializing_if = "String::is_empty")]
391 agent_version: String,
392 }
393
394 #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
395 struct LegacyRelayClientDisconnected {
396 id_start: u32,
397 id_end_exclusive: u32,
398 }
399
400 #[test]
401 fn ready_without_transport_capabilities_is_byte_compatible() {
402 let legacy = LegacyReady {
403 boot_time_ns: 11,
404 init_time_ns: 22,
405 ready_time_ns: 33,
406 agent_version: "0.6.8".into(),
407 };
408 let current = Ready {
409 boot_time_ns: legacy.boot_time_ns,
410 init_time_ns: legacy.init_time_ns,
411 ready_time_ns: legacy.ready_time_ns,
412 agent_version: legacy.agent_version.clone(),
413 bulk_transport: None,
414 relay_lease: None,
415 local_transport: None,
416 workload_transport_barrier_version: None,
417 };
418 let mut legacy_bytes = Vec::new();
419 ciborium::into_writer(&legacy, &mut legacy_bytes).unwrap();
420 let mut current_bytes = Vec::new();
421 ciborium::into_writer(¤t, &mut current_bytes).unwrap();
422
423 assert_eq!(current_bytes, legacy_bytes);
424 let decoded: Ready = ciborium::from_reader(legacy_bytes.as_slice()).unwrap();
425 assert!(decoded.bulk_transport.is_none());
426 assert!(decoded.relay_lease.is_none());
427 assert!(decoded.local_transport.is_none());
428 assert!(decoded.workload_transport_barrier_version.is_none());
429 }
430
431 #[test]
432 fn relay_disconnect_without_incarnation_is_byte_compatible() {
433 let legacy = LegacyRelayClientDisconnected {
434 id_start: 1,
435 id_end_exclusive: 1024,
436 };
437 let current = RelayClientDisconnected {
438 id_start: legacy.id_start,
439 id_end_exclusive: legacy.id_end_exclusive,
440 incarnation: None,
441 };
442 let mut legacy_bytes = Vec::new();
443 ciborium::into_writer(&legacy, &mut legacy_bytes).unwrap();
444 let mut current_bytes = Vec::new();
445 ciborium::into_writer(¤t, &mut current_bytes).unwrap();
446
447 assert_eq!(current_bytes, legacy_bytes);
448 let decoded: RelayClientDisconnected =
449 ciborium::from_reader(legacy_bytes.as_slice()).unwrap();
450 assert_eq!(decoded.id_start, current.id_start);
451 assert_eq!(decoded.id_end_exclusive, current.id_end_exclusive);
452 assert_eq!(decoded.incarnation, None);
453 }
454}
455
456//--------------------------------------------------------------------------------------------------
457// Tests
458//--------------------------------------------------------------------------------------------------
459
460#[cfg(test)]
461mod workload_tests {
462 use super::*;
463
464 #[test]
465 fn workload_barrier_payloads_roundtrip_without_resetting_counters() {
466 let position = WorkloadTransportPosition {
467 control_bytes: 73 * WORKLOAD_TRANSPORT_CONTROL_BYTES,
468 control_frames: 20_000,
469 bulk_bytes: 91 * WORKLOAD_TRANSPORT_BULK_BYTES,
470 bulk_frames: 30_000,
471 };
472 let freeze = WorkloadFreeze {
473 external_mount_tags: Vec::new(),
474 attempt_id: "captured-generation".into(),
475 host_input: position,
476 };
477 let mut bytes = Vec::new();
478 ciborium::into_writer(&freeze, &mut bytes).unwrap();
479 let decoded: WorkloadFreeze = ciborium::from_reader(bytes.as_slice()).unwrap();
480 assert_eq!(decoded, freeze);
481
482 // A restored guest may still own most of the window as pending stdin.
483 // Carry absolute grants, not a reset that would admit that much again.
484 let frozen = WorkloadFrozen {
485 external_mounts_synced: false,
486 attempt_id: freeze.attempt_id,
487 guest_bulk_bytes_target: 987_654_321,
488 input_credit: WorkloadTransportCredit {
489 control_bytes: position.control_bytes + 100,
490 control_frames: position.control_frames + 2,
491 bulk_bytes: position.bulk_bytes + 200,
492 bulk_frames: position.bulk_frames + 3,
493 },
494 };
495 bytes.clear();
496 ciborium::into_writer(&frozen, &mut bytes).unwrap();
497 let decoded: WorkloadFrozen = ciborium::from_reader(bytes.as_slice()).unwrap();
498 assert_eq!(decoded, frozen);
499 }
500
501 #[test]
502 fn superseded_development_freeze_payloads_do_not_imply_safe_boundaries() {
503 let old = serde_json::json!({"attempt_id":"old-development-capture"});
504 assert!(serde_json::from_value::<WorkloadFreeze>(old.clone()).is_err());
505 assert!(serde_json::from_value::<WorkloadFrozen>(old).is_err());
506 }
507
508 #[test]
509 fn unknown_barrier_capability_is_preserved_for_explicit_negotiation() {
510 let ready = Ready {
511 workload_transport_barrier_version: Some(99),
512 ..Ready::default()
513 };
514 let mut bytes = Vec::new();
515 ciborium::into_writer(&ready, &mut bytes).unwrap();
516 let decoded: Ready = ciborium::from_reader(bytes.as_slice()).unwrap();
517 assert_eq!(decoded.workload_transport_barrier_version, Some(99));
518 assert_ne!(
519 decoded.workload_transport_barrier_version,
520 Some(WORKLOAD_TRANSPORT_BARRIER_VERSION)
521 );
522 }
523
524 #[test]
525 fn input_window_fits_a_maximum_primary_frame() {
526 let credit = WorkloadTransportCredit {
527 control_bytes: WORKLOAD_TRANSPORT_CONTROL_BYTES,
528 control_frames: WORKLOAD_TRANSPORT_CONTROL_FRAMES,
529 bulk_bytes: WORKLOAD_TRANSPORT_BULK_BYTES,
530 bulk_frames: WORKLOAD_TRANSPORT_BULK_FRAMES,
531 };
532 assert!(credit.control_bytes >= crate::codec::MAX_FRAME_SIZE as u64 + 4);
533 assert!(credit.control_frames > 0);
534 assert!(credit.bulk_frames > 0);
535 }
536
537 #[test]
538 fn freezer_error_details_are_additive_and_unknown_details_are_not_unavailable() {
539 let old = serde_json::json!({"kind":"capability_unavailable", "message":"freezer failed"});
540 let decoded: CoreError = serde_json::from_value(old.clone()).unwrap();
541 assert!(decoded.workload_failure.is_none());
542 let mut new = old;
543 new["workload_failure"] =
544 serde_json::json!({"attempt_id":"a", "disposition":"future_state"});
545 let decoded: CoreError = serde_json::from_value(new.clone()).unwrap();
546 assert_eq!(
547 decoded.workload_failure.unwrap().disposition,
548 WorkloadFailureDisposition::Unknown
549 );
550
551 #[derive(Deserialize)]
552 struct OldCoreError {
553 kind: CoreErrorKind,
554 message: String,
555 }
556 let old_reader: OldCoreError = serde_json::from_value(new).unwrap();
557 assert_eq!(old_reader.kind, CoreErrorKind::CapabilityUnavailable);
558 assert_eq!(old_reader.message, "freezer failed");
559 }
560}