1use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex};
8use std::task::{Context, Poll};
9use std::time::{Duration, Instant};
10
11use tower::limit::{ConcurrencyLimit, ConcurrencyLimitLayer};
12use tower::load_shed::{LoadShed, LoadShedLayer};
13use tower::timeout::TimeoutLayer;
14use tower::Service;
15use tower_layer::Layer;
16
17pub type TenantId = String;
23
24pub const DEFAULT_MAX_TENANT_CONCURRENCY_BUCKETS: usize = 1024;
26
27#[derive(Clone)]
29pub struct KernelService {
30 kernel: Arc<chio_kernel::ChioKernel>,
31}
32
33impl KernelService {
34 pub fn new(kernel: Arc<chio_kernel::ChioKernel>) -> Self {
36 Self { kernel }
37 }
38
39 pub fn kernel(&self) -> &Arc<chio_kernel::ChioKernel> {
41 &self.kernel
42 }
43}
44
45pub struct KernelRequest {
47 pub call: chio_kernel::ToolCallRequest,
49 pub tenant_id: TenantId,
52}
53
54impl KernelRequest {
55 pub fn new(call: chio_kernel::ToolCallRequest, tenant_id: impl Into<TenantId>) -> Self {
57 Self {
58 call,
59 tenant_id: tenant_id.into(),
60 }
61 }
62}
63
64pub type KernelResponse = chio_kernel::ToolCallResponse;
66
67#[derive(Debug, thiserror::Error)]
69pub enum KernelServiceError {
70 #[error("kernel: {0}")]
72 Kernel(#[from] chio_kernel::KernelError),
73 #[error("overloaded")]
75 Overloaded,
76 #[error("tenant bucket table is full")]
80 TenantTableFull,
81 #[error("timeout")]
83 Timeout,
84 #[error("middleware: {0}")]
86 Middleware(String),
87}
88
89impl Service<KernelRequest> for KernelService {
90 type Response = KernelResponse;
91 type Error = KernelServiceError;
92 type Future =
93 Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
94
95 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
96 Poll::Ready(Ok(()))
97 }
98
99 fn call(&mut self, req: KernelRequest) -> Self::Future {
100 let kernel = Arc::clone(&self.kernel);
101
102 Box::pin(async move {
103 let response = kernel
107 .evaluate_tool_call(&req.call)
108 .await
109 .map_err(map_kernel_error)?;
110 Ok(response)
111 })
112 }
113}
114
115fn map_kernel_error(error: chio_kernel::KernelError) -> KernelServiceError {
126 match error {
127 chio_kernel::KernelError::Overloaded { .. } => KernelServiceError::Overloaded,
128 other => KernelServiceError::Kernel(other),
129 }
130}
131
132#[derive(Clone, Debug, Default)]
134pub struct KernelTraceLayer;
135
136impl<S> Layer<S> for KernelTraceLayer {
137 type Service = KernelTraceService<S>;
138
139 fn layer(&self, inner: S) -> Self::Service {
140 KernelTraceService { inner }
141 }
142}
143
144#[derive(Clone, Debug)]
146pub struct KernelTraceService<S> {
147 inner: S,
148}
149
150impl<S> Service<KernelRequest> for KernelTraceService<S>
151where
152 S: Service<KernelRequest, Response = KernelResponse, Error = KernelServiceError>
153 + Send
154 + 'static,
155 S::Future: Send + 'static,
156{
157 type Response = KernelResponse;
158 type Error = KernelServiceError;
159 type Future =
160 Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
161
162 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
163 self.inner.poll_ready(cx)
164 }
165
166 fn call(&mut self, req: KernelRequest) -> Self::Future {
167 let request_id = req.call.request_id.clone();
168 let tenant_id = req.tenant_id.clone();
169 let tool_name = req.call.tool_name.clone();
170 let server_id = req.call.server_id.clone();
171 let started = Instant::now();
172 let future = self.inner.call(req);
173
174 Box::pin(async move {
175 tracing::debug!(
176 request_id = %request_id,
177 tenant_id = %tenant_id,
178 tool_name = %tool_name,
179 server_id = %server_id,
180 "kernel service request started"
181 );
182
183 let result = future.await;
184 match &result {
185 Ok(response) => {
186 tracing::debug!(
187 request_id = %response.request_id,
188 tenant_id = %tenant_id,
189 verdict = ?response.verdict,
190 elapsed_ms = started.elapsed().as_millis(),
191 "kernel service request finished"
192 );
193 }
194 Err(error) => {
195 tracing::warn!(
196 request_id = %request_id,
197 tenant_id = %tenant_id,
198 elapsed_ms = started.elapsed().as_millis(),
199 error = %error,
200 "kernel service request failed"
201 );
202 }
203 }
204 result
205 })
206 }
207}
208
209const DEFAULT_TENANT_IDLE_REAP_SECS: u64 = 3600;
212
213#[derive(Clone, Debug)]
219pub struct TenantConcurrencyLimitLayer {
220 per_tenant_limit: usize,
221 max_tenants: usize,
222 tenant_idle_reap_secs: u64,
223}
224
225impl TenantConcurrencyLimitLayer {
226 pub fn new(per_tenant_limit: usize) -> Self {
228 Self {
229 per_tenant_limit,
230 max_tenants: DEFAULT_MAX_TENANT_CONCURRENCY_BUCKETS,
231 tenant_idle_reap_secs: DEFAULT_TENANT_IDLE_REAP_SECS,
232 }
233 }
234
235 pub fn with_max_tenants(mut self, max_tenants: usize) -> Self {
237 self.max_tenants = max_tenants;
238 self
239 }
240
241 pub fn with_tenant_idle_reap_secs(mut self, secs: u64) -> Self {
244 self.tenant_idle_reap_secs = secs;
245 self
246 }
247}
248
249impl<S> Layer<S> for TenantConcurrencyLimitLayer {
250 type Service = TenantConcurrencyLimitService<S>;
251
252 fn layer(&self, inner: S) -> Self::Service {
253 TenantConcurrencyLimitService {
254 inner,
255 per_tenant_limit: self.per_tenant_limit,
256 max_tenants: self.max_tenants,
257 tenant_idle_reap_secs: self.tenant_idle_reap_secs,
258 tenants: Arc::new(Mutex::new(HashMap::new())),
259 }
260 }
261}
262
263#[derive(Clone, Debug)]
265pub struct TenantConcurrencyLimitService<S> {
266 inner: S,
267 per_tenant_limit: usize,
268 max_tenants: usize,
269 tenant_idle_reap_secs: u64,
270 tenants: Arc<Mutex<HashMap<TenantId, TenantBucketEntry<S>>>>,
271}
272
273type TenantBucketService<S> = LoadShed<ConcurrencyLimit<S>>;
274type TenantBucketEntry<S> = (TenantBucketService<S>, std::time::Instant, Arc<AtomicUsize>);
277
278#[derive(Debug)]
284struct InFlightGuard(Arc<AtomicUsize>);
285
286impl Drop for InFlightGuard {
287 fn drop(&mut self) {
288 self.0.fetch_sub(1, Ordering::SeqCst);
289 }
290}
291
292impl<S> TenantConcurrencyLimitService<S>
293where
294 S: Clone,
295{
296 fn service_for_tenant(
297 &self,
298 tenant_id: &TenantId,
299 ) -> Result<(TenantBucketService<S>, InFlightGuard), KernelServiceError> {
300 let mut tenants = self.tenants.lock().map_err(|_| {
301 KernelServiceError::Middleware("tenant concurrency limit state poisoned".to_string())
302 })?;
303
304 if let Some((service, last_use, in_flight)) = tenants.get_mut(tenant_id) {
305 *last_use = std::time::Instant::now();
306 in_flight.fetch_add(1, Ordering::SeqCst);
307 return Ok((service.clone(), InFlightGuard(Arc::clone(in_flight))));
308 }
309
310 if tenants.len() >= self.max_tenants {
311 let idle = std::time::Duration::from_secs(self.tenant_idle_reap_secs);
316 let victim = tenants
317 .iter()
318 .filter(|(_, (_, last, in_flight))| {
319 last.elapsed() >= idle && in_flight.load(Ordering::SeqCst) == 0
320 })
321 .max_by_key(|(_, (_, last, _))| last.elapsed())
322 .map(|(k, _)| k.clone());
323 match victim {
324 Some(v) => {
325 tenants.remove(&v);
326 }
327 None => return Err(KernelServiceError::TenantTableFull),
328 }
329 }
330
331 let service = ConcurrencyLimitLayer::new(self.per_tenant_limit).layer(self.inner.clone());
332 let service = LoadShedLayer::new().layer(service);
333 let in_flight = Arc::new(AtomicUsize::new(1));
334 let guard = InFlightGuard(Arc::clone(&in_flight));
335 tenants.insert(
336 tenant_id.clone(),
337 (service.clone(), std::time::Instant::now(), in_flight),
338 );
339 Ok((service, guard))
340 }
341}
342
343impl<S> Service<KernelRequest> for TenantConcurrencyLimitService<S>
344where
345 S: Service<KernelRequest, Error = KernelServiceError> + Clone + Send + 'static,
346 S::Future: Send + 'static,
347 S::Response: Send + 'static,
348{
349 type Response = S::Response;
350 type Error = KernelServiceError;
351 type Future =
352 Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
353
354 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
355 Poll::Ready(Ok(()))
356 }
357
358 fn call(&mut self, req: KernelRequest) -> Self::Future {
359 let acquired = self.service_for_tenant(&req.tenant_id);
360
361 Box::pin(async move {
362 let (mut service, _in_flight) = acquired?;
365 poll_ready_once(&mut service)?;
366 service.call(req).await.map_err(normalize_tower_error)
367 })
368 }
369}
370
371fn poll_ready_once<S>(service: &mut TenantBucketService<S>) -> Result<(), KernelServiceError>
372where
373 TenantBucketService<S>: Service<KernelRequest, Error = tower::BoxError>,
374{
375 let waker = std::task::Waker::noop();
376 let mut cx = Context::from_waker(waker);
377 match service.poll_ready(&mut cx) {
378 Poll::Ready(Ok(())) => Ok(()),
379 Poll::Ready(Err(error)) => Err(normalize_tower_error(error)),
380 Poll::Pending => Err(KernelServiceError::Overloaded),
381 }
382}
383
384#[derive(Clone, Debug, Default)]
385struct KernelTimeoutErrorLayer;
386
387impl<S> Layer<S> for KernelTimeoutErrorLayer {
388 type Service = KernelTimeoutErrorService<S>;
389
390 fn layer(&self, inner: S) -> Self::Service {
391 KernelTimeoutErrorService { inner }
392 }
393}
394
395#[derive(Clone, Debug)]
396struct KernelTimeoutErrorService<S> {
397 inner: S,
398}
399
400impl<S, Request> Service<Request> for KernelTimeoutErrorService<S>
401where
402 S: Service<Request, Error = tower::BoxError>,
403 S::Future: Send + 'static,
404 Request: Send + 'static,
405{
406 type Response = S::Response;
407 type Error = KernelServiceError;
408 type Future =
409 Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
410
411 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
412 self.inner.poll_ready(cx).map_err(normalize_tower_error)
413 }
414
415 fn call(&mut self, request: Request) -> Self::Future {
416 let future = self.inner.call(request);
417 Box::pin(async move { future.await.map_err(normalize_tower_error) })
418 }
419}
420
421fn normalize_tower_error(error: tower::BoxError) -> KernelServiceError {
422 let error = match error.downcast::<KernelServiceError>() {
423 Ok(kernel_error) => return *kernel_error,
424 Err(error) => error,
425 };
426
427 if error.is::<tower::timeout::error::Elapsed>() {
428 return KernelServiceError::Timeout;
429 }
430
431 if error.is::<tower::load_shed::error::Overloaded>() {
432 return KernelServiceError::Overloaded;
433 }
434
435 KernelServiceError::Middleware(error.to_string())
436}
437
438pub fn build_layered(
443 kernel: Arc<chio_kernel::ChioKernel>,
444 per_tenant_limit: usize,
445 request_timeout: Duration,
446) -> impl Service<KernelRequest, Response = KernelResponse, Error = KernelServiceError> + Clone {
447 let service = KernelService::new(kernel);
448 let service = TenantConcurrencyLimitLayer::new(per_tenant_limit).layer(service);
449 let service = TimeoutLayer::new(request_timeout).layer(service);
450 let service = KernelTimeoutErrorLayer.layer(service);
451 KernelTraceLayer.layer(service)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 use chio_core_types::capability::scope::{ChioScope, Operation, ToolGrant};
459 use chio_core_types::crypto::Keypair;
460 use chio_kernel::{
461 ChioKernel, KernelConfig, KernelError, NestedFlowBridge, ToolCallOutput, ToolCallRequest,
462 ToolServerConnection, ToolServerStreamResult, DEFAULT_CHECKPOINT_BATCH_SIZE,
463 DEFAULT_MAX_STREAM_DURATION_SECS, DEFAULT_MAX_STREAM_TOTAL_BYTES,
464 };
465 use tower::ServiceExt;
466
467 struct EchoServer;
468
469 #[async_trait::async_trait]
470 impl ToolServerConnection for EchoServer {
471 fn server_id(&self) -> &str {
472 "srv-a"
473 }
474
475 fn tool_names(&self) -> Vec<String> {
476 vec!["echo".to_string()]
477 }
478
479 async fn invoke(
480 &self,
481 tool_name: &str,
482 arguments: serde_json::Value,
483 _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
484 ) -> Result<serde_json::Value, KernelError> {
485 Ok(serde_json::json!({
486 "tool": tool_name,
487 "arguments": arguments,
488 }))
489 }
490
491 async fn invoke_stream(
492 &self,
493 _tool_name: &str,
494 _arguments: serde_json::Value,
495 _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
496 ) -> Result<Option<ToolServerStreamResult>, KernelError> {
497 Ok(None)
498 }
499 }
500
501 #[test]
502 fn table_full_is_distinct_from_per_tenant_overload() {
503 #[derive(Clone, Debug)]
507 struct MockInner;
508
509 let service = TenantConcurrencyLimitLayer::new(1)
510 .with_max_tenants(2)
511 .layer(MockInner);
512 assert!(service.service_for_tenant(&"tenant-a".to_string()).is_ok());
513 assert!(service.service_for_tenant(&"tenant-b".to_string()).is_ok());
514 match service.service_for_tenant(&"tenant-c".to_string()) {
515 Err(KernelServiceError::TenantTableFull) => {}
516 other => panic!("expected TenantTableFull, got {other:?}"),
517 }
518 }
519
520 #[test]
521 fn idle_tenant_is_reaped_to_admit_a_new_tenant() {
522 #[derive(Clone, Debug)]
525 struct MockInner;
526
527 let service = TenantConcurrencyLimitLayer::new(1)
528 .with_max_tenants(2)
529 .with_tenant_idle_reap_secs(0)
530 .layer(MockInner);
531 assert!(service.service_for_tenant(&"tenant-a".to_string()).is_ok());
532 assert!(service.service_for_tenant(&"tenant-b".to_string()).is_ok());
533 assert!(
534 service.service_for_tenant(&"tenant-c".to_string()).is_ok(),
535 "an idle tenant should be reaped to admit a new one"
536 );
537 }
538
539 #[test]
540 fn in_flight_tenant_is_not_reaped_even_when_idle_timed() {
541 #[derive(Clone, Debug)]
544 struct MockInner;
545
546 let service = TenantConcurrencyLimitLayer::new(1)
547 .with_max_tenants(1)
548 .with_tenant_idle_reap_secs(0)
549 .layer(MockInner);
550
551 let held = match service.service_for_tenant(&"tenant-a".to_string()) {
553 Ok(acquired) => acquired,
554 Err(error) => panic!("tenant-a should be admitted: {error:?}"),
555 };
556
557 match service.service_for_tenant(&"tenant-b".to_string()) {
561 Err(KernelServiceError::TenantTableFull) => {}
562 other => {
563 panic!("expected TenantTableFull (active tenant not reaped), got {other:?}")
564 }
565 }
566
567 drop(held);
569 assert!(
570 service.service_for_tenant(&"tenant-b".to_string()).is_ok(),
571 "an idle tenant with no in-flight call should be reaped"
572 );
573 }
574
575 fn make_config() -> KernelConfig {
576 KernelConfig {
577 keypair: Keypair::generate(),
578 ca_public_keys: vec![],
579 max_delegation_depth: 5,
580 policy_hash: "test-policy-hash".to_string(),
581 allow_sampling: false,
582 allow_sampling_tool_use: false,
583 allow_elicitation: false,
584 max_stream_duration_secs: DEFAULT_MAX_STREAM_DURATION_SECS,
585 max_stream_total_bytes: DEFAULT_MAX_STREAM_TOTAL_BYTES,
586 require_web3_evidence: false,
587 allow_ephemeral_receipt_log: true,
588 allow_ephemeral_revocation_store: true,
589 checkpoint_batch_size: DEFAULT_CHECKPOINT_BATCH_SIZE,
590 retention_config: None,
591 memory_budget: chio_kernel::MemoryBudgetConfig::defaults(),
592 deadlines: chio_kernel::HotPathDeadlineConfig::default(),
593 }
594 }
595
596 fn make_grant() -> ToolGrant {
597 ToolGrant {
598 server_id: "srv-a".to_string(),
599 tool_name: "echo".to_string(),
600 operations: vec![Operation::Invoke],
601 constraints: vec![],
602 max_invocations: None,
603 max_cost_per_invocation: None,
604 max_total_cost: None,
605 dpop_required: None,
606 }
607 }
608
609 fn make_scope() -> ChioScope {
610 ChioScope {
611 grants: vec![make_grant()],
612 ..ChioScope::default()
613 }
614 }
615
616 fn make_kernel_request(kernel: &ChioKernel) -> KernelRequest {
617 let agent_keypair = Keypair::generate();
618 let capability = kernel
619 .issue_capability(&agent_keypair.public_key(), make_scope(), 60)
620 .unwrap_or_else(|error| panic!("issue capability failed: {error}"));
621 let call = ToolCallRequest {
622 request_id: "req-kernel-service".to_string(),
623 capability,
624 tool_name: "echo".to_string(),
625 server_id: "srv-a".to_string(),
626 agent_id: agent_keypair.public_key().to_hex(),
627 arguments: serde_json::json!({ "message": "hello" }),
628 dpop_proof: None,
629 execution_nonce: None,
630 governed_intent: None,
631 approval_token: None,
632 approval_tokens: Vec::new(),
633 threshold_approval_proposal: None,
634 supplemental_authorization: None,
635 model_metadata: None,
636 federated_origin_kernel_id: None,
637 };
638 KernelRequest::new(call, "tenant-a")
639 }
640
641 #[tokio::test]
642 async fn kernel_service_dispatches_through_kernel() {
643 let mut kernel = ChioKernel::new(make_config());
644 kernel.register_tool_server(Box::new(EchoServer));
645 let request = make_kernel_request(&kernel);
646 let mut service = build_layered(Arc::new(kernel), 16, Duration::from_secs(5));
647
648 let response = service
649 .ready()
650 .await
651 .unwrap_or_else(|error| panic!("service ready failed: {error}"))
652 .call(request)
653 .await
654 .unwrap_or_else(|error| panic!("service call failed: {error}"));
655
656 assert_eq!(response.verdict, chio_kernel::Verdict::Allow);
657 match response.output {
658 Some(ToolCallOutput::Value(value)) => {
659 assert_eq!(value["tool"], "echo");
660 assert_eq!(value["arguments"]["message"], "hello");
661 }
662 other => panic!("expected value output, got {other:?}"),
663 }
664 assert_eq!(response.receipt.body().tool_name, "echo");
665 }
666
667 #[tokio::test]
668 async fn timeout_layer_maps_elapsed_error() {
669 let inner = tower::service_fn(|_request: KernelRequest| async {
670 tokio::time::sleep(Duration::from_millis(50)).await;
671 Ok::<(), KernelServiceError>(())
672 });
673 let timeout = TimeoutLayer::new(Duration::from_millis(1)).layer(inner);
674 let mut service = KernelTimeoutErrorLayer.layer(timeout);
675 let kernel = ChioKernel::new(make_config());
676 let request = make_kernel_request(&kernel);
677
678 let result = service
679 .ready()
680 .await
681 .unwrap_or_else(|error| panic!("service ready failed: {error}"))
682 .call(request)
683 .await;
684 let Err(error) = result else {
685 panic!("timeout should fail");
686 };
687
688 assert!(matches!(error, KernelServiceError::Timeout));
689 }
690
691 #[test]
692 fn rss_shed_kernel_overload_maps_to_service_overloaded() {
693 let mapped = map_kernel_error(KernelError::Overloaded {
698 resource: chio_kernel::OverloadResource::Allocation,
699 });
700 assert!(
701 matches!(mapped, KernelServiceError::Overloaded),
702 "an RSS shed must map to the retryable Overloaded variant, got {mapped:?}"
703 );
704
705 let via_from: KernelServiceError = KernelError::Overloaded {
708 resource: chio_kernel::OverloadResource::Allocation,
709 }
710 .into();
711 assert!(
712 matches!(via_from, KernelServiceError::Kernel(_)),
713 "the blanket From maps Overloaded to Kernel; call() must use map_kernel_error"
714 );
715
716 let other = map_kernel_error(KernelError::Internal("boom".to_string()));
718 assert!(matches!(other, KernelServiceError::Kernel(_)));
719 }
720
721 struct ParkingServer {
722 invoked: Arc<std::sync::atomic::AtomicBool>,
723 }
724
725 #[async_trait::async_trait]
726 impl ToolServerConnection for ParkingServer {
727 fn server_id(&self) -> &str {
728 "srv-a"
729 }
730
731 fn tool_names(&self) -> Vec<String> {
732 vec!["echo".to_string()]
733 }
734
735 async fn invoke(
736 &self,
737 _tool_name: &str,
738 _arguments: serde_json::Value,
739 _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
740 ) -> Result<serde_json::Value, KernelError> {
741 self.invoked
742 .store(true, std::sync::atomic::Ordering::SeqCst);
743 std::future::pending::<Result<serde_json::Value, KernelError>>().await
744 }
745
746 async fn invoke_stream(
747 &self,
748 _tool_name: &str,
749 _arguments: serde_json::Value,
750 _nested_flow_bridge: Option<&mut dyn NestedFlowBridge>,
751 ) -> Result<Option<ToolServerStreamResult>, KernelError> {
752 Ok(None)
753 }
754 }
755
756 #[tokio::test(start_paused = true)]
757 async fn build_layered_timeout_drop_records_cancellation_receipt() {
758 let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
759 let mut kernel = ChioKernel::new(make_config());
760 kernel.register_tool_server(Box::new(ParkingServer {
761 invoked: Arc::clone(&invoked),
762 }));
763 let request = make_kernel_request(&kernel);
764 let kernel = Arc::new(kernel);
765 let mut service = build_layered(Arc::clone(&kernel), 16, Duration::from_millis(1));
766
767 let result = service
768 .ready()
769 .await
770 .unwrap_or_else(|error| panic!("service ready failed: {error}"))
771 .call(request)
772 .await;
773 let Err(error) = result else {
774 panic!("a parked dispatch must time out");
775 };
776 assert!(matches!(error, KernelServiceError::Timeout));
777 assert!(
778 invoked.load(std::sync::atomic::Ordering::SeqCst),
779 "tool dispatch must have been entered before the timeout elapsed"
780 );
781
782 let receipt_log = kernel.receipt_log();
783 assert_eq!(
784 receipt_log.len(),
785 1,
786 "build_layered timeout must record exactly one cancellation receipt"
787 );
788 let Some(receipt) = receipt_log.get(0) else {
789 panic!("cancellation receipt missing from the kernel receipt log");
790 };
791 assert!(receipt.is_cancelled());
792 }
793}