Skip to main content

rpc/
ctx.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU64, Ordering};
3
4use uuid::Uuid;
5use wasmtime::Engine;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ScriptLimits {
9    pub fuel: u64,
10    pub max_memory_pages: u64,
11}
12
13impl Default for ScriptLimits {
14    fn default() -> Self {
15        Self {
16            fuel: 1_000_000,
17            max_memory_pages: 64,
18        }
19    }
20}
21
22#[derive(Debug, Clone)]
23pub struct ScriptCtx {
24    pub user_id: Uuid,
25    pub limits: ScriptLimits,
26}
27
28impl ScriptCtx {
29    #[must_use]
30    pub fn new(user_id: Uuid) -> Self {
31        Self {
32            user_id,
33            limits: ScriptLimits::default(),
34        }
35    }
36
37    #[must_use]
38    pub fn with_limits(mut self, limits: ScriptLimits) -> Self {
39        self.limits = limits;
40        self
41    }
42}
43
44/// Cooperative interrupt signal shared between the SLYNK reader task (which
45/// `interrupt()`s on `(:emacs-interrupt)`) and the eval task. Backed by a
46/// monotonic generation counter rather than a boolean latch: an interrupt
47/// increments the count, and a request asks "did the count move since I
48/// started?" via [`InterruptHandle::generation`] + [`InterruptHandle::is_interrupted_since`].
49///
50/// The generation design is deliberate (a prior boolean latch was racy): there
51/// is no shared flag to leave set, so an interrupt can never "stick" and poison
52/// an unrelated later request. Each top-level operation snapshots the
53/// generation at entry and compares against it; the next operation re-snapshots,
54/// automatically discarding any interrupt that targeted the finished one — no
55/// per-exit cleanup needed, on any terminal path.
56#[derive(Debug, Clone, Default)]
57pub struct InterruptHandle {
58    generation: Arc<AtomicU64>,
59}
60
61impl InterruptHandle {
62    pub(crate) fn new() -> Self {
63        Self::default()
64    }
65
66    /// Signals an interrupt by advancing the generation. Idempotent in effect:
67    /// any request whose baseline predates this call observes the interrupt.
68    pub fn interrupt(&self) {
69        self.generation.fetch_add(1, Ordering::SeqCst);
70    }
71
72    /// The current interrupt generation. The eval task keeps an "ack"
73    /// watermark of the highest generation already attributed to a finished
74    /// request; a new interrupt is one whose generation exceeds that watermark.
75    pub(crate) fn generation(&self) -> u64 {
76        self.generation.load(Ordering::SeqCst)
77    }
78}
79
80/// Cooperative cancel handle for an in-flight eval. Wraps a clone of
81/// the Session's [`wasmtime::Engine`]; the engine's epoch counter is
82/// shared internally so a `.bump()` from any task takes effect on the
83/// concurrent `func.call_async` running against the same engine.
84///
85/// Each Session sets `store.set_epoch_deadline(1)` before invoking
86/// `nomi-eval`, so one bump trips the deadline and the eval traps
87/// with `wasmtime::Trap::Interrupt` — surfaced upstream as
88/// `EngineError::EpochInterrupt` and on the wire as
89/// `(:error (:code interrupted ...))`.
90#[derive(Clone, Debug)]
91pub struct EpochBumper {
92    engine: Engine,
93}
94
95impl EpochBumper {
96    pub(crate) fn new(engine: Engine) -> Self {
97        Self { engine }
98    }
99
100    /// Increments the engine epoch. Returns immediately (no await).
101    /// Safe to call before, during, or after an eval; the next epoch
102    /// check inside any in-flight `call_async` traps.
103    pub fn bump(&self) {
104        self.engine.increment_epoch();
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn default_limits_are_non_zero() {
114        let limits = ScriptLimits::default();
115        assert!(limits.fuel > 0);
116        assert!(limits.max_memory_pages > 0);
117    }
118
119    #[test]
120    fn ctx_carries_user_id() {
121        let id = Uuid::new_v4();
122        let ctx = ScriptCtx::new(id);
123        assert_eq!(ctx.user_id, id);
124    }
125
126    #[test]
127    fn with_limits_overrides_default() {
128        let id = Uuid::new_v4();
129        let limits = ScriptLimits {
130            fuel: 42,
131            max_memory_pages: 7,
132        };
133        let ctx = ScriptCtx::new(id).with_limits(limits);
134        assert_eq!(ctx.limits, limits);
135    }
136
137    #[test]
138    fn interrupt_advances_generation_monotonically() {
139        let h = InterruptHandle::new();
140        let base = h.generation();
141        h.interrupt();
142        assert_eq!(h.generation(), base + 1);
143        h.interrupt();
144        assert_eq!(h.generation(), base + 2);
145    }
146
147    #[test]
148    fn interrupt_handle_clones_share_generation() {
149        let a = InterruptHandle::new();
150        let base = a.generation();
151        let b = a.clone();
152        b.interrupt();
153        assert!(a.generation() > base);
154    }
155}