Skip to main content

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                        &currency,
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}