1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum HostcallDispatchLane {
13 Fast,
14 IoUring,
15 Compat,
16}
17
18impl HostcallDispatchLane {
19 #[must_use]
20 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::Fast => "fast",
23 Self::IoUring => "io_uring",
24 Self::Compat => "compat",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HostcallIoHint {
33 Unknown,
34 IoHeavy,
35 CpuBound,
36}
37
38impl HostcallIoHint {
39 #[must_use]
40 pub const fn is_io_heavy(self) -> bool {
41 matches!(self, Self::IoHeavy)
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum HostcallCapabilityClass {
49 Filesystem,
50 Network,
51 Execution,
52 Session,
53 Events,
54 Environment,
55 Tool,
56 Ui,
57 Telemetry,
58 Unknown,
59}
60
61impl HostcallCapabilityClass {
62 #[must_use]
63 pub fn from_capability(value: &str) -> Self {
64 match value.trim().to_ascii_lowercase().as_str() {
65 "read" | "write" | "filesystem" | "fs" => Self::Filesystem,
66 "http" | "network" => Self::Network,
67 "exec" | "execution" => Self::Execution,
68 "session" => Self::Session,
69 "events" => Self::Events,
70 "env" | "environment" => Self::Environment,
71 "tool" => Self::Tool,
72 "ui" => Self::Ui,
73 "log" | "telemetry" => Self::Telemetry,
74 _ => Self::Unknown,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum IoUringFallbackReason {
83 CompatKillSwitch,
84 IoUringDisabled,
85 IoUringUnavailable,
86 MissingIoHint,
87 UnsupportedCapability,
88 QueueDepthBudgetExceeded,
89}
90
91impl IoUringFallbackReason {
92 #[must_use]
93 pub const fn as_code(self) -> &'static str {
94 match self {
95 Self::CompatKillSwitch => "forced_compat_kill_switch",
96 Self::IoUringDisabled => "io_uring_disabled",
97 Self::IoUringUnavailable => "io_uring_unavailable",
98 Self::MissingIoHint => "io_hint_missing",
99 Self::UnsupportedCapability => "io_uring_capability_not_supported",
100 Self::QueueDepthBudgetExceeded => "io_uring_queue_depth_budget_exceeded",
101 }
102 }
103}
104
105#[allow(clippy::struct_excessive_bools)]
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub struct IoUringLanePolicyConfig {
109 pub enabled: bool,
110 pub ring_available: bool,
111 pub max_queue_depth: usize,
112 pub allow_filesystem: bool,
113 pub allow_network: bool,
114}
115
116impl IoUringLanePolicyConfig {
117 #[must_use]
119 pub const fn conservative() -> Self {
120 Self {
121 enabled: false,
122 ring_available: false,
123 max_queue_depth: 256,
124 allow_filesystem: true,
125 allow_network: true,
126 }
127 }
128
129 #[must_use]
130 pub const fn allow_for_capability(self, capability: HostcallCapabilityClass) -> bool {
131 match capability {
132 HostcallCapabilityClass::Filesystem => self.allow_filesystem,
133 HostcallCapabilityClass::Network => self.allow_network,
134 _ => false,
135 }
136 }
137}
138
139impl Default for IoUringLanePolicyConfig {
140 fn default() -> Self {
141 Self::conservative()
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct IoUringLaneDecisionInput {
148 pub capability: HostcallCapabilityClass,
149 pub io_hint: HostcallIoHint,
150 pub queue_depth: usize,
151 pub force_compat_lane: bool,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156pub struct IoUringLaneDecision {
157 pub lane: HostcallDispatchLane,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub fallback_reason: Option<IoUringFallbackReason>,
160}
161
162impl IoUringLaneDecision {
163 #[must_use]
164 pub const fn io_uring() -> Self {
165 Self {
166 lane: HostcallDispatchLane::IoUring,
167 fallback_reason: None,
168 }
169 }
170
171 #[must_use]
172 pub const fn compat(reason: IoUringFallbackReason) -> Self {
173 Self {
174 lane: HostcallDispatchLane::Compat,
175 fallback_reason: Some(reason),
176 }
177 }
178
179 #[must_use]
180 pub const fn fast(reason: IoUringFallbackReason) -> Self {
181 Self {
182 lane: HostcallDispatchLane::Fast,
183 fallback_reason: Some(reason),
184 }
185 }
186
187 #[must_use]
188 pub fn fallback_code(self) -> Option<&'static str> {
189 self.fallback_reason.map(IoUringFallbackReason::as_code)
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[allow(clippy::struct_excessive_bools)]
196pub struct IoUringLaneTelemetry {
197 pub lane: HostcallDispatchLane,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub fallback_reason: Option<IoUringFallbackReason>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub fallback_code: Option<String>,
202 pub capability: HostcallCapabilityClass,
203 pub io_hint: HostcallIoHint,
204 pub queue_depth: usize,
205 pub queue_depth_budget: usize,
206 pub queue_depth_budget_remaining: usize,
207 pub force_compat_lane: bool,
208 pub policy_enabled: bool,
209 pub ring_available: bool,
210 pub capability_allowed: bool,
211 pub queue_depth_within_budget: bool,
212}
213
214#[must_use]
216pub fn build_io_uring_lane_telemetry(
217 config: IoUringLanePolicyConfig,
218 input: IoUringLaneDecisionInput,
219 decision: IoUringLaneDecision,
220) -> IoUringLaneTelemetry {
221 let capability_allowed = config.allow_for_capability(input.capability);
222 let queue_depth_within_budget = input.queue_depth < config.max_queue_depth;
223 let queue_depth_budget_remaining = config.max_queue_depth.saturating_sub(input.queue_depth);
224 IoUringLaneTelemetry {
225 lane: decision.lane,
226 fallback_reason: decision.fallback_reason,
227 fallback_code: decision.fallback_code().map(ToString::to_string),
228 capability: input.capability,
229 io_hint: input.io_hint,
230 queue_depth: input.queue_depth,
231 queue_depth_budget: config.max_queue_depth,
232 queue_depth_budget_remaining,
233 force_compat_lane: input.force_compat_lane,
234 policy_enabled: config.enabled,
235 ring_available: config.ring_available,
236 capability_allowed,
237 queue_depth_within_budget,
238 }
239}
240
241#[must_use]
243pub fn decide_io_uring_lane_with_telemetry(
244 config: IoUringLanePolicyConfig,
245 input: IoUringLaneDecisionInput,
246) -> (IoUringLaneDecision, IoUringLaneTelemetry) {
247 let decision = decide_io_uring_lane(config, input);
248 let telemetry = build_io_uring_lane_telemetry(config, input, decision);
249 (decision, telemetry)
250}
251
252#[must_use]
262pub const fn decide_io_uring_lane(
263 config: IoUringLanePolicyConfig,
264 input: IoUringLaneDecisionInput,
265) -> IoUringLaneDecision {
266 if input.force_compat_lane {
267 return IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
268 }
269 if !config.enabled {
270 return IoUringLaneDecision::fast(IoUringFallbackReason::IoUringDisabled);
271 }
272 if !config.ring_available {
273 return IoUringLaneDecision::fast(IoUringFallbackReason::IoUringUnavailable);
274 }
275 if !input.io_hint.is_io_heavy() {
276 return IoUringLaneDecision::fast(IoUringFallbackReason::MissingIoHint);
277 }
278 if !config.allow_for_capability(input.capability) {
279 return IoUringLaneDecision::fast(IoUringFallbackReason::UnsupportedCapability);
280 }
281 if input.queue_depth >= config.max_queue_depth {
282 return IoUringLaneDecision::fast(IoUringFallbackReason::QueueDepthBudgetExceeded);
283 }
284 IoUringLaneDecision::io_uring()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn enabled_config() -> IoUringLanePolicyConfig {
292 IoUringLanePolicyConfig {
293 enabled: true,
294 ring_available: true,
295 max_queue_depth: 8,
296 allow_filesystem: true,
297 allow_network: true,
298 }
299 }
300
301 #[test]
302 fn capability_aliases_map_to_expected_classes() {
303 assert_eq!(
304 HostcallCapabilityClass::from_capability("read"),
305 HostcallCapabilityClass::Filesystem
306 );
307 assert_eq!(
308 HostcallCapabilityClass::from_capability("http"),
309 HostcallCapabilityClass::Network
310 );
311 assert_eq!(
312 HostcallCapabilityClass::from_capability("session"),
313 HostcallCapabilityClass::Session
314 );
315 assert_eq!(
316 HostcallCapabilityClass::from_capability("unknown-cap"),
317 HostcallCapabilityClass::Unknown
318 );
319 }
320
321 #[test]
322 fn selects_io_uring_for_io_heavy_allowed_capability_with_budget_headroom() {
323 let decision = decide_io_uring_lane(
324 enabled_config(),
325 IoUringLaneDecisionInput {
326 capability: HostcallCapabilityClass::Network,
327 io_hint: HostcallIoHint::IoHeavy,
328 queue_depth: 3,
329 force_compat_lane: false,
330 },
331 );
332 assert_eq!(decision.lane, HostcallDispatchLane::IoUring);
333 assert!(decision.fallback_reason.is_none());
334 }
335
336 #[test]
337 fn kill_switch_forces_compat_lane() {
338 let decision = decide_io_uring_lane(
339 enabled_config(),
340 IoUringLaneDecisionInput {
341 capability: HostcallCapabilityClass::Filesystem,
342 io_hint: HostcallIoHint::IoHeavy,
343 queue_depth: 0,
344 force_compat_lane: true,
345 },
346 );
347 assert_eq!(decision.lane, HostcallDispatchLane::Compat);
348 assert_eq!(
349 decision.fallback_reason,
350 Some(IoUringFallbackReason::CompatKillSwitch)
351 );
352 assert_eq!(decision.fallback_code(), Some("forced_compat_kill_switch"));
353 }
354
355 #[test]
356 fn disabled_policy_falls_back_to_fast_lane() {
357 let mut config = enabled_config();
358 config.enabled = false;
359 let decision = decide_io_uring_lane(
360 config,
361 IoUringLaneDecisionInput {
362 capability: HostcallCapabilityClass::Network,
363 io_hint: HostcallIoHint::IoHeavy,
364 queue_depth: 0,
365 force_compat_lane: false,
366 },
367 );
368 assert_eq!(decision.lane, HostcallDispatchLane::Fast);
369 assert_eq!(
370 decision.fallback_reason,
371 Some(IoUringFallbackReason::IoUringDisabled)
372 );
373 }
374
375 #[test]
376 fn unavailable_ring_falls_back_to_fast_lane() {
377 let mut config = enabled_config();
378 config.ring_available = false;
379 let decision = decide_io_uring_lane(
380 config,
381 IoUringLaneDecisionInput {
382 capability: HostcallCapabilityClass::Network,
383 io_hint: HostcallIoHint::IoHeavy,
384 queue_depth: 0,
385 force_compat_lane: false,
386 },
387 );
388 assert_eq!(
389 decision.fallback_reason,
390 Some(IoUringFallbackReason::IoUringUnavailable)
391 );
392 }
393
394 #[test]
395 fn non_io_hint_falls_back_to_fast_lane() {
396 let decision = decide_io_uring_lane(
397 enabled_config(),
398 IoUringLaneDecisionInput {
399 capability: HostcallCapabilityClass::Network,
400 io_hint: HostcallIoHint::CpuBound,
401 queue_depth: 0,
402 force_compat_lane: false,
403 },
404 );
405 assert_eq!(decision.lane, HostcallDispatchLane::Fast);
406 assert_eq!(
407 decision.fallback_reason,
408 Some(IoUringFallbackReason::MissingIoHint)
409 );
410 }
411
412 #[test]
413 fn unsupported_capability_falls_back_to_fast_lane() {
414 let decision = decide_io_uring_lane(
415 enabled_config(),
416 IoUringLaneDecisionInput {
417 capability: HostcallCapabilityClass::Session,
418 io_hint: HostcallIoHint::IoHeavy,
419 queue_depth: 0,
420 force_compat_lane: false,
421 },
422 );
423 assert_eq!(decision.lane, HostcallDispatchLane::Fast);
424 assert_eq!(
425 decision.fallback_reason,
426 Some(IoUringFallbackReason::UnsupportedCapability)
427 );
428 }
429
430 #[test]
431 fn queue_depth_budget_exceeded_falls_back_to_fast_lane() {
432 let decision = decide_io_uring_lane(
433 enabled_config(),
434 IoUringLaneDecisionInput {
435 capability: HostcallCapabilityClass::Filesystem,
436 io_hint: HostcallIoHint::IoHeavy,
437 queue_depth: 8,
438 force_compat_lane: false,
439 },
440 );
441 assert_eq!(decision.lane, HostcallDispatchLane::Fast);
442 assert_eq!(
443 decision.fallback_reason,
444 Some(IoUringFallbackReason::QueueDepthBudgetExceeded)
445 );
446 }
447
448 #[test]
449 fn telemetry_builder_omits_fallback_fields_for_io_uring_success() {
450 let config = enabled_config();
451 let input = IoUringLaneDecisionInput {
452 capability: HostcallCapabilityClass::Filesystem,
453 io_hint: HostcallIoHint::IoHeavy,
454 queue_depth: 2,
455 force_compat_lane: false,
456 };
457 let decision = decide_io_uring_lane(config, input);
458 let telemetry = build_io_uring_lane_telemetry(config, input, decision);
459 assert_eq!(telemetry.lane, HostcallDispatchLane::IoUring);
460 assert_eq!(telemetry.fallback_reason, None);
461 assert_eq!(telemetry.fallback_code, None);
462 assert!(telemetry.capability_allowed);
463 assert!(telemetry.queue_depth_within_budget);
464
465 let value = serde_json::to_value(&telemetry).expect("serialize telemetry");
466 let obj = value.as_object().expect("telemetry object");
467 assert!(!obj.contains_key("fallback_reason"));
468 assert!(!obj.contains_key("fallback_code"));
469 assert_eq!(obj.get("queue_depth_budget"), Some(&serde_json::json!(8)));
470 assert_eq!(
471 obj.get("queue_depth_budget_remaining"),
472 Some(&serde_json::json!(6))
473 );
474 }
475
476 #[test]
477 fn telemetry_builder_includes_fallback_fields_for_fast_fallback() {
478 let config = IoUringLanePolicyConfig {
479 enabled: true,
480 ring_available: true,
481 max_queue_depth: 8,
482 allow_filesystem: false,
483 allow_network: true,
484 };
485 let input = IoUringLaneDecisionInput {
486 capability: HostcallCapabilityClass::Filesystem,
487 io_hint: HostcallIoHint::IoHeavy,
488 queue_depth: 0,
489 force_compat_lane: false,
490 };
491 let decision = decide_io_uring_lane(config, input);
492 let telemetry = build_io_uring_lane_telemetry(config, input, decision);
493 assert_eq!(telemetry.lane, HostcallDispatchLane::Fast);
494 assert_eq!(
495 telemetry.fallback_reason,
496 Some(IoUringFallbackReason::UnsupportedCapability)
497 );
498 assert_eq!(
499 telemetry.fallback_code.as_deref(),
500 Some("io_uring_capability_not_supported")
501 );
502 assert!(!telemetry.capability_allowed);
503
504 let value = serde_json::to_value(&telemetry).expect("serialize telemetry");
505 let obj = value.as_object().expect("telemetry object");
506 assert_eq!(
507 obj.get("fallback_code"),
508 Some(&serde_json::json!("io_uring_capability_not_supported"))
509 );
510 }
511
512 #[test]
513 #[allow(clippy::too_many_lines)]
514 fn fallback_reason_matrix_reports_expected_lane_and_code() {
515 struct Case {
516 name: &'static str,
517 config: IoUringLanePolicyConfig,
518 input: IoUringLaneDecisionInput,
519 expected_lane: HostcallDispatchLane,
520 expected_reason: IoUringFallbackReason,
521 }
522
523 let mut disabled = enabled_config();
524 disabled.enabled = false;
525
526 let mut unavailable = enabled_config();
527 unavailable.ring_available = false;
528
529 let mut unsupported_capability = enabled_config();
530 unsupported_capability.allow_network = false;
531
532 let cases = [
533 Case {
534 name: "compat kill-switch",
535 config: enabled_config(),
536 input: IoUringLaneDecisionInput {
537 capability: HostcallCapabilityClass::Filesystem,
538 io_hint: HostcallIoHint::IoHeavy,
539 queue_depth: 0,
540 force_compat_lane: true,
541 },
542 expected_lane: HostcallDispatchLane::Compat,
543 expected_reason: IoUringFallbackReason::CompatKillSwitch,
544 },
545 Case {
546 name: "disabled",
547 config: disabled,
548 input: IoUringLaneDecisionInput {
549 capability: HostcallCapabilityClass::Network,
550 io_hint: HostcallIoHint::IoHeavy,
551 queue_depth: 0,
552 force_compat_lane: false,
553 },
554 expected_lane: HostcallDispatchLane::Fast,
555 expected_reason: IoUringFallbackReason::IoUringDisabled,
556 },
557 Case {
558 name: "unavailable ring",
559 config: unavailable,
560 input: IoUringLaneDecisionInput {
561 capability: HostcallCapabilityClass::Network,
562 io_hint: HostcallIoHint::IoHeavy,
563 queue_depth: 0,
564 force_compat_lane: false,
565 },
566 expected_lane: HostcallDispatchLane::Fast,
567 expected_reason: IoUringFallbackReason::IoUringUnavailable,
568 },
569 Case {
570 name: "missing io hint",
571 config: enabled_config(),
572 input: IoUringLaneDecisionInput {
573 capability: HostcallCapabilityClass::Network,
574 io_hint: HostcallIoHint::CpuBound,
575 queue_depth: 0,
576 force_compat_lane: false,
577 },
578 expected_lane: HostcallDispatchLane::Fast,
579 expected_reason: IoUringFallbackReason::MissingIoHint,
580 },
581 Case {
582 name: "unsupported capability",
583 config: unsupported_capability,
584 input: IoUringLaneDecisionInput {
585 capability: HostcallCapabilityClass::Network,
586 io_hint: HostcallIoHint::IoHeavy,
587 queue_depth: 0,
588 force_compat_lane: false,
589 },
590 expected_lane: HostcallDispatchLane::Fast,
591 expected_reason: IoUringFallbackReason::UnsupportedCapability,
592 },
593 Case {
594 name: "queue budget exceeded",
595 config: enabled_config(),
596 input: IoUringLaneDecisionInput {
597 capability: HostcallCapabilityClass::Filesystem,
598 io_hint: HostcallIoHint::IoHeavy,
599 queue_depth: 8,
600 force_compat_lane: false,
601 },
602 expected_lane: HostcallDispatchLane::Fast,
603 expected_reason: IoUringFallbackReason::QueueDepthBudgetExceeded,
604 },
605 ];
606
607 for case in cases {
608 let decision = decide_io_uring_lane(case.config, case.input);
609 assert_eq!(decision.lane, case.expected_lane, "{}", case.name);
610 assert_eq!(
611 decision.fallback_reason,
612 Some(case.expected_reason),
613 "{}",
614 case.name
615 );
616 assert_eq!(
617 decision.fallback_code(),
618 Some(case.expected_reason.as_code()),
619 "{}",
620 case.name
621 );
622 }
623 }
624
625 #[test]
626 fn telemetry_budget_remaining_saturates_when_queue_depth_exceeds_budget() {
627 let config = IoUringLanePolicyConfig {
628 enabled: true,
629 ring_available: true,
630 max_queue_depth: 4,
631 allow_filesystem: true,
632 allow_network: true,
633 };
634 let input = IoUringLaneDecisionInput {
635 capability: HostcallCapabilityClass::Filesystem,
636 io_hint: HostcallIoHint::IoHeavy,
637 queue_depth: 11,
638 force_compat_lane: false,
639 };
640
641 let decision = decide_io_uring_lane(config, input);
642 let telemetry = build_io_uring_lane_telemetry(config, input, decision);
643
644 assert_eq!(decision.lane, HostcallDispatchLane::Fast);
645 assert_eq!(
646 decision.fallback_reason,
647 Some(IoUringFallbackReason::QueueDepthBudgetExceeded)
648 );
649 assert_eq!(
650 telemetry.fallback_code.as_deref(),
651 Some("io_uring_queue_depth_budget_exceeded")
652 );
653 assert!(!telemetry.queue_depth_within_budget);
654 assert_eq!(telemetry.queue_depth_budget_remaining, 0);
655 }
656
657 #[test]
660 fn dispatch_lane_as_str_all_variants() {
661 assert_eq!(HostcallDispatchLane::Fast.as_str(), "fast");
662 assert_eq!(HostcallDispatchLane::IoUring.as_str(), "io_uring");
663 assert_eq!(HostcallDispatchLane::Compat.as_str(), "compat");
664 }
665
666 #[test]
667 fn io_hint_is_io_heavy_only_for_io_heavy_variant() {
668 assert!(HostcallIoHint::IoHeavy.is_io_heavy());
669 assert!(!HostcallIoHint::Unknown.is_io_heavy());
670 assert!(!HostcallIoHint::CpuBound.is_io_heavy());
671 }
672
673 #[test]
674 fn conservative_config_defaults() {
675 let config = IoUringLanePolicyConfig::conservative();
676 assert!(!config.enabled);
677 assert!(!config.ring_available);
678 assert_eq!(config.max_queue_depth, 256);
679 assert!(config.allow_filesystem);
680 assert!(config.allow_network);
681 assert_eq!(IoUringLanePolicyConfig::default(), config);
683 }
684
685 #[test]
686 fn allow_for_capability_only_filesystem_and_network() {
687 let config = IoUringLanePolicyConfig {
688 enabled: true,
689 ring_available: true,
690 max_queue_depth: 8,
691 allow_filesystem: true,
692 allow_network: false,
693 };
694 assert!(config.allow_for_capability(HostcallCapabilityClass::Filesystem));
695 assert!(!config.allow_for_capability(HostcallCapabilityClass::Network));
696 assert!(!config.allow_for_capability(HostcallCapabilityClass::Execution));
698 assert!(!config.allow_for_capability(HostcallCapabilityClass::Session));
699 assert!(!config.allow_for_capability(HostcallCapabilityClass::Events));
700 assert!(!config.allow_for_capability(HostcallCapabilityClass::Environment));
701 assert!(!config.allow_for_capability(HostcallCapabilityClass::Tool));
702 assert!(!config.allow_for_capability(HostcallCapabilityClass::Ui));
703 assert!(!config.allow_for_capability(HostcallCapabilityClass::Telemetry));
704 assert!(!config.allow_for_capability(HostcallCapabilityClass::Unknown));
705 }
706
707 #[test]
708 fn decision_constructors_produce_expected_lanes() {
709 let uring = IoUringLaneDecision::io_uring();
710 assert_eq!(uring.lane, HostcallDispatchLane::IoUring);
711 assert!(uring.fallback_reason.is_none());
712 assert!(uring.fallback_code().is_none());
713
714 let compat = IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
715 assert_eq!(compat.lane, HostcallDispatchLane::Compat);
716 assert_eq!(
717 compat.fallback_reason,
718 Some(IoUringFallbackReason::CompatKillSwitch)
719 );
720 assert_eq!(compat.fallback_code(), Some("forced_compat_kill_switch"));
721
722 let fast = IoUringLaneDecision::fast(IoUringFallbackReason::IoUringDisabled);
723 assert_eq!(fast.lane, HostcallDispatchLane::Fast);
724 assert_eq!(
725 fast.fallback_reason,
726 Some(IoUringFallbackReason::IoUringDisabled)
727 );
728 assert_eq!(fast.fallback_code(), Some("io_uring_disabled"));
729 }
730
731 #[test]
732 fn capability_class_from_all_aliases() {
733 assert_eq!(
735 HostcallCapabilityClass::from_capability("write"),
736 HostcallCapabilityClass::Filesystem
737 );
738 assert_eq!(
739 HostcallCapabilityClass::from_capability("filesystem"),
740 HostcallCapabilityClass::Filesystem
741 );
742 assert_eq!(
743 HostcallCapabilityClass::from_capability("fs"),
744 HostcallCapabilityClass::Filesystem
745 );
746 assert_eq!(
748 HostcallCapabilityClass::from_capability("network"),
749 HostcallCapabilityClass::Network
750 );
751 assert_eq!(
753 HostcallCapabilityClass::from_capability("exec"),
754 HostcallCapabilityClass::Execution
755 );
756 assert_eq!(
757 HostcallCapabilityClass::from_capability("execution"),
758 HostcallCapabilityClass::Execution
759 );
760 assert_eq!(
762 HostcallCapabilityClass::from_capability("env"),
763 HostcallCapabilityClass::Environment
764 );
765 assert_eq!(
766 HostcallCapabilityClass::from_capability("environment"),
767 HostcallCapabilityClass::Environment
768 );
769 assert_eq!(
771 HostcallCapabilityClass::from_capability("events"),
772 HostcallCapabilityClass::Events
773 );
774 assert_eq!(
776 HostcallCapabilityClass::from_capability("tool"),
777 HostcallCapabilityClass::Tool
778 );
779 assert_eq!(
781 HostcallCapabilityClass::from_capability("ui"),
782 HostcallCapabilityClass::Ui
783 );
784 assert_eq!(
786 HostcallCapabilityClass::from_capability("log"),
787 HostcallCapabilityClass::Telemetry
788 );
789 assert_eq!(
790 HostcallCapabilityClass::from_capability("telemetry"),
791 HostcallCapabilityClass::Telemetry
792 );
793 assert_eq!(
795 HostcallCapabilityClass::from_capability("READ"),
796 HostcallCapabilityClass::Filesystem
797 );
798 assert_eq!(
799 HostcallCapabilityClass::from_capability(" HTTP "),
800 HostcallCapabilityClass::Network
801 );
802 }
803
804 #[test]
805 fn fallback_reason_as_code_all_variants() {
806 assert_eq!(
807 IoUringFallbackReason::CompatKillSwitch.as_code(),
808 "forced_compat_kill_switch"
809 );
810 assert_eq!(
811 IoUringFallbackReason::IoUringDisabled.as_code(),
812 "io_uring_disabled"
813 );
814 assert_eq!(
815 IoUringFallbackReason::IoUringUnavailable.as_code(),
816 "io_uring_unavailable"
817 );
818 assert_eq!(
819 IoUringFallbackReason::MissingIoHint.as_code(),
820 "io_hint_missing"
821 );
822 assert_eq!(
823 IoUringFallbackReason::UnsupportedCapability.as_code(),
824 "io_uring_capability_not_supported"
825 );
826 assert_eq!(
827 IoUringFallbackReason::QueueDepthBudgetExceeded.as_code(),
828 "io_uring_queue_depth_budget_exceeded"
829 );
830 }
831
832 #[test]
833 fn serde_roundtrip_decision_and_lane() {
834 let decision = IoUringLaneDecision::io_uring();
835 let json = serde_json::to_string(&decision).expect("serialize");
836 let back: IoUringLaneDecision = serde_json::from_str(&json).expect("deserialize");
837 assert_eq!(back, decision);
838
839 let compat = IoUringLaneDecision::compat(IoUringFallbackReason::CompatKillSwitch);
840 let json2 = serde_json::to_string(&compat).expect("serialize");
841 let back2: IoUringLaneDecision = serde_json::from_str(&json2).expect("deserialize");
842 assert_eq!(back2, compat);
843 }
844
845 #[test]
846 fn decide_with_telemetry_matches_core_decision() {
847 let config = enabled_config();
848 let input = IoUringLaneDecisionInput {
849 capability: HostcallCapabilityClass::Network,
850 io_hint: HostcallIoHint::CpuBound,
851 queue_depth: 1,
852 force_compat_lane: false,
853 };
854 let expected = decide_io_uring_lane(config, input);
855 let (actual, telemetry) = decide_io_uring_lane_with_telemetry(config, input);
856 assert_eq!(actual, expected);
857 assert_eq!(telemetry.lane, expected.lane);
858 assert_eq!(telemetry.fallback_reason, expected.fallback_reason);
859 assert_eq!(telemetry.fallback_code.as_deref(), expected.fallback_code());
860 }
861
862 mod proptest_io_uring_lane {
865 use super::*;
866 use proptest::prelude::*;
867
868 fn arb_capability() -> impl Strategy<Value = HostcallCapabilityClass> {
869 prop::sample::select(vec![
870 HostcallCapabilityClass::Filesystem,
871 HostcallCapabilityClass::Network,
872 HostcallCapabilityClass::Execution,
873 HostcallCapabilityClass::Session,
874 HostcallCapabilityClass::Events,
875 HostcallCapabilityClass::Environment,
876 HostcallCapabilityClass::Tool,
877 HostcallCapabilityClass::Ui,
878 HostcallCapabilityClass::Telemetry,
879 HostcallCapabilityClass::Unknown,
880 ])
881 }
882
883 fn arb_io_hint() -> impl Strategy<Value = HostcallIoHint> {
884 prop::sample::select(vec![
885 HostcallIoHint::Unknown,
886 HostcallIoHint::IoHeavy,
887 HostcallIoHint::CpuBound,
888 ])
889 }
890
891 fn arb_config() -> impl Strategy<Value = IoUringLanePolicyConfig> {
892 (
893 any::<bool>(),
894 any::<bool>(),
895 1..512usize,
896 any::<bool>(),
897 any::<bool>(),
898 )
899 .prop_map(
900 |(enabled, ring_available, max_queue_depth, allow_fs, allow_net)| {
901 IoUringLanePolicyConfig {
902 enabled,
903 ring_available,
904 max_queue_depth,
905 allow_filesystem: allow_fs,
906 allow_network: allow_net,
907 }
908 },
909 )
910 }
911
912 fn arb_input() -> impl Strategy<Value = IoUringLaneDecisionInput> {
913 (arb_capability(), arb_io_hint(), 0..1024usize, any::<bool>()).prop_map(
914 |(capability, io_hint, queue_depth, force_compat_lane)| IoUringLaneDecisionInput {
915 capability,
916 io_hint,
917 queue_depth,
918 force_compat_lane,
919 },
920 )
921 }
922
923 proptest! {
924 #[test]
925 fn force_compat_always_returns_compat_lane(
926 cfg in arb_config(),
927 capability in arb_capability(),
928 io_hint in arb_io_hint(),
929 queue_depth in 0..1024usize,
930 ) {
931 let input = IoUringLaneDecisionInput {
932 capability,
933 io_hint,
934 queue_depth,
935 force_compat_lane: true,
936 };
937 let decision = decide_io_uring_lane(cfg, input);
938 assert_eq!(decision.lane, HostcallDispatchLane::Compat);
939 assert_eq!(
940 decision.fallback_reason,
941 Some(IoUringFallbackReason::CompatKillSwitch)
942 );
943 }
944
945 #[test]
946 fn disabled_never_returns_io_uring(
947 cfg_base in arb_config(),
948 input in arb_input(),
949 ) {
950 let cfg = IoUringLanePolicyConfig {
951 enabled: false,
952 ..cfg_base
953 };
954 let input = IoUringLaneDecisionInput {
955 force_compat_lane: false,
956 ..input
957 };
958 let decision = decide_io_uring_lane(cfg, input);
959 assert_ne!(
960 decision.lane,
961 HostcallDispatchLane::IoUring,
962 "disabled config must never select io_uring"
963 );
964 }
965
966 #[test]
967 fn io_uring_only_when_all_preconditions_met(
968 cfg in arb_config(),
969 input in arb_input(),
970 ) {
971 let decision = decide_io_uring_lane(cfg, input);
972 if decision.lane == HostcallDispatchLane::IoUring {
973 assert!(!input.force_compat_lane, "compat kill-switch must be off");
974 assert!(cfg.enabled, "policy must be enabled");
975 assert!(cfg.ring_available, "ring must be available");
976 assert!(input.io_hint.is_io_heavy(), "must be IO-heavy");
977 assert!(
978 cfg.allow_for_capability(input.capability),
979 "capability must be allowed"
980 );
981 assert!(
982 input.queue_depth < cfg.max_queue_depth,
983 "queue depth must be within budget"
984 );
985 }
986 }
987
988 #[test]
989 fn decision_always_has_fallback_reason_unless_io_uring(
990 cfg in arb_config(),
991 input in arb_input(),
992 ) {
993 let decision = decide_io_uring_lane(cfg, input);
994 if decision.lane == HostcallDispatchLane::IoUring {
995 assert!(
996 decision.fallback_reason.is_none(),
997 "io_uring lane must have no fallback reason"
998 );
999 } else {
1000 assert!(
1001 decision.fallback_reason.is_some(),
1002 "non-io_uring lane must have a fallback reason"
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn telemetry_consistent_with_decision(
1009 cfg in arb_config(),
1010 input in arb_input(),
1011 ) {
1012 let (decision, telemetry) = decide_io_uring_lane_with_telemetry(cfg, input);
1013 assert_eq!(telemetry.lane, decision.lane);
1014 assert_eq!(telemetry.fallback_reason, decision.fallback_reason);
1015 assert_eq!(telemetry.policy_enabled, cfg.enabled);
1016 assert_eq!(telemetry.ring_available, cfg.ring_available);
1017 assert_eq!(telemetry.force_compat_lane, input.force_compat_lane);
1018 assert_eq!(telemetry.queue_depth, input.queue_depth);
1019 assert_eq!(telemetry.queue_depth_budget, cfg.max_queue_depth);
1020 }
1021
1022 #[test]
1023 fn decide_is_deterministic(
1024 cfg in arb_config(),
1025 input in arb_input(),
1026 ) {
1027 let d1 = decide_io_uring_lane(cfg, input);
1028 let d2 = decide_io_uring_lane(cfg, input);
1029 assert_eq!(d1, d2, "same inputs must produce same decision");
1030 }
1031 }
1032 }
1033}