chio_kernel/kernel/dispatch.rs
1//! `ChioKernel` guard evaluation, runtime admission, and tool dispatch.
2//!
3//! Holds parent-request continuation, guard execution, runtime admission
4//! hook invocation, the tool-dispatch entrypoints, and child-receipt
5//! recording.
6
7use crate::budget_store::BudgetReverseHoldDecision;
8use chio_log_redact::redacted;
9
10use super::*;
11
12pub(crate) struct GuardRunError {
13 pub(crate) error: KernelError,
14 pub(crate) evidence: Vec<chio_core::receipt::metadata::GuardEvidence>,
15}
16
17impl GuardRunError {
18 fn new(error: KernelError, evidence: Vec<chio_core::receipt::metadata::GuardEvidence>) -> Self {
19 Self { error, evidence }
20 }
21}
22
23/// Owned copy of a guard invocation, so the sequential guard core can run inside
24/// `spawn_blocking` (which requires a `'static` closure) without borrowing from
25/// the async evaluate future.
26struct OwnedGuardInvocation {
27 request: ToolCallRequest,
28 scope: ChioScope,
29 session_filesystem_roots: Option<Vec<String>>,
30 matched_grant_index: Option<usize>,
31}
32
33/// Synchronous fail-closed guard loop. Shared by the inline path and the
34/// offloaded (`spawn_blocking`) path so the two can never diverge. Any deny,
35/// unsupported approval verdict, or guard error short-circuits fail-closed.
36fn evaluate_guards_sequential(
37 guards: &[Arc<dyn Guard>],
38 ctx: &GuardContext,
39) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
40 let mut evidence = Vec::new();
41 for guard in guards {
42 match guard.evaluate(ctx) {
43 Ok(decision) => {
44 evidence.extend(decision.evidence);
45 match decision.verdict {
46 Verdict::Allow => {
47 debug!(guard = guard.name(), "guard passed");
48 }
49 Verdict::Deny => {
50 return Err(GuardRunError::new(
51 KernelError::GuardDenied(format!(
52 "guard \"{}\" denied the request",
53 guard.name()
54 )),
55 evidence,
56 ));
57 }
58 Verdict::PendingApproval => {
59 // The `Guard` trait does not carry the HITL approval flow; that runs via
60 // `ApprovalGuard::evaluate`. A `Guard` returning `PendingApproval` is an
61 // unsupported state, so fail closed.
62 return Err(GuardRunError::new(
63 KernelError::GuardDenied(format!(
64 "guard \"{}\" returned an unsupported approval verdict",
65 guard.name()
66 )),
67 evidence,
68 ));
69 }
70 }
71 }
72 Err(e) => {
73 // Fail closed: guard errors are treated as denials.
74 return Err(GuardRunError::new(
75 KernelError::GuardDenied(format!(
76 "guard \"{}\" error (fail-closed): {e}",
77 guard.name()
78 )),
79 evidence,
80 ));
81 }
82 }
83 }
84 Ok(evidence)
85}
86
87/// Run the guard loop over an owned invocation, rebuilding the borrowed
88/// `GuardContext` from the owned fields. Used by the blocking offload.
89fn run_guards_owned(
90 guards: &[Arc<dyn Guard>],
91 owned: &OwnedGuardInvocation,
92) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
93 let ctx = GuardContext {
94 request: &owned.request,
95 scope: &owned.scope,
96 agent_id: &owned.request.agent_id,
97 server_id: &owned.request.server_id,
98 session_filesystem_roots: owned.session_filesystem_roots.as_deref(),
99 matched_grant_index: owned.matched_grant_index,
100 };
101 evaluate_guards_sequential(guards, &ctx)
102}
103
104fn budget_ms_saturating(budget: std::time::Duration) -> u64 {
105 budget.as_millis().min(u128::from(u64::MAX)) as u64
106}
107
108/// The fail-closed error returned when a tool-server call outruns its dispatch
109/// budget. Shared by every dispatch path (top-level and nested-flow) so the
110/// timeout verdict is byte-identical wherever the deadline fires.
111fn dispatch_deadline_exceeded(budget: std::time::Duration) -> KernelError {
112 KernelError::HotPathDeadlineExceeded {
113 stage: HotPathStage::Dispatch,
114 budget_ms: budget_ms_saturating(budget),
115 }
116}
117
118/// Aborts an offloaded `spawn_blocking` task when the awaiting future is
119/// dropped, whether because its deadline fired or because the caller was
120/// cancelled. Dropping a bare `JoinHandle` only *detaches* the task: one still
121/// queued on a saturated blocking pool would then start and run the tool call
122/// (or guard) after the kernel has already emitted a timed-out response and
123/// unwound its charges. Aborting cancels a not-yet-started task so it cannot
124/// execute side effects past the deadline; a task already running its blocking
125/// body cannot be interrupted, but its own inner deadline still frees it, and a
126/// task that already finished is aborted as a harmless no-op.
127struct AbortOnDrop(tokio::task::AbortHandle);
128
129impl Drop for AbortOnDrop {
130 fn drop(&mut self) {
131 self.0.abort();
132 }
133}
134
135thread_local! {
136 /// Cached probe of whether the currently entered Tokio runtime has a timer
137 /// driver, keyed by that runtime's id. Timer availability is a property of the
138 /// entered runtime, not the OS thread, so caching a bare verdict per thread
139 /// would let a timerless verdict leak into a later timer-enabled runtime on
140 /// the same thread (skipping deadlines) or a timer-enabled verdict leak into a
141 /// later timerless one (panicking on timer construction). The key is the
142 /// runtime id (`None` for "no runtime entered"); the verdict is re-probed
143 /// whenever the entered runtime changes. Within a single runtime the cache
144 /// keeps the panic-hook swap off the steady-state hot path.
145 static DISPATCH_TIMER_AVAILABLE: std::cell::Cell<Option<(Option<tokio::runtime::Id>, bool)>> =
146 const { std::cell::Cell::new(None) };
147}
148
149/// Serializes the panic-hook swap in [`dispatch_timer_available`] so concurrent
150/// first-probes on different worker threads cannot interleave their
151/// take/set-hook pairs and leave the silencing hook installed process-wide.
152static TIMER_PROBE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
153
154/// Whether `tokio::time::timeout` can run in the current context without
155/// panicking, i.e. a Tokio runtime is entered and its time driver is enabled.
156///
157/// `Handle::try_current()` only proves a runtime is entered, not that timers are
158/// enabled; a host runtime built without `enable_time` panics when a timer is
159/// constructed. Tokio exposes no query for the driver, so this probes by
160/// constructing one zero-duration timer under a caught unwind. The panic is
161/// synchronous at construction, and the panic hook is silenced for the probe so
162/// a timerless host does not emit a spurious backtrace. The swap is serialized
163/// so the real hook is always restored, and the result is cached per runtime per
164/// thread; callers fall back to running the guarded work inline when it is false.
165pub(crate) fn dispatch_timer_available() -> bool {
166 let current_runtime = tokio::runtime::Handle::try_current()
167 .ok()
168 .map(|handle| handle.id());
169 DISPATCH_TIMER_AVAILABLE.with(|cached| {
170 if let Some((probed_runtime, available)) = cached.get() {
171 if probed_runtime == current_runtime {
172 return available;
173 }
174 }
175 let available = {
176 let _serialized = TIMER_PROBE_LOCK
177 .lock()
178 .unwrap_or_else(|poisoned| poisoned.into_inner());
179 let previous_hook = std::panic::take_hook();
180 std::panic::set_hook(Box::new(|_| {}));
181 let result = std::panic::catch_unwind(|| {
182 drop(tokio::time::timeout(
183 std::time::Duration::ZERO,
184 std::future::ready(()),
185 ));
186 })
187 .is_ok();
188 std::panic::set_hook(previous_hook);
189 result
190 };
191 cached.set(Some((current_runtime, available)));
192 available
193 })
194}
195
196/// Whether a Tokio runtime is entered in the current context. `spawn_blocking`,
197/// used to offload a blocking guard, requires one; a synchronous host driving
198/// dispatch through `futures::executor::block_on` has none, so the offload must
199/// degrade to running the guards inline rather than panicking.
200pub(crate) fn dispatch_runtime_available() -> bool {
201 tokio::runtime::Handle::try_current().is_ok()
202}
203
204/// Bound a nested-flow tool-server call by its dispatch `budget`.
205///
206/// The top-level dispatch path moves a budgeted call onto `spawn_blocking` so a
207/// connection that blocks synchronously before its first `.await` cannot pin an
208/// async worker. A nested-flow call cannot use that mechanism: its future
209/// borrows the nested-flow bridge (the caller's `&mut` client, the session map,
210/// and the child-receipt buffer), so it is neither `Send` nor `'static`; it can
211/// be moved to no other thread, nor detached after a deadline without leaving
212/// those borrows dangling.
213///
214/// On a multi-thread runtime the call is therefore driven under
215/// [`tokio::task::block_in_place`], which requires neither bound: Tokio promotes
216/// a replacement worker while this thread blocks, so a nested connection that
217/// blocks synchronously before its first `.await` no longer starves the async
218/// worker pool, and the inner timeout still fails a *cooperating* call closed at
219/// the budget. A call wedged in a synchronous poll cannot be interrupted (the
220/// timer cannot be polled on the blocked thread); with a borrowed bridge that
221/// cannot be handed to a detachable task this is inherent, and it stays confined
222/// to the one blocked thread rather than the whole pool.
223///
224/// On a current-thread runtime there is no spare worker to promote, and with no
225/// timer driver the timeout wrapper would panic, so the call runs inline: under
226/// the timeout when a timer is present, and directly otherwise.
227pub(crate) async fn dispatch_nested_call_within_budget<F, T>(
228 call: F,
229 budget: std::time::Duration,
230) -> Result<T, KernelError>
231where
232 F: std::future::Future<Output = Result<T, KernelError>>,
233{
234 async fn bounded<F, T>(
235 call: F,
236 budget: std::time::Duration,
237 timer_available: bool,
238 ) -> Result<T, KernelError>
239 where
240 F: std::future::Future<Output = Result<T, KernelError>>,
241 {
242 if timer_available {
243 match tokio::time::timeout(budget, call).await {
244 Ok(result) => result,
245 Err(_elapsed) => Err(dispatch_deadline_exceeded(budget)),
246 }
247 } else {
248 call.await
249 }
250 }
251
252 let timer_available = dispatch_timer_available();
253 let multi_thread = matches!(
254 tokio::runtime::Handle::try_current().map(|handle| handle.runtime_flavor()),
255 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
256 );
257 if multi_thread {
258 let handle = tokio::runtime::Handle::current();
259 tokio::task::block_in_place(|| handle.block_on(bounded(call, budget, timer_available)))
260 } else {
261 bounded(call, budget, timer_available).await
262 }
263}
264
265impl ChioKernel {
266 pub(crate) fn validate_parent_request_continuation(
267 &self,
268 request: &ToolCallRequest,
269 parent_context: &OperationContext,
270 ) -> Result<(), KernelError> {
271 let child_request_id = RequestId::new(request.request_id.clone());
272 self.with_session(&parent_context.session_id, |session| {
273 session.validate_context(parent_context)?;
274 session
275 .validate_parent_request_lineage(&child_request_id, &parent_context.request_id)?;
276 Ok(())
277 })
278 }
279
280 pub(crate) fn has_local_receipt_id(&self, receipt_id: &str) -> Result<bool, KernelError> {
281 if self.load_durable_admission_receipt(receipt_id)?.is_some() {
282 return Ok(true);
283 }
284 // Store-authoritative: a durable store is a point lookup by id, not an
285 // O(n) mirror scan. On a store MISS fall back to the local mirror below: a
286 // store may implement append without point loads (for example an
287 // append-only or remote store), so a receipt appended and mirrored locally
288 // must still resolve. A store READ ERROR fails closed and PROPAGATES; only
289 // a genuine miss (`Ok(None)`) falls through to the mirror, so a store
290 // verification failure is never masked by a mirror hit.
291 //
292 // Boundary: if that append-only/remote store does not implement point
293 // loads, the bounded mirror is the ONLY lookup source.
294 // Once the mirror evicts a receipt past `receipt_mirror_capacity`, this
295 // returns `Ok(false)` and the dependent call-chain claim is denied
296 // (fail-closed, never a false allow). Such deployments must implement
297 // `ReceiptStore::load_chio_receipt` so older parent receipts stay
298 // point-loadable after eviction.
299 if self.receipt_store.is_some() {
300 if self
301 .with_receipt_store(|store| Ok(store.load_chio_receipt(receipt_id)?))?
302 .flatten()
303 .is_some()
304 {
305 return Ok(true);
306 }
307 if self
308 .with_receipt_store(|store| Ok(store.load_child_receipt(receipt_id)?))?
309 .flatten()
310 .is_some()
311 {
312 return Ok(true);
313 }
314 // Store miss: fall through to the local mirror scan.
315 }
316 // Local mirror scan (no store, or store missed).
317 let chio_receipt_match = match self.receipt_log.lock() {
318 Ok(log) => log.iter().any(|receipt| receipt.id == receipt_id),
319 Err(poisoned) => poisoned
320 .into_inner()
321 .iter()
322 .any(|receipt| receipt.id == receipt_id),
323 };
324 if chio_receipt_match {
325 return Ok(true);
326 }
327
328 Ok(match self.child_receipt_log.lock() {
329 Ok(log) => log.iter().any(|receipt| receipt.id == receipt_id),
330 Err(poisoned) => poisoned
331 .into_inner()
332 .iter()
333 .any(|receipt| receipt.id == receipt_id),
334 })
335 }
336
337 pub(crate) fn local_receipt_artifact(
338 &self,
339 receipt_id: &str,
340 ) -> Result<Option<LocalReceiptArtifact>, KernelError> {
341 if let Some(receipt) = self.load_durable_admission_receipt(receipt_id)? {
342 return Ok(Some(LocalReceiptArtifact::Tool(Box::new(receipt))));
343 }
344 // Consult the durable store first; on a MISS fall back to the local
345 // mirror (append-only / remote stores may not implement point loads, so
346 // a receipt appended and mirrored locally must still resolve). A store
347 // READ ERROR fails closed and PROPAGATES; only a genuine miss
348 // (`Ok(None)`) falls through to the mirror, so a store verification
349 // failure can never be accepted from the bounded mirror.
350 if self.receipt_store.is_some() {
351 if let Some(receipt) = self
352 .with_receipt_store(|store| Ok(store.load_chio_receipt(receipt_id)?))?
353 .flatten()
354 {
355 return Ok(Some(LocalReceiptArtifact::Tool(Box::new(receipt))));
356 }
357 if let Some(child) = self
358 .with_receipt_store(|store| Ok(store.load_child_receipt(receipt_id)?))?
359 .flatten()
360 {
361 return Ok(Some(LocalReceiptArtifact::Child(Box::new(child))));
362 }
363 // Store miss: fall through to the local mirror scan.
364 }
365 let tool_match = match self.receipt_log.lock() {
366 Ok(log) => log
367 .iter()
368 .find(|receipt| receipt.id == receipt_id)
369 .cloned()
370 .map(|receipt| LocalReceiptArtifact::Tool(Box::new(receipt))),
371 Err(poisoned) => poisoned
372 .into_inner()
373 .iter()
374 .find(|receipt| receipt.id == receipt_id)
375 .cloned()
376 .map(|receipt| LocalReceiptArtifact::Tool(Box::new(receipt))),
377 };
378 if tool_match.is_some() {
379 return Ok(tool_match);
380 }
381
382 Ok(match self.child_receipt_log.lock() {
383 Ok(log) => log
384 .iter()
385 .find(|receipt| receipt.id == receipt_id)
386 .cloned()
387 .map(|receipt| LocalReceiptArtifact::Child(Box::new(receipt))),
388 Err(poisoned) => poisoned
389 .into_inner()
390 .iter()
391 .find(|receipt| receipt.id == receipt_id)
392 .cloned()
393 .map(|receipt| LocalReceiptArtifact::Child(Box::new(receipt))),
394 })
395 }
396
397 pub(crate) fn is_trusted_governed_continuation_signer(
398 &self,
399 signer: &chio_core::PublicKey,
400 ) -> bool {
401 if *signer == self.config.keypair.public_key() {
402 return true;
403 }
404 if self
405 .config
406 .ca_public_keys
407 .iter()
408 .any(|candidate| candidate == signer)
409 {
410 return true;
411 }
412 self.capability_authority
413 .trusted_public_keys()
414 .into_iter()
415 .any(|candidate| candidate == *signer)
416 }
417
418 pub(crate) fn unwind_pre_dispatch_monetary_invocation(
419 &self,
420 request: &ToolCallRequest,
421 cap: &CapabilityToken,
422 charge_result: Option<&BudgetChargeResult>,
423 payment_authorization: Option<&PaymentAuthorization>,
424 ) -> Result<Option<BudgetReverseHoldDecision>, KernelError> {
425 if let Some(authorization) = payment_authorization {
426 let adapter = self.payment_adapter.as_ref().ok_or_else(|| {
427 KernelError::Internal(
428 "payment authorization present without configured adapter".to_string(),
429 )
430 })?;
431 let refund_amount = ChioKernel::mustprepay_quoted_amount(request).or_else(|| {
432 charge_result.map(|charge| (charge.cost_charged, charge.currency.clone()))
433 });
434 let (unwind_result, expected_status) = if authorization.state.is_final() {
435 let (amount_units, currency) = refund_amount.ok_or_else(|| {
436 KernelError::Internal(
437 "final payment authorization omitted a refundable amount".to_string(),
438 )
439 })?;
440 (
441 adapter.refund(
442 &authorization.authorization_id,
443 amount_units,
444 ¤cy,
445 &request.request_id,
446 ),
447 RailSettlementStatus::Refunded,
448 )
449 } else {
450 (
451 adapter.release(&authorization.authorization_id, &request.request_id),
452 RailSettlementStatus::Released,
453 )
454 };
455 match unwind_result {
456 Ok(result) if result.settlement_status == expected_status => {}
457 Ok(_) => {
458 return Err(KernelError::Internal(
459 "payment unwind returned an unconfirmed status".to_string(),
460 ));
461 }
462 Err(_) => {
463 return Err(KernelError::Internal(
464 "payment unwind acknowledgement was not confirmed".to_string(),
465 ));
466 }
467 }
468 }
469
470 let Some(charge) = charge_result else {
471 return Ok(None);
472 };
473
474 if charge.invocation_capture.is_some() {
475 Ok(Some(self.cancel_captured_monetary_before_dispatch(
476 &cap.id, charge,
477 )?))
478 } else {
479 Ok(Some(self.reverse_budget_charge(&cap.id, charge)?))
480 }
481 }
482
483 pub(crate) fn record_observed_capability_snapshot(
484 &self,
485 capability: &CapabilityToken,
486 ) -> Result<(), KernelError> {
487 let parent_capability_id = capability
488 .delegation_chain
489 .last()
490 .map(|link| link.capability_id.as_str());
491 // Bound the snapshot write by the receipt append budget. The
492 // pre-dispatch liveness gate denies an already-wedged writer, but a
493 // writer that passes the check and then stalls on this write must fail
494 // closed within budget rather than hang the request before dispatch.
495 let budget = self.config.deadlines.receipt_append_budget();
496 let _ = self.with_receipt_store(|store| {
497 Ok(store.record_capability_snapshot_with_timeout(
498 capability,
499 parent_capability_id,
500 budget,
501 )?)
502 })?;
503 Ok(())
504 }
505
506 /// Verify a DPoP proof carried on the request against the capability.
507 ///
508 /// Fails closed: if no proof is present, or if the nonce store / config is
509 /// absent (misconfigured kernel), or if verification fails, the call is denied.
510 pub(crate) fn verify_dpop_for_request(
511 &self,
512 request: &ToolCallRequest,
513 cap: &CapabilityToken,
514 ) -> Result<(), KernelError> {
515 let proof = request.dpop_proof.as_ref().ok_or_else(|| {
516 KernelError::DpopVerificationFailed(
517 "grant requires DPoP proof but none was provided".to_string(),
518 )
519 })?;
520
521 let nonce_store = self.dpop_nonce_store.as_ref().ok_or_else(|| {
522 KernelError::DpopVerificationFailed(
523 "kernel DPoP nonce store not configured".to_string(),
524 )
525 })?;
526
527 let config = self.dpop_config.as_ref().ok_or_else(|| {
528 KernelError::DpopVerificationFailed("kernel DPoP config not configured".to_string())
529 })?;
530
531 let args_bytes = canonical_json_bytes(&request.arguments).map_err(|e| {
532 KernelError::DpopVerificationFailed(format!(
533 "failed to serialize arguments for action hash: {e}"
534 ))
535 })?;
536 let action_hash = sha256_hex(&args_bytes);
537
538 dpop::verify_dpop_proof(
539 proof,
540 cap,
541 &request.server_id,
542 &request.tool_name,
543 &action_hash,
544 nonce_store,
545 config,
546 )
547 }
548
549 /// Verify a DPoP proof for non-mutating permission preview.
550 ///
551 /// This mirrors invocation DPoP policy and checks that the nonce store and
552 /// config are installed, but deliberately avoids inserting the nonce so a
553 /// later authoritative invocation can still spend it.
554 pub fn verify_dpop_for_permission_preview(
555 &self,
556 proof: &dpop::DpopProof,
557 cap: &CapabilityToken,
558 expected_tool_server: &str,
559 expected_tool_name: &str,
560 arguments: &serde_json::Value,
561 ) -> Result<(), KernelError> {
562 if self.dpop_nonce_store.is_none() {
563 return Err(KernelError::DpopVerificationFailed(
564 "kernel DPoP nonce store not configured".to_string(),
565 ));
566 }
567
568 let config = self.dpop_config.as_ref().ok_or_else(|| {
569 KernelError::DpopVerificationFailed("kernel DPoP config not configured".to_string())
570 })?;
571
572 let args_bytes = canonical_json_bytes(arguments).map_err(|e| {
573 KernelError::DpopVerificationFailed(format!(
574 "failed to serialize arguments for action hash: {e}"
575 ))
576 })?;
577 let action_hash = sha256_hex(&args_bytes);
578
579 dpop::verify_dpop_proof_stateless(
580 proof,
581 cap,
582 expected_tool_server,
583 expected_tool_name,
584 &action_hash,
585 config,
586 )
587 }
588
589 /// Run all registered guards. Fail-closed: any error from a guard is
590 /// treated as a deny.
591 pub(crate) fn run_guards(
592 &self,
593 request: &ToolCallRequest,
594 scope: &ChioScope,
595 session_filesystem_roots: Option<&[String]>,
596 matched_grant_index: Option<usize>,
597 ) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
598 let ctx = GuardContext {
599 request,
600 scope,
601 agent_id: &request.agent_id,
602 server_id: &request.server_id,
603 session_filesystem_roots,
604 matched_grant_index,
605 };
606 evaluate_guards_sequential(self.guards.as_slice(), &ctx)
607 }
608
609 /// Async wrapper deciding how to run the synchronous guard pipeline. When a
610 /// pipeline budget, a per-guard override, or `always_offload_guards` applies,
611 /// the sync core runs under `spawn_blocking` (wrapped in `tokio::time::timeout`
612 /// only when the runtime has a timer driver), so a blocking guard can no longer
613 /// pin an async worker and a hung guard fails closed as
614 /// `HotPathDeadlineExceeded`. A budget with no timer driver degrades to inline
615 /// because the timeout wrapper would panic; `always_offload_guards` still
616 /// offloads in that case and simply skips the unenforceable timeout. With no
617 /// Tokio runtime at all `spawn_blocking` is unavailable, so the guards run
618 /// inline.
619 ///
620 /// `tokio::time::timeout` drops the `JoinHandle` on expiry, which detaches
621 /// (does not kill) the blocking thread: a runaway guard runs to completion
622 /// on the blocking pool with its result discarded, while the async worker is
623 /// freed and the request fails fast. The blast radius is contained in the
624 /// blocking pool instead of starving the async worker pool.
625 pub(crate) async fn run_guards_within_budget(
626 &self,
627 request: &ToolCallRequest,
628 scope: &ChioScope,
629 session_filesystem_roots: Option<&[String]>,
630 matched_grant_index: Option<usize>,
631 ) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
632 let has_per_guard = !self.config.deadlines.per_guard_budget_ms.is_empty();
633 let pipeline_budget = self.config.deadlines.guard_pipeline_budget();
634 let needs_timer = pipeline_budget.is_some() || has_per_guard;
635 let always_offload = self.config.deadlines.always_offload_guards;
636 let want_offload = needs_timer || always_offload;
637
638 // Offloading needs an entered Tokio runtime, since `spawn_blocking` panics
639 // without one; a synchronous host bridging dispatch through
640 // `futures::executor::block_on` has no runtime, so the offload degrades to
641 // inline there.
642 if !want_offload || !dispatch_runtime_available() {
643 return self.run_guards(
644 request,
645 scope,
646 session_filesystem_roots,
647 matched_grant_index,
648 );
649 }
650
651 // A budget is only enforceable with a Tokio time driver: the timeout
652 // wrapper panics without one. A budget-only configuration therefore
653 // degrades to inline when no timer is present. `always_offload_guards` is
654 // different: it asks to keep a blocking guard off the async worker
655 // regardless of budgets, so it still offloads onto `spawn_blocking` here
656 // and only skips the (unenforceable) timeout.
657 let timer_available = dispatch_timer_available();
658 if needs_timer && !timer_available && !always_offload {
659 return self.run_guards(
660 request,
661 scope,
662 session_filesystem_roots,
663 matched_grant_index,
664 );
665 }
666
667 let owned = Arc::new(OwnedGuardInvocation {
668 request: request.clone(),
669 scope: scope.clone(),
670 session_filesystem_roots: session_filesystem_roots.map(<[String]>::to_vec),
671 matched_grant_index,
672 });
673
674 // Per-guard budgets require a per-guard timeout, so this path only runs
675 // with a timer driver. Without one (reached only because
676 // `always_offload_guards` forced the offload past the missing timer),
677 // fall through to a single whole-pipeline `spawn_blocking` with no
678 // enforceable timeout.
679 if has_per_guard && timer_available {
680 // Per-guard budgets bound each guard individually, but the whole
681 // loop must still honor the pipeline budget: without an outer
682 // deadline a chain of guards, each within its own budget, can run
683 // far past the configured pipeline wall-clock limit. Keep the
684 // pipeline deadline around the loop so total guard time stays bounded.
685 let per_guard = self.run_guards_per_guard_offloaded(&owned);
686 return match pipeline_budget {
687 Some(budget) => match tokio::time::timeout(budget, per_guard).await {
688 Ok(result) => result,
689 Err(_elapsed) => Err(GuardRunError::new(
690 KernelError::HotPathDeadlineExceeded {
691 stage: HotPathStage::GuardPipeline,
692 budget_ms: budget_ms_saturating(budget),
693 },
694 Vec::new(),
695 )),
696 },
697 None => per_guard.await,
698 };
699 }
700
701 let guards = Arc::clone(&self.guards);
702 let owned_for_task = Arc::clone(&owned);
703 let join = tokio::task::spawn_blocking(move || run_guards_owned(&guards, &owned_for_task));
704 // Abort the offloaded guard loop if the pipeline deadline fires or this
705 // future is cancelled, so a task still queued on a saturated blocking
706 // pool cannot run the (side-effecting) guards after the request has
707 // already failed closed.
708 let _abort_on_drop = AbortOnDrop(join.abort_handle());
709 match pipeline_budget.filter(|_| timer_available) {
710 Some(budget) => match tokio::time::timeout(budget, join).await {
711 Ok(Ok(result)) => result,
712 Ok(Err(join_err)) => Err(GuardRunError::new(
713 KernelError::Internal(format!("guard task join failed: {join_err}")),
714 Vec::new(),
715 )),
716 Err(_elapsed) => Err(GuardRunError::new(
717 KernelError::HotPathDeadlineExceeded {
718 stage: HotPathStage::GuardPipeline,
719 budget_ms: budget_ms_saturating(budget),
720 },
721 Vec::new(),
722 )),
723 },
724 None => match join.await {
725 Ok(result) => result,
726 Err(join_err) => Err(GuardRunError::new(
727 KernelError::Internal(format!("guard task join failed: {join_err}")),
728 Vec::new(),
729 )),
730 },
731 }
732 }
733
734 /// Enforce each guard against its own effective budget, so one wedged guard
735 /// is bounded to its own budget while the rest still run. One blocking
736 /// handoff per guard; used only when per-guard budgets are configured.
737 async fn run_guards_per_guard_offloaded(
738 &self,
739 owned: &Arc<OwnedGuardInvocation>,
740 ) -> Result<Vec<chio_core::receipt::metadata::GuardEvidence>, GuardRunError> {
741 let mut evidence = Vec::new();
742 for guard in self.guards.iter() {
743 let budget = self.config.deadlines.guard_budget_for(guard.name());
744 let guard = Arc::clone(guard);
745 let owned = Arc::clone(owned);
746 let run_one = tokio::task::spawn_blocking(move || {
747 run_guards_owned(std::slice::from_ref(&guard), &owned)
748 });
749 // Abort this guard's offloaded task if its own budget fires or the
750 // enclosing pipeline deadline drops this future, so a task still
751 // queued on a saturated blocking pool cannot run the guard after the
752 // request has already failed closed.
753 let _abort_on_drop = AbortOnDrop(run_one.abort_handle());
754 let outcome = match budget {
755 Some(budget) => match tokio::time::timeout(budget, run_one).await {
756 Ok(joined) => joined,
757 Err(_elapsed) => {
758 return Err(GuardRunError::new(
759 KernelError::HotPathDeadlineExceeded {
760 stage: HotPathStage::GuardPipeline,
761 budget_ms: budget_ms_saturating(budget),
762 },
763 std::mem::take(&mut evidence),
764 ));
765 }
766 },
767 None => run_one.await,
768 };
769 match outcome {
770 Ok(Ok(mut guard_evidence)) => evidence.append(&mut guard_evidence),
771 Ok(Err(mut guard_error)) => {
772 // Preserve the evidence accumulated from earlier guards ahead
773 // of the failing guard's own evidence.
774 guard_error.evidence.splice(0..0, evidence);
775 return Err(guard_error);
776 }
777 Err(join_err) => {
778 return Err(GuardRunError::new(
779 KernelError::Internal(format!("guard task join failed: {join_err}")),
780 std::mem::take(&mut evidence),
781 ));
782 }
783 }
784 }
785 Ok(evidence)
786 }
787
788 pub(crate) fn run_runtime_admission_hook(
789 &self,
790 request: &ToolCallRequest,
791 extra_metadata: Option<&serde_json::Value>,
792 now: u64,
793 now_unix_ms: u64,
794 matched_grant_index: Option<usize>,
795 ) -> RuntimeAdmissionDecision {
796 let Some(hook) = self.runtime_admission_hook.as_ref() else {
797 let has_runtime_context = request
798 .governed_intent
799 .as_ref()
800 .and_then(|intent| intent.context.as_ref())
801 .is_some_and(|context| {
802 context.get("chioAdmission").is_some()
803 || context.get("chioTreaty").is_some()
804 || context.get("chioSwarm").is_some()
805 });
806 if has_runtime_context {
807 return RuntimeAdmissionDecision::deny(
808 "chio runtime admission hook is required for governed runtime requests",
809 Some(serde_json::json!({
810 "chio_runtime": {
811 "accepted": false,
812 "failure_code": "runtime_admission_hook_missing"
813 }
814 })),
815 );
816 }
817 if request.federated_origin_kernel_id.is_some() {
818 return RuntimeAdmissionDecision::deny(
819 "chio treaty-bound runtime admission context missing",
820 Some(serde_json::json!({
821 "chio_runtime": {
822 "accepted": false,
823 "failure_code": "missing_chio_treaty_context"
824 }
825 })),
826 );
827 }
828 return RuntimeAdmissionDecision::allow(None);
829 };
830 let context = RuntimeAdmissionContext {
831 request,
832 extra_metadata,
833 now_unix_secs: now,
834 now_unix_ms,
835 matched_grant_index,
836 local_kernel_id: self.federation_local_kernel_id(),
837 };
838 match hook.evaluate(&context) {
839 Ok(decision) => decision,
840 Err(error) => RuntimeAdmissionDecision::deny(
841 format!(
842 "runtime admission hook \"{}\" error (fail-closed): {error}",
843 hook.name()
844 ),
845 Some(serde_json::json!({
846 "runtime_admission": {
847 "hook": hook.name(),
848 "accepted": false,
849 "failure_code": "runtime_admission_hook_error"
850 }
851 })),
852 ),
853 }
854 }
855
856 pub(crate) fn release_runtime_admission_reservations(
857 &self,
858 metadata: Option<&serde_json::Value>,
859 ) -> Result<(), KernelError> {
860 let Some(metadata) = metadata else {
861 return Ok(());
862 };
863 let Some(hook) = self.runtime_admission_hook.as_ref() else {
864 return Ok(());
865 };
866 hook.release_reserved(metadata)
867 }
868
869 /// Record, in receipt metadata, that runtime-admission reservations
870 /// consumed at admission were deliberately NOT released because a tool
871 /// side effect may have executed. The reserved ids are copied so an
872 /// operator can locate and re-issue the burned lease/continuation from
873 /// the signed receipt alone. Fail-closed: metadata without a
874 /// `chio_runtime` block, or a `chio_runtime` block that carries no real
875 /// reservation (no present, non-empty `reserved_*` id), is returned
876 /// unchanged. Marking such metadata retained would claim a reservation was
877 /// burned when there was nothing to recover, which misleads operators.
878 pub(crate) fn mark_runtime_admission_reservations_retained_fail_closed(
879 &self,
880 metadata: Option<serde_json::Value>,
881 ) -> Option<serde_json::Value> {
882 let mut retained = serde_json::Map::new();
883 {
884 let Some(runtime) = metadata
885 .as_ref()
886 .and_then(|value| value.get("chio_runtime"))
887 .and_then(serde_json::Value::as_object)
888 else {
889 return metadata;
890 };
891 // Copy across only the ids that name a REAL reservation: a present,
892 // non-empty reserved lease/continuation id. A `chio_runtime` route
893 // block that merely carries the key with no (or an empty) value had
894 // nothing to burn.
895 for (source, target) in [
896 (
897 "reserved_destructive_lease_id",
898 "retained_destructive_lease_id",
899 ),
900 (
901 "reserved_treaty_continuation_id",
902 "retained_treaty_continuation_id",
903 ),
904 (
905 "reserved_swarm_continuation_id",
906 "retained_swarm_continuation_id",
907 ),
908 ] {
909 if let Some(id) = runtime
910 .get(source)
911 .and_then(serde_json::Value::as_str)
912 .filter(|id| !id.is_empty())
913 {
914 retained.insert(target.to_string(), serde_json::json!(id));
915 }
916 }
917 // Only mark retained when at least one real reservation was actually
918 // retained. An observe-only admission or a metadata-only
919 // `chio_runtime` route block has no `reserved_*` id to recover, so
920 // it must not carry the fail-closed marker.
921 if retained.is_empty() {
922 return metadata;
923 }
924 retained.insert(
925 "reservations_retained_fail_closed".to_string(),
926 serde_json::Value::Bool(true),
927 );
928 }
929 merge_metadata_objects(
930 metadata,
931 Some(serde_json::json!({ "chio_runtime": retained })),
932 )
933 }
934
935 pub(crate) fn release_runtime_admission_reservations_for_pre_dispatch_denial(
936 &self,
937 metadata: Option<serde_json::Value>,
938 ) -> (Option<serde_json::Value>, bool) {
939 let Some(metadata_value) = metadata else {
940 return (None, true);
941 };
942 let Some(hook) = self.runtime_admission_hook.as_ref() else {
943 return (Some(metadata_value), true);
944 };
945
946 match hook.release_reserved(&metadata_value) {
947 Ok(()) => (Some(metadata_value), true),
948 Err(error) => {
949 warn!(
950 hook = hook.name(),
951 reason = %redacted!(&error),
952 "runtime admission reservation release failed on pre-dispatch denial"
953 );
954 (
955 merge_metadata_objects(
956 Some(metadata_value),
957 Some(serde_json::json!({
958 "chio_runtime": {
959 "reservation_release_failed": true,
960 "reservation_retained": true
961 }
962 })),
963 ),
964 false,
965 )
966 }
967 }
968 }
969
970 /// Forward the validated request and optionally report actual invocation
971 /// cost, enforcing the configured dispatch budget. This is the phase-level
972 /// dispatch entry point (`ToolEvaluator::dispatch`), so a custom evaluator or
973 /// phase-level caller cannot bypass the deadline and hang indefinitely on a
974 /// wedged tool server; it matches the budget the full evaluate path enforces.
975 #[cfg(test)]
976 pub(crate) async fn dispatch_tool_call_with_cost(
977 &self,
978 request: &ToolCallRequest,
979 has_monetary_grant: bool,
980 ) -> Result<(ToolServerOutput, Option<ToolInvocationCost>), KernelError> {
981 self.validate_required_execution_nonce(request, &request.capability)?;
982 let request_has_monetary_grant = resolve_required_matching_grants(
983 &request.capability,
984 &request.tool_name,
985 &request.server_id,
986 &request.arguments,
987 request.model_metadata.as_ref(),
988 )?
989 .iter()
990 .any(|matching| {
991 matching.grant.max_cost_per_invocation.is_some()
992 || matching.grant.max_total_cost.is_some()
993 });
994 if has_monetary_grant || request_has_monetary_grant {
995 return Err(KernelError::DirectDispatchUnavailable);
996 }
997 self.reserve_presented_execution_nonce(request)?;
998 self.dispatch_within_budget(request, has_monetary_grant)
999 .await
1000 }
1001
1002 /// Bound the tool-server call by the per-server (or default) dispatch
1003 /// budget. On expiry the call fails closed with `HotPathDeadlineExceeded`,
1004 /// which the evaluate core unwinds like a cancellation.
1005 ///
1006 /// Wrapping the call future in `timeout` only bounds it if the connection
1007 /// yields to Tokio; a connection that does synchronous blocking work before
1008 /// its first `.await` would pin the polling worker and the timer would never
1009 /// fire. So on a multi-thread runtime a budgeted call is driven on a
1010 /// `spawn_blocking` thread (like the guard pipeline) and only its join handle
1011 /// is awaited under the deadline, keeping a blocking connection off the async
1012 /// worker pool. With no budget the call runs inline. On a current-thread
1013 /// runtime the call also runs inline under the timeout: there is no spare
1014 /// worker to isolate a blocking poll onto, and driving the call on a second
1015 /// thread would contend for the sole scheduler (so a blocking connection can
1016 /// still pin the only worker there, an inherent single-threaded-runtime
1017 /// limit). With no runtime at all it runs inline without a timeout (there is
1018 /// no async transport to hang on, and the timeout wrapper would panic without
1019 /// a timer driver).
1020 #[cfg(test)]
1021 pub(crate) async fn dispatch_within_budget(
1022 &self,
1023 request: &ToolCallRequest,
1024 has_monetary_grant: bool,
1025 ) -> Result<(ToolServerOutput, Option<ToolInvocationCost>), KernelError> {
1026 let server = self
1027 .tool_servers
1028 .get(&request.server_id)
1029 .cloned()
1030 .ok_or_else(|| {
1031 KernelError::ToolNotRegistered(format!(
1032 "server \"{}\" / tool \"{}\"",
1033 request.server_id, request.tool_name
1034 ))
1035 })?;
1036 self.dispatch_resolved_server_within_budget(server, request, has_monetary_grant)
1037 .await
1038 }
1039
1040 pub(crate) async fn dispatch_resolved_server_within_budget(
1041 &self,
1042 server: Arc<dyn ToolServerConnection>,
1043 request: &ToolCallRequest,
1044 has_monetary_grant: bool,
1045 ) -> Result<(ToolServerOutput, Option<ToolInvocationCost>), KernelError> {
1046 let Some(budget) = self
1047 .config
1048 .deadlines
1049 .dispatch_budget_for(&request.server_id)
1050 else {
1051 return Self::invoke_resolved_server(
1052 server,
1053 request.tool_name.clone(),
1054 request.arguments.clone(),
1055 has_monetary_grant,
1056 )
1057 .await;
1058 };
1059
1060 let timer_available = dispatch_timer_available();
1061 let multi_thread = matches!(
1062 tokio::runtime::Handle::try_current().map(|handle| handle.runtime_flavor()),
1063 Ok(tokio::runtime::RuntimeFlavor::MultiThread)
1064 );
1065 if !multi_thread {
1066 let call = Self::invoke_resolved_server(
1067 server,
1068 request.tool_name.clone(),
1069 request.arguments.clone(),
1070 has_monetary_grant,
1071 );
1072 if timer_available {
1073 return match tokio::time::timeout(budget, call).await {
1074 Ok(result) => result,
1075 Err(_elapsed) => Err(dispatch_deadline_exceeded(budget)),
1076 };
1077 }
1078 return call.await;
1079 }
1080
1081 let tool_name = request.tool_name.clone();
1082 let arguments = request.arguments.clone();
1083 let handle = tokio::runtime::Handle::current();
1084 // Drive the connection call to completion on the blocking pool via
1085 // `Handle::block_on`. `spawn_blocking` threads carry the runtime handle
1086 // without being marked as "inside" it, so `block_on` does not panic there,
1087 // and a blocking first poll stays on the blocking pool rather than the
1088 // async worker.
1089 //
1090 // The inner timeout matters for a *cooperating* connection that never
1091 // completes (it yields but never resolves): `block_on` cannot be
1092 // cancelled by dropping the join handle, so without it that blocking
1093 // thread would be pinned forever. The inner timeout lets `block_on` return
1094 // at the budget, freeing the blocking thread. It cannot fire for a
1095 // connection still stuck in a synchronous blocking poll (the timer cannot
1096 // be polled either); that thread frees when the blocking work finally
1097 // returns, bounded by Tokio's blocking-pool ceiling rather than growing
1098 // without limit. Either way the outer timeout frees the async worker at
1099 // the budget, so the per-eval wall clock holds.
1100 let join = tokio::task::spawn_blocking(move || {
1101 let call =
1102 Self::invoke_resolved_server(server, tool_name, arguments, has_monetary_grant);
1103 if timer_available {
1104 handle.block_on(async move {
1105 match tokio::time::timeout(budget, call).await {
1106 Ok(result) => result,
1107 Err(_elapsed) => Err(dispatch_deadline_exceeded(budget)),
1108 }
1109 })
1110 } else {
1111 handle.block_on(call)
1112 }
1113 });
1114 // Cancel the offloaded call if the outer deadline fires before the
1115 // blocking pool even starts it. Dropping the join handle alone detaches
1116 // the task, so a call still queued on a saturated pool could later run
1117 // the tool after this eval has already returned a timed-out response and
1118 // unwound its charges.
1119 let _abort_on_drop = AbortOnDrop(join.abort_handle());
1120
1121 if timer_available {
1122 match tokio::time::timeout(budget, join).await {
1123 Ok(Ok(result)) => result,
1124 Ok(Err(join_error)) => Err(KernelError::Internal(format!(
1125 "dispatch task join failed: {join_error}"
1126 ))),
1127 Err(_elapsed) => Err(dispatch_deadline_exceeded(budget)),
1128 }
1129 } else {
1130 match join.await {
1131 Ok(result) => result,
1132 Err(join_error) => Err(KernelError::Internal(format!(
1133 "dispatch task join failed: {join_error}"
1134 ))),
1135 }
1136 }
1137 }
1138
1139 /// Drive one already-resolved tool-server invocation to completion. Taken
1140 /// over owned inputs and free of any `&self` borrow so the dispatch deadline
1141 /// path can move it onto a `spawn_blocking` thread (`'static`), isolating a
1142 /// connection that blocks synchronously before its first `.await` from the
1143 /// async worker.
1144 async fn invoke_resolved_server(
1145 server: Arc<dyn ToolServerConnection>,
1146 tool_name: String,
1147 arguments: serde_json::Value,
1148 has_monetary_grant: bool,
1149 ) -> Result<(ToolServerOutput, Option<ToolInvocationCost>), KernelError> {
1150 // Try streaming first regardless of monetary mode.
1151 //
1152 // Why the kernel cannot bound stream memory "as chunks arrive" at THIS
1153 // seam, and where the actual bounds live.
1154 //
1155 // `ToolServerConnection::invoke_stream` returns a FULLY MATERIALIZED
1156 // `ToolServerStreamResult` (which owns a `ToolCallStream { chunks: Vec<..>
1157 // }`). The connector is in-process trusted code that drains its transport
1158 // and builds the entire Vec BEFORE returning; the kernel receives control
1159 // only after materialization. There is no incremental per-chunk arrival at
1160 // this seam, so `push_chunk_bounded` cannot be driven here to bound the
1161 // stream as it accumulates. True accumulation-time bounding would require
1162 // changing the trait contract to a kernel-driven pull model (invoke_stream
1163 // yielding a chunk source the kernel pulls), a public runtime-API change
1164 // affecting every implementor; and even then a malicious in-process
1165 // connector could allocate before yielding. So the transient peak
1166 // allocation of a non-cooperating out-of-tree connector is a genuine
1167 // connector-trust-boundary limit, bounded only by the process RSS ceiling
1168 // (cgroup/ulimit).
1169 //
1170 // Layered bounds that DO apply:
1171 // - Accumulation is bounded by the ACCUMULATOR. In-tree connectors cap
1172 // it (A2A: `parse_sse_stream_with_limit`, MAX_SSE_TOTAL_BYTES = 1 MiB).
1173 // `enforce_stream_byte_limit` / `push_chunk_bounded` (crate::runtime)
1174 // are pub fail-closed Overloaded { StreamBytes / StreamChunks }
1175 // primitives (bounding total bytes AND retained chunk count) so
1176 // out-of-tree connector authors can bound their own invoke_stream.
1177 // - Retained memory is bounded at finalize by `apply_stream_limits` /
1178 // `truncate_stream_to_limits`: the stream is truncated to
1179 // `max_stream_total_bytes` / `max_stream_chunks` and the receipt is
1180 // marked incomplete,
1181 // PRESERVING the charge-for-work-done and financial metadata on
1182 // governed monetary streams (pinned by
1183 // `governed_monetary_incomplete_receipt_keeps_financial_and_governed_metadata`
1184 // and `streamed_tool_byte_limit_truncates_output_and_marks_receipt_incomplete`).
1185 // A hard-deny (Err) here was deliberately reverted because it unwinds
1186 // the monetary charge for an already-executed stream, so this seam
1187 // does not hard-deny.
1188 if let Some(stream) = server
1189 .invoke_stream(&tool_name, arguments.clone(), None)
1190 .await?
1191 {
1192 return Ok((ToolServerOutput::Stream(stream), None));
1193 }
1194
1195 if has_monetary_grant {
1196 let (value, cost) = server.invoke_with_cost(&tool_name, arguments, None).await?;
1197 Ok((ToolServerOutput::Value(value), cost))
1198 } else {
1199 let value = server.invoke(&tool_name, arguments, None).await?;
1200 Ok((ToolServerOutput::Value(value), None))
1201 }
1202 }
1203
1204 /// Persist a single already-signed child receipt: a commit-bounded durable
1205 /// append under the kernel-wide receipt write lock, then the in-process log.
1206 /// Child receipts hold that lock, so an unbounded wait would let a wedged
1207 /// writer pin every subsequent receipt write; the bounded append fails
1208 /// closed on timeout. The in-process log is appended only after the durable
1209 /// append succeeds, so a failed append never records a child receipt that is
1210 /// absent from the durable log.
1211 pub(crate) fn record_child_receipt(
1212 &self,
1213 receipt: &ChildRequestReceipt,
1214 ) -> Result<(), KernelError> {
1215 let receipt_store_write = self
1216 .receipt_store_write_lock
1217 .lock()
1218 .map_err(|_| KernelError::Internal("receipt store write lock poisoned".to_string()))?;
1219 self.with_receipt_store(|store| {
1220 Ok(store.append_child_receipt_with_timeout(
1221 receipt,
1222 self.config.deadlines.receipt_append_budget(),
1223 )?)
1224 })?;
1225 drop(receipt_store_write);
1226 self.append_child_receipt_to_local_log(receipt.clone());
1227 Ok(())
1228 }
1229
1230 pub(crate) fn append_chio_receipt_to_local_log(&self, receipt: ChioReceipt) {
1231 match self.receipt_log.lock() {
1232 Ok(mut log) => log.append(receipt),
1233 Err(poisoned) => poisoned.into_inner().append(receipt),
1234 }
1235 }
1236
1237 fn append_child_receipt_to_local_log(&self, receipt: ChildRequestReceipt) {
1238 match self.child_receipt_log.lock() {
1239 Ok(mut log) => log.append(receipt),
1240 Err(poisoned) => poisoned.into_inner().append(receipt),
1241 }
1242 }
1243}
1244
1245#[cfg(test)]
1246mod timer_probe_tests {
1247 use super::dispatch_timer_available;
1248
1249 // The probe verdict is keyed by runtime id, so each runtime is probed under
1250 // its own key. `re_probes_when_the_entered_runtime_changes_on_one_thread`
1251 // exercises two runtimes on one thread directly; the two single-runtime tests
1252 // below pin the per-runtime verdicts in isolation.
1253
1254 #[test]
1255 fn re_probes_when_the_entered_runtime_changes_on_one_thread(
1256 ) -> Result<(), Box<dyn std::error::Error>> {
1257 // A timerless runtime, then a timer-enabled one, both entered from this
1258 // same OS thread. A per-thread-only cache would reuse the timerless
1259 // verdict and wrongly report no timer in the second runtime; keying on the
1260 // runtime id re-probes when the entered runtime changes.
1261 let timerless = tokio::runtime::Builder::new_current_thread().build()?;
1262 timerless.block_on(async {
1263 assert!(!dispatch_timer_available());
1264 });
1265 let timed = tokio::runtime::Builder::new_current_thread()
1266 .enable_time()
1267 .build()?;
1268 timed.block_on(async {
1269 assert!(dispatch_timer_available());
1270 let elapsed = tokio::time::timeout(
1271 std::time::Duration::from_millis(1),
1272 std::future::pending::<()>(),
1273 )
1274 .await;
1275 assert!(elapsed.is_err(), "the timer must actually fire here");
1276 });
1277 Ok(())
1278 }
1279
1280 #[test]
1281 fn reports_false_in_a_runtime_without_a_time_driver() -> Result<(), Box<dyn std::error::Error>>
1282 {
1283 let runtime = tokio::runtime::Builder::new_current_thread().build()?;
1284 runtime.block_on(async {
1285 assert!(!dispatch_timer_available());
1286 // Mirror the hot-path guard: only wrap work in a timer when the probe
1287 // allows it, so a timerless runtime degrades to inline instead of
1288 // panicking on timer construction.
1289 let ran_inline = if dispatch_timer_available() {
1290 tokio::time::timeout(std::time::Duration::from_millis(1), std::future::ready(()))
1291 .await
1292 .is_ok()
1293 } else {
1294 std::future::ready(()).await;
1295 true
1296 };
1297 assert!(ran_inline);
1298 });
1299 Ok(())
1300 }
1301
1302 #[test]
1303 fn reports_true_in_a_runtime_with_a_time_driver() -> Result<(), Box<dyn std::error::Error>> {
1304 let runtime = tokio::runtime::Builder::new_current_thread()
1305 .enable_time()
1306 .build()?;
1307 runtime.block_on(async {
1308 assert!(dispatch_timer_available());
1309 let elapsed = tokio::time::timeout(
1310 std::time::Duration::from_millis(1),
1311 std::future::pending::<()>(),
1312 )
1313 .await;
1314 assert!(elapsed.is_err(), "the timer must actually fire here");
1315 });
1316 Ok(())
1317 }
1318}