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