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(¤t, &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(¤t, &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}