Skip to main content

relon_codegen_llvm/
sandbox.rs

1//! Sandbox primitives for the LLVM-native AOT (`emit_object`) backend.
2//! **Phase C.**
3//!
4//! The cranelift-native backend (`relon-codegen-cranelift::sandbox`) is
5//! the gold standard: it enforces the four hard sandbox guarantees the
6//! wasm-AOT backend ships, expressed in cranelift IR. This module ports
7//! the host-facing half of that surface to the LLVM backend so the
8//! linked-after **native** binary produced by [`crate::LlvmAotEvaluator::emit_object`]
9//! carries the same capability-gate + trap semantics.
10//!
11//! ## How the LLVM gate differs from the cranelift gate
12//!
13//! The two backends reach the *same* policy outcome
14//! (`RuntimeError::CapabilityDenied` on a denied bit) through different
15//! machinery:
16//!
17//! * **cranelift** parks an `extern "C"` host-fn pointer in a heap
18//!   `CapabilityVtable`, takes its base address as a constant, and emits
19//!   a per-call `cap_lookup` + null-check; a null slot traps.
20//! * **LLVM** carries the host-granted capability set as the
21//!   buffer-protocol entry's trailing `i64 caps` param (IR `LocalGet(4)`).
22//!   `Op::CheckCap { cap_bit }` (lowered in `codegen/call.rs`) bakes a
23//!   `(caps & (1 << cap_bit)) != 0` test into the emitted object; a clear
24//!   bit records [`SandboxTrapKind::CapabilityDenied`] in
25//!   `ArenaState::trap_code` and returns the negative sentinel so the host
26//!   lifts a typed `RuntimeError` (rather than an `llvm.trap` `ud2` /
27//!   SIGILL the host cannot catch on stable Rust).
28//!
29//! Because the gate is a *bitmask* on the LLVM side, the LLVM
30//! [`CapabilityVtable`] is a thin builder around that `i64` mask: it
31//! consults the same [`relon_eval_api::CapabilityGate`] policy the
32//! cranelift backend and the tree-walker consult ([`Self::register_via_gate`])
33//! and folds each granted bit into the mask the linked binary receives
34//! as `caps`. The grant decision and the bit index are identical across
35//! all three backends — only the runtime carrier differs.
36//!
37//! ## What lives where
38//!
39//! * [`SandboxConfig`] — compile-time knobs (mirror of cranelift's).
40//! * [`SandboxTrapKind`] — the trap-cause enum, numbered to match
41//!   cranelift's `TrapKind` and the [`crate::state::NativeTrap`] subset
42//!   already recorded by the JIT-side dynamic dispatch helper.
43//! * [`CapabilityVtable`] — the grant surface, expressed as an `i64`
44//!   `caps` bitmask + the `import_idx`-keyed dynamic host-fn registry
45//!   (which is just a re-export of [`crate::state::HostFnRegistry`], the
46//!   existing LLVM equivalent of cranelift's `host_fns` half).
47//!
48//! `state.rs` (`ArenaState` / `HostFnRegistry` / `NativeTrap` /
49//! `relon_llvm_call_native`) is consumed read-only by this module — it is
50//! the codegen-visible runtime contract and must not change.
51
52use relon_eval_api::{CapabilityBit, CapabilityGate, RelonFunction, RuntimeError};
53use relon_parser::TokenRange;
54use std::sync::Arc;
55
56use crate::state::HostFnRegistry;
57
58/// Compile-time sandbox configuration. Mirrors the cranelift backend's
59/// `SandboxConfig` field-for-field so a side-by-side comparison of the
60/// two AOT backends shares the same knob surface.
61///
62/// Production LLVM buffer entries emit the guard surface unconditionally:
63/// arena bounds checks, div/mod guards, checked signed `Int` arithmetic,
64/// capability gates, dynamic host-call trap lifting, and deterministic
65/// step-budget fuel. This struct stays field-compatible with cranelift's
66/// configuration so tests and host code can describe the same policy
67/// intent across backends. The booleans are bench/debug intent records
68/// for LLVM today; they should not be used to create a trusted execution
69/// posture for untrusted source.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SandboxConfig {
72    /// When `true`, host-visible memory access should be guarded
73    /// against the arena byte length. LLVM buffer entries currently emit
74    /// these guards unconditionally.
75    pub bounds_check: bool,
76    /// When `true`, resource exhaustion should be enforced. LLVM uses
77    /// deterministic step-budget fuel configured on `LlvmAotEvaluator`
78    /// rather than reading this bool directly as a wall-clock deadline
79    /// switch.
80    pub deadline_check: bool,
81    /// When `true`, `Op::CheckCap` bakes the `caps`-bitmask test into
82    /// the emitted object. The `codegen/call.rs` lowering already emits
83    /// it unconditionally for a non-`NO_CAPABILITY_BIT` bit; this flag
84    /// is the host-facing intent record.
85    pub capability_check: bool,
86    /// When `true`, `Op::Div` / `Op::Mod` emit an explicit divisor-zero
87    /// guard before LLVM's `sdiv` / `srem` (whose div-by-zero is UB).
88    /// The `codegen/arith.rs` lowering already emits it; this flag is
89    /// the host-facing intent record.
90    pub div_check: bool,
91}
92
93impl Default for SandboxConfig {
94    fn default() -> Self {
95        Self {
96            bounds_check: true,
97            deadline_check: true,
98            capability_check: true,
99            div_check: true,
100        }
101    }
102}
103
104impl SandboxConfig {
105    /// Disable all four guards. Bench-only — production code paths
106    /// should never call this.
107    pub fn unchecked() -> Self {
108        Self {
109            bounds_check: false,
110            deadline_check: false,
111            capability_check: false,
112            div_check: false,
113        }
114    }
115}
116
117/// Trap kind raised by a guard inside LLVM-emitted native code. The
118/// numeric values match the cranelift backend's `TrapKind` and the
119/// [`crate::state::NativeTrap`] subset the JIT-side dynamic dispatch
120/// helper already records, so the host decodes the same cause numbering
121/// across backends. Encoded as `u64` so it fits the `ArenaState::trap_code`
122/// slot the emitted object writes through `relon_llvm_call_native` /
123/// the `Op::CheckCap` trap arm.
124///
125/// Only the subset the LLVM native path can currently raise
126/// (`DivisionByZero` via the `sdiv`/`srem` guard, `BoundsViolation`
127/// via arena guards, `CapabilityDenied` via `Op::CheckCap`,
128/// `NumericOverflow` via checked Int arithmetic / reductions, and
129/// `HostFnMissing`/`HostFnError` via dynamic dispatch) is reachable
130/// today; the remaining variants are kept so the numbering stays a
131/// faithful mirror of cranelift's for the deadline work that lands
132/// with the wider emitter.
133#[repr(u64)]
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SandboxTrapKind {
136    /// Division (`Op::Div` / `Op::Mod`) by zero. Buffer entries record
137    /// this code in `ArenaState::trap_code`; legacy/fast entries have no
138    /// typed error lane and still use `llvm.trap`.
139    DivisionByZero = 1,
140    /// Pointer dereference walked past the arena bounds.
141    BoundsViolation = 2,
142    /// An `Op::CheckCap { cap_bit }` found the matching bit clear in the
143    /// host-granted `caps` mask. Lifts to `RuntimeError::CapabilityDenied`.
144    /// Matches cranelift's `TrapKind::CapabilityDenied` and
145    /// [`crate::state::NativeTrap::CapabilityDenied`] (= 3).
146    CapabilityDenied = 3,
147    /// Per-call resource budget exhausted. LLVM currently raises this
148    /// through deterministic step-budget fuel; a future wall-clock
149    /// deadline can reuse the same trap code.
150    ResourceExhausted = 4,
151    /// No host fn registered at the requested `import_idx`, or no
152    /// registry installed. Matches cranelift's `TrapKind::Unreachable`
153    /// (= 5) and [`crate::state::NativeTrap::HostFnMissing`]; lifts to
154    /// `RuntimeError::Unsupported`.
155    HostFnMissing = 5,
156    /// Signed integer overflow. Matches cranelift's
157    /// `TrapKind::NumericOverflow` (= 6) and
158    /// [`crate::state::NativeTrap::NumericOverflow`]. Raised by checked
159    /// `Op::Add` / `Op::Sub` / `Op::Mul`, the `INT_MIN / -1` div/rem
160    /// guard, and bundled checked reductions such as `list_int_sum`.
161    NumericOverflow = 6,
162    /// A host fn returned an error, or a value outside the scalar return
163    /// envelope. Matches [`crate::state::NativeTrap::HostFnError`] (= 7);
164    /// lifts to `RuntimeError::Unsupported`.
165    HostFnError = 7,
166}
167
168impl SandboxTrapKind {
169    /// Decode a `u64` recorded in `ArenaState::trap_code` back into a
170    /// [`SandboxTrapKind`]. Unknown / `0` codes route to
171    /// [`SandboxTrapKind::HostFnError`] so the host always gets a typed
172    /// `RuntimeError` rather than a panic — matching cranelift's
173    /// catch-all-into-typed-error posture.
174    pub fn from_code(code: u64) -> SandboxTrapKind {
175        match code {
176            1 => SandboxTrapKind::DivisionByZero,
177            2 => SandboxTrapKind::BoundsViolation,
178            3 => SandboxTrapKind::CapabilityDenied,
179            4 => SandboxTrapKind::ResourceExhausted,
180            5 => SandboxTrapKind::HostFnMissing,
181            6 => SandboxTrapKind::NumericOverflow,
182            _ => SandboxTrapKind::HostFnError,
183        }
184    }
185
186    /// Lift a trap kind into the appropriate [`RuntimeError`] variant.
187    /// All trap mappings carry the entry function's source range so the
188    /// diagnostic at least points at the `#main` declaration. Mirrors
189    /// cranelift's `TrapKind::to_runtime_error` and the
190    /// [`crate::state::NativeTrap::runtime_error_from_code`] subset.
191    pub fn to_runtime_error(self, range: TokenRange) -> RuntimeError {
192        match self {
193            SandboxTrapKind::DivisionByZero => RuntimeError::DivisionByZero(range),
194            SandboxTrapKind::BoundsViolation => RuntimeError::IndexOutOfBounds { range },
195            SandboxTrapKind::CapabilityDenied => RuntimeError::CapabilityDenied {
196                // The trap path carries no bit (the cleared mask bit is
197                // the only signal), so the host gets a generic reason —
198                // same posture as cranelift's null-slot trap.
199                cap_bit: None,
200                reason: "llvm-native: host-fn call denied by capability gate".to_string(),
201                range,
202            },
203            SandboxTrapKind::ResourceExhausted => {
204                RuntimeError::StepLimitExceeded { limit: None, range }
205            }
206            SandboxTrapKind::NumericOverflow => RuntimeError::NumericOverflow(range),
207            SandboxTrapKind::HostFnMissing | SandboxTrapKind::HostFnError => {
208                RuntimeError::Unsupported {
209                    reason: "llvm-native: native-fn dispatch failed (host fn missing / errored / \
210                             returned a non-scalar value)"
211                        .to_string(),
212                }
213            }
214        }
215    }
216}
217
218/// Highest `cap_bit` the `i64 caps` bitmask can represent. Mirrors the
219/// `cap_bit >= 64` guard `codegen/call.rs::emit_check_cap` enforces.
220pub const MAX_CAP_BIT: u32 = 64;
221
222/// The LLVM backend's capability grant surface.
223///
224/// On the cranelift side the equivalent `CapabilityVtable` is a heap
225/// array of `extern "C"` host-fn pointers whose *non-null-ness* at
226/// `slots[cap_bit]` is what lets an `Op::CheckCap { cap_bit }` pass. On
227/// the LLVM side the granted set is carried as an `i64` bitmask the
228/// buffer-protocol entry receives as its trailing `caps` param, so this
229/// type is a thin builder around that mask plus the dynamic host-fn
230/// registry the `import_idx`-keyed `Op::CallNative` dispatch resolves
231/// against.
232///
233/// ## Two halves (same split as cranelift)
234///
235/// * `caps_mask` — the granted-capability bitmask. A set bit at index
236///   `cap_bit` is what lets an `Op::CheckCap { cap_bit }` pass (the LLVM
237///   analogue of cranelift's "non-null slot at `cap_bit`"). Built via
238///   [`Self::grant`] / [`Self::register_via_gate`]; consumed by the host
239///   as the `caps` word it hands to the linked entry (or to
240///   `LlvmAotEvaluator::with_caps`).
241/// * `host_fns` — the `import_idx`-keyed dynamic callable registry
242///   ([`HostFnRegistry`]). A source-lowered
243///   `Op::CallNative { cap_bit: NO_CAPABILITY_BIT }` resolves through it
244///   via `relon_llvm_call_native`. Keyed off `import_idx` (a private
245///   namespace) so it never collides with the `cap_bit`-indexed mask —
246///   exactly cranelift's `host_fns` split.
247#[derive(Default, Clone)]
248pub struct CapabilityVtable {
249    caps_mask: i64,
250    host_fns: HostFnRegistry,
251}
252
253impl std::fmt::Debug for CapabilityVtable {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("CapabilityVtable")
256            .field("caps_mask", &format_args!("{:#018b}", self.caps_mask))
257            .field("host_fn_count", &self.host_fns.len())
258            .finish()
259    }
260}
261
262impl CapabilityVtable {
263    /// Build an empty vtable: no capabilities granted, no host fns
264    /// registered. The `n` argument is accepted for source-shape parity
265    /// with cranelift's `with_capacity(n)`; the LLVM mask is fixed at 64
266    /// bits so the value is only used to assert the caller does not ask
267    /// for more bits than the `i64` mask can hold.
268    pub fn with_capacity(_n: usize) -> Self {
269        Self {
270            caps_mask: 0,
271            host_fns: HostFnRegistry::new(),
272        }
273    }
274
275    /// Grant a capability bit by setting it in the `caps` mask. An
276    /// `Op::CheckCap { cap_bit }` only tests the bit, so a set bit is
277    /// enough to let the guard pass; the actual call dispatches through
278    /// the `import_idx`-keyed `host_fns` registry. Mirrors cranelift's
279    /// `CapabilityVtable::grant` (which parks a non-null sentinel).
280    ///
281    /// Bits `>= 64` are silently ignored (the `i64` mask cannot carry
282    /// them); the matching `Op::CheckCap` lowering rejects an out-of-
283    /// range bit at compile time, so a too-large grant can never satisfy
284    /// a gate either way.
285    pub fn grant(&mut self, cap_bit: u32) {
286        if cap_bit < MAX_CAP_BIT {
287            self.caps_mask |= 1i64 << cap_bit;
288        }
289    }
290
291    /// Capability-gated grant. Consults `gate` for `cap_bit` via the
292    /// shared [`relon_eval_api::CapabilityGate`] trait; if the gate
293    /// denies the bit, the mask bit stays clear so the IR-level
294    /// `Op::CheckCap` traps with [`SandboxTrapKind::CapabilityDenied`].
295    /// This is the LLVM backend's half of the unified-enforcement
296    /// design: the same policy the tree-walker consults at dispatch time
297    /// and the cranelift backend consults at vtable-build time is
298    /// consulted here when folding the bit into the `caps` mask, so
299    /// denying a bit on the host side produces the same outcome class
300    /// (`RuntimeError::CapabilityDenied`) on all three backends.
301    ///
302    /// Returns `true` if the bit was granted; `false` if the gate denied
303    /// it (mask bit left clear).
304    pub fn register_via_gate<G: CapabilityGate>(
305        &mut self,
306        gate: &G,
307        cap_bit: CapabilityBit,
308    ) -> bool {
309        match gate.check(cap_bit) {
310            Ok(()) => {
311                self.grant(cap_bit.bit_index());
312                true
313            }
314            Err(_) => false,
315        }
316    }
317
318    /// `true` when `cap_bit` is granted in the mask. The LLVM analogue
319    /// of cranelift's `lookup(cap_bit).is_some()`.
320    pub fn is_granted(&self, cap_bit: u32) -> bool {
321        cap_bit < MAX_CAP_BIT && (self.caps_mask & (1i64 << cap_bit)) != 0
322    }
323
324    /// The granted-capability bitmask, ready to hand to the linked
325    /// entry as its trailing `caps` param (or to
326    /// `LlvmAotEvaluator::with_caps`). This is the runtime carrier the
327    /// `Op::CheckCap` gate baked into the emitted object reads.
328    pub fn caps_mask(&self) -> i64 {
329        self.caps_mask
330    }
331
332    /// Register a dynamic `Arc<dyn RelonFunction>` host fn at the given
333    /// `import_idx`. Mirrors cranelift's
334    /// `CapabilityVtable::register_host_fn`; delegates to the existing
335    /// [`HostFnRegistry`] so the JIT-side `relon_llvm_call_native`
336    /// dispatch resolves against the same map.
337    pub fn register_host_fn(&mut self, import_idx: u32, func: Arc<dyn RelonFunction>) {
338        self.host_fns.register(import_idx, func);
339    }
340
341    /// Resolve the dynamic host fn registered at `import_idx`. Mirrors
342    /// cranelift's `CapabilityVtable::resolve_host_fn`.
343    pub fn resolve_host_fn(&self, import_idx: u32) -> Option<&Arc<dyn RelonFunction>> {
344        self.host_fns.resolve(import_idx)
345    }
346
347    /// Borrow the underlying [`HostFnRegistry`] so the evaluator can
348    /// install it on a per-call [`crate::state::ArenaState`] via
349    /// `ArenaState::install_host_fns`.
350    pub fn host_fns(&self) -> &HostFnRegistry {
351        &self.host_fns
352    }
353
354    /// Number of registered dynamic host fns.
355    pub fn host_fn_count(&self) -> usize {
356        self.host_fns.len()
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use relon_eval_api::{Capabilities, NativeArgs, Value};
364
365    #[test]
366    fn config_default_enables_all_guards() {
367        let cfg = SandboxConfig::default();
368        assert!(cfg.bounds_check);
369        assert!(cfg.deadline_check);
370        assert!(cfg.capability_check);
371        assert!(cfg.div_check);
372    }
373
374    #[test]
375    fn config_unchecked_disables_all_guards() {
376        let cfg = SandboxConfig::unchecked();
377        assert!(!cfg.bounds_check);
378        assert!(!cfg.deadline_check);
379        assert!(!cfg.capability_check);
380        assert!(!cfg.div_check);
381    }
382
383    #[test]
384    fn trap_kind_round_trips_through_u64_code() {
385        for kind in [
386            SandboxTrapKind::DivisionByZero,
387            SandboxTrapKind::BoundsViolation,
388            SandboxTrapKind::CapabilityDenied,
389            SandboxTrapKind::ResourceExhausted,
390            SandboxTrapKind::HostFnMissing,
391            SandboxTrapKind::NumericOverflow,
392            SandboxTrapKind::HostFnError,
393        ] {
394            let code = kind as u64;
395            assert_eq!(SandboxTrapKind::from_code(code), kind);
396        }
397        // Unknown / 0 codes route to HostFnError (defensive catch-all).
398        assert_eq!(SandboxTrapKind::from_code(0), SandboxTrapKind::HostFnError);
399        assert_eq!(SandboxTrapKind::from_code(99), SandboxTrapKind::HostFnError);
400    }
401
402    #[test]
403    fn trap_kind_numbering_mirrors_cranelift_and_native_trap() {
404        // The numbering MUST match cranelift's TrapKind and the
405        // crate-local NativeTrap subset so the host decodes one cause
406        // numbering across backends.
407        assert_eq!(SandboxTrapKind::CapabilityDenied as u64, 3);
408        assert_eq!(SandboxTrapKind::HostFnMissing as u64, 5);
409        assert_eq!(SandboxTrapKind::HostFnError as u64, 7);
410        assert_eq!(
411            SandboxTrapKind::CapabilityDenied as u64,
412            crate::state::NativeTrap::CapabilityDenied as u64
413        );
414        assert_eq!(
415            SandboxTrapKind::HostFnMissing as u64,
416            crate::state::NativeTrap::HostFnMissing as u64
417        );
418        assert_eq!(
419            SandboxTrapKind::HostFnError as u64,
420            crate::state::NativeTrap::HostFnError as u64
421        );
422    }
423
424    #[test]
425    fn trap_kind_maps_to_runtime_error_variant() {
426        let range = TokenRange::default();
427        assert!(matches!(
428            SandboxTrapKind::DivisionByZero.to_runtime_error(range),
429            RuntimeError::DivisionByZero(_)
430        ));
431        assert!(matches!(
432            SandboxTrapKind::BoundsViolation.to_runtime_error(range),
433            RuntimeError::IndexOutOfBounds { .. }
434        ));
435        assert!(matches!(
436            SandboxTrapKind::CapabilityDenied.to_runtime_error(range),
437            RuntimeError::CapabilityDenied { .. }
438        ));
439        assert!(matches!(
440            SandboxTrapKind::ResourceExhausted.to_runtime_error(range),
441            RuntimeError::StepLimitExceeded { .. }
442        ));
443        assert!(matches!(
444            SandboxTrapKind::NumericOverflow.to_runtime_error(range),
445            RuntimeError::NumericOverflow(_)
446        ));
447        assert!(matches!(
448            SandboxTrapKind::HostFnMissing.to_runtime_error(range),
449            RuntimeError::Unsupported { .. }
450        ));
451    }
452
453    #[test]
454    fn grant_and_is_granted_round_trip() {
455        let mut vt = CapabilityVtable::with_capacity(64);
456        assert!(!vt.is_granted(2));
457        vt.grant(2);
458        assert!(vt.is_granted(2));
459        assert!(!vt.is_granted(3));
460        // The runtime carrier is the bitmask: bit 2 set.
461        assert_eq!(vt.caps_mask(), 1i64 << 2);
462    }
463
464    #[test]
465    fn grant_ignores_out_of_range_bits() {
466        let mut vt = CapabilityVtable::with_capacity(64);
467        vt.grant(64);
468        vt.grant(200);
469        assert_eq!(vt.caps_mask(), 0);
470        assert!(!vt.is_granted(64));
471    }
472
473    #[test]
474    fn register_via_gate_denies_when_capability_not_granted() {
475        let caps = Capabilities::default();
476        let mut vt = CapabilityVtable::with_capacity(64);
477        // `reads_fs` not granted in the default snapshot — bit stays clear.
478        let populated = vt.register_via_gate(&caps, CapabilityBit::ReadsFs);
479        assert!(!populated, "denied gate must leave the mask bit clear");
480        assert!(!vt.is_granted(CapabilityBit::ReadsFs.bit_index()));
481        assert_eq!(vt.caps_mask(), 0);
482    }
483
484    #[test]
485    fn register_via_gate_populates_when_capability_granted() {
486        let caps = Capabilities::all_granted();
487        let mut vt = CapabilityVtable::with_capacity(64);
488        let populated = vt.register_via_gate(&caps, CapabilityBit::Network);
489        assert!(populated, "granted gate must set the mask bit");
490        assert!(vt.is_granted(CapabilityBit::Network.bit_index()));
491        assert_eq!(vt.caps_mask(), 1i64 << CapabilityBit::Network.bit_index());
492    }
493
494    /// Mirrors cranelift's `host_fns` half: a registered callable is
495    /// resolvable by `import_idx` and dispatch-callable.
496    struct AddOne;
497    impl RelonFunction for AddOne {
498        fn call(&self, args: NativeArgs, _r: TokenRange) -> Result<Value, RuntimeError> {
499            match args.positional.first() {
500                Some(Value::Int(x)) => Ok(Value::Int(x + 1)),
501                _ => Err(RuntimeError::Unsupported {
502                    reason: "AddOne expects Int".into(),
503                }),
504            }
505        }
506    }
507
508    #[test]
509    fn host_fn_registry_round_trip() {
510        let mut vt = CapabilityVtable::with_capacity(64);
511        assert!(vt.resolve_host_fn(0).is_none());
512        vt.register_host_fn(0, Arc::new(AddOne));
513        assert_eq!(vt.host_fn_count(), 1);
514        let f = vt.resolve_host_fn(0).expect("registered");
515        let r = f
516            .call(
517                NativeArgs::from_positional(vec![Value::Int(41)], {
518                    // reuse the crate's caps shim via a trivial closure-free path
519                    use relon_eval_api::NativeFnCaps;
520                    struct NoCb;
521                    impl NativeFnCaps for NoCb {
522                        fn call_relon(
523                            &self,
524                            _f: &Value,
525                            _a: Vec<Value>,
526                            _r: TokenRange,
527                        ) -> Result<Value, RuntimeError> {
528                            Err(RuntimeError::Unsupported {
529                                reason: "no cb".into(),
530                            })
531                        }
532                    }
533                    Arc::new(NoCb)
534                }),
535                TokenRange::default(),
536            )
537            .expect("dispatch");
538        assert_eq!(r, Value::Int(42));
539    }
540}