1
use std::cell::RefCell;
2
use std::sync::{Arc, Mutex};
3
use std::time::{Duration, Instant};
4

            
5
use nomiscript::{
6
    Compiler, Error as NomiError, Expr, HostFnSpec, Program, Reader, SymbolTable, Value,
7
};
8
use scripting::runtime::{
9
    EngineError, EngineOpts, ModuleCache, build_engine, classify_runtime_error, decode_eval_result,
10
};
11
use thiserror::Error;
12
use tracing::debug;
13
use wasmtime::{AnyRef, Engine, Linker, Rooted, Store, Val};
14

            
15
use crate::ctx::{EpochBumper, InterruptHandle, ScriptCtx};
16
use crate::envelope::{
17
    EnvelopeError, ErrorCode, Request, RequestId, Response, ResponsePayload, format_response,
18
    parse_request,
19
};
20

            
21
const EPOCH_DEADLINE_TICKS: u64 = 1;
22

            
23
/// Wasmtime Store data type for the rpc eval channel. Carries the
24
/// per-session user context (`ScriptCtx`) so native fns reach
25
/// `caller.data().ctx().user_id` directly. The legacy
26
/// `EvalContext` capture-protocol companion field retired alongside
27
/// the capture imports in P4 A6.c — `nomi-eval` now returns its
28
/// final value via the function's `(ref null any)` return slot.
29
///
30
/// Native fns are async (`Linker::func_wrap_async`) — they `.await`
31
/// `server::command::*` futures directly on whatever runtime drives the
32
/// surrounding `Session::handle_form` call. No owned runtime, no
33
/// `spawn_blocking`, no thread-local pool concerns.
34
pub struct SessionData {
35
    ctx: ScriptCtx,
36
    /// Per-request stdout sink. `env.log` (PRINT/DISPLAY/NEWLINE) appends here so
37
    /// the mREPL (`nms --slynk-port`) can surface script output as
38
    /// `:write-string` separately from the final value. Shared with the owning
39
    /// `Session`, which drains it per request. The non-mrepl paths (sshd / the
40
    /// rpc text REPL) ignore it; `env.log` still tees to `tracing` for them.
41
    output: Arc<Mutex<String>>,
42
    /// Render-only draft accumulator. `Some` only on the restricted template
43
    /// render path ([`crate::template`]); the draft natives mutate it and the
44
    /// render entry point reads it back via `store.into_data()`. `None` on the
45
    /// normal eval channel, where the draft natives are not even linked.
46
    draft: Option<RefCell<crate::draft::TransactionDraft>>,
47
}
48

            
49
impl SessionData {
50
7432
    pub(crate) fn new(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
51
7432
        Self {
52
7432
            ctx,
53
7432
            output,
54
7432
            draft: None,
55
7432
        }
56
7432
    }
57

            
58
    /// Builds session data with a draft accumulator armed — the render path's
59
    /// constructor. Draft natives require `draft` to be `Some`.
60
140
    pub(crate) fn for_render(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
61
140
        Self {
62
140
            ctx,
63
140
            output,
64
140
            draft: Some(RefCell::new(crate::draft::TransactionDraft::new())),
65
140
        }
66
140
    }
67

            
68
    #[must_use]
69
6087
    pub fn ctx(&self) -> &ScriptCtx {
70
6087
        &self.ctx
71
6087
    }
72

            
73
    /// Mutates the draft accumulator if armed (render path). Returns an error
74
    /// on the normal eval path where no draft is present — a draft native
75
    /// reaching a non-render Store is a wiring bug, surfaced as a trap.
76
234
    pub fn with_draft<F>(&self, f: F) -> wasmtime::Result<()>
77
234
    where
78
234
        F: FnOnce(&mut crate::draft::TransactionDraft),
79
    {
80
234
        let cell = self
81
234
            .draft
82
234
            .as_ref()
83
234
            .ok_or_else(|| wasmtime::Error::msg("draft native invoked outside render mode"))?;
84
234
        f(&mut cell.borrow_mut());
85
234
        Ok(())
86
234
    }
87

            
88
    /// Consumes the accumulated draft, if any. Called after a render run via
89
    /// `store.into_data()`.
90
    #[must_use]
91
78
    pub fn into_draft(self) -> Option<crate::draft::TransactionDraft> {
92
78
        self.draft.map(RefCell::into_inner)
93
78
    }
94

            
95
    /// Appends a script-output line to the per-request buffer. Called by the
96
    /// `env.log` host fn. A poisoned lock is swallowed (output capture is
97
    /// best-effort telemetry, never a reason to fail an eval).
98
6
    pub fn push_output(&self, msg: &str) {
99
6
        if let Ok(mut buf) = self.output.lock() {
100
6
            buf.push_str(msg);
101
6
        }
102
6
    }
103
}
104

            
105
/// Per-channel evaluator. Owns mutable state across forms (defun-defined symbols
106
/// persist between requests in the same session via `SymbolTable`; the wasm
107
/// `ModuleCache` reuses compilations of structurally identical forms) and an
108
/// interrupt handle that cooperatively short-circuits the next form on demand.
109
///
110
/// Eval pipeline: `nomiscript::Compiler::compile_with_mode(Eval)` emits a
111
/// module exporting `nomi-eval`; the form's final value rides the function's
112
/// `(ref null any)` return slot and the host walks it into an `EvalValue`
113
/// via [`decode_eval_result`].
114
pub struct Session {
115
    ctx: ScriptCtx,
116
    engine: Engine,
117
    compiler: Compiler,
118
    cache: ModuleCache,
119
    symbols: SymbolTable,
120
    interrupt: InterruptHandle,
121
    /// Watermark of the highest interrupt generation already attributed to a
122
    /// finished request (see [`Session::check_interrupt`] / [`Session::ack_interrupt`]).
123
    /// A `C-g` counts only while `interrupt.generation() > interrupt_ack`, so one
124
    /// signal aborts exactly one request and can never linger to poison a later
125
    /// form.
126
    interrupt_ack: u64,
127
    /// Shared with each per-request `SessionData` so `env.log` output lands
128
    /// where [`Session::handle_request`] can drain it.
129
    output: Arc<Mutex<String>>,
130
    /// `None` on an unguarded session; otherwise when the account status may
131
    /// next go unchecked until. See [`Session::guarded`].
132
    status_check_after: Option<Instant>,
133
}
134

            
135
/// The same 30s as the tenant pool's `QUARANTINE_RECHECK`, kept in step by hand.
136
const STATUS_RECHECK: Duration = Duration::from_secs(30);
137

            
138
/// A structured eval result: the captured script output plus the typed
139
/// value/error payload. The SLYNK mREPL maps `output` → `:write-string` and
140
/// `payload` → `:write-values` / `:evaluation-aborted`. `handle_form` (the text
141
/// wire path) does not use this — it formats the payload alone.
142
#[derive(Debug, Clone, PartialEq)]
143
pub struct EvalOutcome {
144
    pub output: String,
145
    pub payload: ResponsePayload,
146
}
147

            
148
#[derive(Debug, Error)]
149
pub enum SessionError {
150
    #[error("engine init failed: {0}")]
151
    Engine(#[from] EngineError),
152
}
153

            
154
impl Session {
155
3911
    pub fn new(ctx: ScriptCtx) -> Result<Self, SessionError> {
156
3911
        let engine = build_engine(EngineOpts::baseline().with_fuel())?;
157
3911
        let host_fns = crate::natives::all_compiler_specs();
158
3911
        let mut symbols = SymbolTable::with_builtins();
159
3911
        symbols.register_host_fns(&host_fns);
160
        // Host-dependent prelude (ADR-0029): loaded only here, after the RPC
161
        // host fns it calls are registered. The universal prelude already rode
162
        // in via `with_builtins`.
163
3911
        crate::host_prelude::load(&mut symbols);
164
3911
        let mut session = Self {
165
3911
            ctx,
166
3911
            engine,
167
3911
            compiler: Compiler::with_host_fns(host_fns.clone()),
168
3911
            cache: ModuleCache::new(),
169
3911
            symbols,
170
3911
            interrupt: InterruptHandle::new(),
171
3911
            interrupt_ack: 0,
172
3911
            output: Arc::new(Mutex::new(String::new())),
173
3911
            status_check_after: None,
174
3911
        };
175
        // Phase 4: pre-warm the per-Session ModuleCache with the
176
        // bare-call wasm for every zero-arg host fn that has a
177
        // return type. The cache is keyed by full bytecode bytes so
178
        // when a request like `(:id N :form (rpc-protocol-version))`
179
        // lands, `cache.get_or_compile` finds the pre-compiled
180
        // module and `handle_form`'s critical path is
181
        // instantiate-only. Composed forms / arg-bearing calls /
182
        // unseen forms still hit the cold compile path.
183
3911
        session.warm_bare_call_cache(&host_fns);
184
3911
        Ok(session)
185
3911
    }
186

            
187
    /// Pre-compile and cache the bare-call wasm for every zero-arg
188
    /// host fn whose result type is non-None. Skips no-arg fns with
189
    /// `result: None` (their bare call would error at value position)
190
    /// and skips arg-bearing fns (their wasm embeds the literal
191
    /// args, so pre-warming would be wrong-keyed).
192
    ///
193
    /// Failures are silently ignored — pre-warming is an optimization,
194
    /// not a correctness step. If a spec fails to compile here, the
195
    /// runtime path will surface the same error when the form lands.
196
3911
    fn warm_bare_call_cache(&mut self, host_fns: &[HostFnSpec]) {
197
187728
        for spec in host_fns {
198
187728
            if !spec.params.is_empty() || spec.result.is_none() {
199
144707
                continue;
200
43021
            }
201
43021
            let form = Expr::List(vec![Expr::Symbol(spec.nomi_name.clone())]);
202
43021
            let program = Program::new(vec![form]);
203
43021
            let Ok((bytes, _ty)) = self
204
43021
                .compiler
205
43021
                .compile_eval_with_type(&program, &mut self.symbols)
206
            else {
207
                continue;
208
            };
209
43021
            let _ = self.cache.get_or_compile(&self.engine, &bytes);
210
        }
211
3911
    }
212

            
213
    #[must_use]
214
    pub fn ctx(&self) -> &ScriptCtx {
215
        &self.ctx
216
    }
217

            
218
    #[must_use]
219
372
    pub fn interrupt_handle(&self) -> InterruptHandle {
220
372
        self.interrupt.clone()
221
372
    }
222

            
223
    /// Symbol names completing `prefix`, sorted and deduplicated, for the SLYNK
224
    /// completion rex (`M-x sly-complete-symbol` / mREPL TAB). The reader folds
225
    /// symbols with `make_ascii_uppercase` at read time, so the match uppercases
226
    /// the prefix the SAME way (ASCII-only — matching the reader, not full
227
    /// Unicode) and returns the canonical name (it re-reads identically). Skips
228
    /// `(SETF …)` setf-place names and compiler-internal `$…` / `__…` symbols —
229
    /// none are head symbols a user types. An empty `prefix` lists every
230
    /// completable symbol.
231
    #[must_use]
232
4
    pub fn completions(&self, prefix: &str) -> Vec<String> {
233
4
        let needle = prefix.to_ascii_uppercase();
234
4
        let mut names: Vec<String> = self
235
4
            .symbols
236
4
            .iter()
237
1017
            .map(|(name, _)| name.as_str())
238
1017
            .filter(|name| {
239
1017
                !name.starts_with('$') && !name.starts_with("__") && !name.starts_with("(SETF")
240
1017
            })
241
897
            .filter(|name| name.starts_with(&needle))
242
4
            .map(str::to_owned)
243
4
            .collect();
244
4
        names.sort_unstable();
245
4
        names.dedup();
246
4
        names
247
4
    }
248

            
249
    /// Cooperative cancel handle for an in-flight `nomi-eval`. Clone
250
    /// and hand to the transport layer: when the client sends a
251
    /// cancel signal (e.g. emacs `C-g`), call `.bump()` and the
252
    /// awaiting evaluation traps with `EngineError::EpochInterrupt`,
253
    /// surfacing on the wire as `(:error (:code interrupted ...))`.
254
    #[must_use]
255
366
    pub fn epoch_bumper(&self) -> EpochBumper {
256
366
        EpochBumper::new(self.engine.clone())
257
366
    }
258

            
259
    /// Number of pre-compiled wasm modules currently cached.
260
    /// Used by tests to confirm the phase-4 pre-warm step ran and
261
    /// that subsequent `handle_form` calls of cached forms don't
262
    /// trigger a cold compile.
263
182
    pub fn cache_size(&self) -> Result<usize, EngineError> {
264
182
        self.cache.len()
265
182
    }
266

            
267
7599
    pub async fn handle_form(&mut self, frame: &str) -> String {
268
1663
        let response = match self.evaluate(frame).await {
269
1493
            Ok(resp) => resp,
270
170
            Err(err) => err.into_response(),
271
        };
272
1663
        format_response(&response)
273
1663
    }
274

            
275
    /// Structured eval of a single bare `form` (not an envelope frame): clears
276
    /// the output buffer, runs the form, and returns the captured script output
277
    /// alongside the typed value/error payload. The SLYNK mREPL uses this so it
278
    /// can render `:write-string` (output) and `:write-values` /
279
    /// `:evaluation-aborted` (payload) separately. `handle_form` is unchanged.
280
7
    pub async fn handle_request(&mut self, source: &str) -> EvalOutcome {
281
7
        if let Ok(mut buf) = self.output.lock() {
282
7
            buf.clear();
283
7
        }
284
7
        let payload = match self.eval_source(source).await {
285
4
            Ok(value) => ResponsePayload::Value(value),
286
3
            Err(err) => err.into_response().payload,
287
        };
288
7
        let output = self
289
7
            .output
290
7
            .lock()
291
7
            .map(|buf| buf.clone())
292
7
            .unwrap_or_default();
293
7
        EvalOutcome { output, payload }
294
7
    }
295

            
296
    /// Parses `source` to a single top-level form and evaluates it. Unlike
297
    /// `handle_form`, the source is parsed to an AST directly (NOT interpolated
298
    /// into an envelope string), so plist-shaped input can't hijack the
299
    /// envelope: `1 :form (+ 2 3)` is rejected as "more than one form", not
300
    /// silently re-read as a `(:id 1 :form …)` plist. The fixed `RequestId::Int(0)`
301
    /// is unused downstream — the SLYNK layer tracks its own channel ids.
302
7
    async fn eval_source(&mut self, source: &str) -> Result<Value, EvalFailure> {
303
7
        let id = RequestId::Int(0);
304
7
        let program = Reader::parse(source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
305
7
        let mut exprs = program.exprs;
306
7
        let form = match exprs.len() {
307
            0 => return Ok(Value::Nil),
308
6
            1 => exprs.remove(0),
309
            _ => {
310
1
                return Err(EvalFailure::Eval(
311
1
                    id,
312
1
                    NomiError::Compile("expected a single form".to_string()),
313
1
                ));
314
            }
315
        };
316
6
        self.eval_one_form(form).await
317
7
    }
318

            
319
    /// Reads `path`, evaluates every top-level form in source order (state —
320
    /// `defun`s etc. — accumulates across forms, like the sshd channel), and
321
    /// returns a short summary. Powers SLY's `M-x sly-load-file`
322
    /// (`slynk:load-file`). Captured output across all forms is returned for the
323
    /// caller to surface; a failing form aborts the load at that point.
324
4
    pub async fn handle_file(&mut self, path: &str) -> EvalOutcome {
325
4
        if let Ok(mut buf) = self.output.lock() {
326
4
            buf.clear();
327
4
        }
328
4
        let payload = match self.load_path(path).await {
329
1
            Ok(summary) => ResponsePayload::Value(Value::String(summary)),
330
3
            Err(err) => err.into_response().payload,
331
        };
332
4
        let output = self
333
4
            .output
334
4
            .lock()
335
4
            .map(|buf| buf.clone())
336
4
            .unwrap_or_default();
337
4
        EvalOutcome { output, payload }
338
4
    }
339

            
340
    /// Attributes all interrupts up to `observed` to the current request, so
341
    /// they aren't counted again. Crucially the caller passes the SAME
342
    /// generation it used to decide an interrupt is pending — never a fresh
343
    /// reload — so a `C-g` that lands strictly after that observation point is
344
    /// NOT folded into this request; it stays pending for the next form. The
345
    /// guard keeps the watermark monotonic.
346
    ///
347
    /// Coalescing is intended: several `C-g`s observed together (generation
348
    /// jumped by >1) cancel the one in-flight form, they do not pre-arm
349
    /// cancellation of distinct future forms — that is the REPL `C-g` contract.
350
595
    fn ack_interrupt(&mut self, observed: u64) {
351
595
        if observed > self.interrupt_ack {
352
113
            self.interrupt_ack = observed;
353
482
        }
354
595
    }
355

            
356
    /// If an interrupt is pending, ack it (using the one generation snapshot
357
    /// that decided it) and yield the interrupted error; otherwise `None`. The
358
    /// single checkpoint used everywhere a form can abort on a `C-g` before the
359
    /// Wasm call.
360
14990
    fn check_interrupt(&mut self, id: &RequestId) -> Option<EvalFailure> {
361
14990
        let observed = self.interrupt.generation();
362
14990
        (observed > self.interrupt_ack).then(|| {
363
113
            self.ack_interrupt(observed);
364
113
            EvalFailure::Interrupted(id.clone())
365
113
        })
366
14990
    }
367

            
368
4
    async fn load_path(&mut self, path: &str) -> Result<String, EvalFailure> {
369
4
        let id = RequestId::Int(0);
370
        // The synchronous file read + parse aren't covered by `run`'s checks, so
371
        // guard them explicitly; a `C-g` during a later form is caught by that
372
        // form's `run`, and the failing form aborts the whole load via `?`.
373
4
        if let Some(err) = self.check_interrupt(&id) {
374
1
            return Err(err);
375
3
        }
376
3
        let source = std::fs::read_to_string(path).map_err(|err| {
377
1
            EvalFailure::Eval(
378
1
                id.clone(),
379
1
                NomiError::Compile(format!("cannot read {path}: {err}")),
380
1
            )
381
1
        })?;
382
2
        if let Some(err) = self.check_interrupt(&id) {
383
            return Err(err);
384
2
        }
385
2
        let program = Reader::parse(&source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
386
2
        let count = program.exprs.len();
387
4
        for form in program.exprs {
388
4
            self.run(&Request {
389
4
                id: id.clone(),
390
4
                form,
391
4
            })
392
4
            .await?;
393
        }
394
1
        Ok(format!("loaded {path} ({count} forms)"))
395
4
    }
396

            
397
    /// Evaluates a single already-parsed form (mREPL input). The interrupt
398
    /// pre-start check lives in `run`.
399
6
    async fn eval_one_form(&mut self, form: Expr) -> Result<Value, EvalFailure> {
400
6
        self.run(&Request {
401
6
            id: RequestId::Int(0),
402
6
            form,
403
6
        })
404
6
        .await
405
6
    }
406

            
407
7599
    async fn evaluate(&mut self, frame: &str) -> Result<Response, EvalFailure> {
408
1663
        let request = parse_request(frame).map_err(EvalFailure::Envelope)?;
409
1658
        let value = self.run(&request).await?;
410
1493
        Ok(Response {
411
1493
            id: request.id,
412
1493
            payload: ResponsePayload::Value(value),
413
1493
        })
414
1663
    }
415

            
416
    /// A session for a remote user, re-checking per form that the account may
417
    /// still be served.
418
    ///
419
    /// The daemon's `refuse_if_withheld` runs only when a channel is *opened*
420
    /// and most natives never touch the tenant pool, so without this an open SSH
421
    /// session outlives a disable indefinitely. [`Session::new`] stays unguarded
422
    /// for the local CLI and for tests, whose users have no directory row.
423
    ///
424
    /// # Errors
425
    /// As [`Session::new`].
426
78
    pub fn guarded(ctx: ScriptCtx) -> Result<Self, SessionError> {
427
78
        let mut session = Self::new(ctx)?;
428
78
        session.status_check_after = Some(Instant::now());
429
78
        Ok(session)
430
78
    }
431

            
432
    /// Bounded by [`STATUS_RECHECK`] rather than read per form: this is the hot
433
    /// eval path and the global pool's budget is 5.
434
7579
    async fn check_account(&mut self, id: &RequestId) -> Result<(), EvalFailure> {
435
1668
        let Some(due) = self.status_check_after else {
436
1406
            return Ok(());
437
        };
438
262
        if Instant::now() < due {
439
247
            return Ok(());
440
15
        }
441
        // Fails closed: an unreadable directory is not evidence of health.
442
15
        let refusal = server::command::user::account_refusal(self.ctx.user_id)
443
15
            .await
444
15
            .unwrap_or(Some(server::command::user::Refusal::Quarantined));
445

            
446
15
        match refusal {
447
            None => {
448
14
                self.status_check_after = Some(Instant::now() + STATUS_RECHECK);
449
14
                Ok(())
450
            }
451
1
            Some(reason) => Err(EvalFailure::Refused(id.clone(), reason)),
452
        }
453
1668
    }
454

            
455
7579
    async fn run(&mut self, request: &Request) -> Result<Value, EvalFailure> {
456
1668
        debug!(user_id = %self.ctx.user_id, "evaluating form");
457
1668
        self.check_account(&request.id).await?;
458
        // A `C-g` that landed before this form started (pre-armed, or during an
459
        // earlier form of a load) aborts here.
460
1667
        if let Some(err) = self.check_interrupt(&request.id) {
461
19
            return Err(err);
462
1648
        }
463
1648
        let program = Program::new(vec![request.form.clone()]);
464
1648
        let (bytes, result_ty) = self
465
1648
            .compiler
466
1648
            .compile_eval_with_type(&program, &mut self.symbols)
467
1648
            .map_err(|err| EvalFailure::Eval(request.id.clone(), err))?;
468
1633
        let module = self
469
1633
            .cache
470
1633
            .get_or_compile(&self.engine, &bytes)
471
1633
            .map_err(|err| EvalFailure::Engine(request.id.clone(), err))?;
472

            
473
1633
        let mut linker: Linker<SessionData> = Linker::new(&self.engine);
474
1633
        crate::natives::link(&mut linker).map_err(|err| {
475
            EvalFailure::Engine(
476
                request.id.clone(),
477
                EngineError::Instantiate(err.to_string()),
478
            )
479
        })?;
480

            
481
1633
        let mut store: Store<SessionData> = Store::new(
482
1633
            &self.engine,
483
1633
            SessionData::new(self.ctx.clone(), Arc::clone(&self.output)),
484
        );
485
1633
        store.set_fuel(self.ctx.limits.fuel).map_err(|err| {
486
            EvalFailure::Engine(request.id.clone(), EngineError::Fuel(err.to_string()))
487
        })?;
488
1633
        store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
489

            
490
1633
        let instance = linker
491
1633
            .instantiate_async(&mut store, &module)
492
1633
            .await
493
1633
            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
494
1633
        let func = instance.get_func(&mut store, "nomi-eval").ok_or_else(|| {
495
            EvalFailure::Engine(
496
                request.id.clone(),
497
                EngineError::MissingExport("nomi-eval".into()),
498
            )
499
        })?;
500
        // An interrupt that arrived during compile/link (the epoch bump only
501
        // cancels a running call) — abort before entering Wasm.
502
1633
        if let Some(err) = self.check_interrupt(&request.id) {
503
41
            return Err(err);
504
1592
        }
505
1592
        let mut results = [Val::AnyRef(None)];
506
1592
        let call_result = func.call_async(&mut store, &[], &mut results).await;
507
        // The reader bumps the epoch AND signals the interrupt together on
508
        // `(:emacs-interrupt)`. If a C-g arrived during THIS call it cancelled it
509
        // (epoch trap → `call_result` is `Err`); attribute it to this request so
510
        // it can't also abort the next one. We ack only on the error path, so a
511
        // C-g just after a clean success stays pending for the next form. This
512
        // covers every terminal exit uniformly (epoch cancel, the
513
        // out-of-fuel/runtime-trap-wins-the-race case) with no per-exit cleanup.
514
        //
515
        // Attribution cutoff (deliberate): the ack snapshot is taken AFTER the
516
        // call returns, so a C-g landing in the sub-µs window between the call
517
        // returning `Err` and this load is attributed to THIS (failing) form,
518
        // not the next one. That is correct REPL semantics — at that instant the
519
        // failing form is still the in-flight request (its error hasn't reached
520
        // the client), so the user can't yet have meant the C-g for a later
521
        // form. A C-g pressed after the error is surfaced necessarily arrives
522
        // after `run` returns, advancing the generation again, and the next
523
        // form's pre-start `check_interrupt` catches it. The exact instant of
524
        // call return is unobservable, so this boundary is irreducible, not a
525
        // lost interrupt.
526
1592
        if call_result.is_err() {
527
92
            let observed = self.interrupt.generation();
528
92
            self.ack_interrupt(observed);
529
1500
        }
530
1592
        call_result
531
1592
            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
532

            
533
1500
        let any: Option<Rooted<AnyRef>> = match &results[0] {
534
1500
            Val::AnyRef(a) => *a,
535
            _ => {
536
                return Err(EvalFailure::Engine(
537
                    request.id.clone(),
538
                    EngineError::Trap("nomi-eval did not return anyref".into()),
539
                ));
540
            }
541
        };
542
1500
        let captured = decode_eval_result(&mut store, any, result_ty).map_err(|err| {
543
            EvalFailure::Engine(
544
                request.id.clone(),
545
                EngineError::Trap(format!("decoding nomi-eval result: {err}")),
546
            )
547
        })?;
548
1500
        Ok(Value::from(captured))
549
1668
    }
550
}
551

            
552
enum EvalFailure {
553
    Envelope(EnvelopeError),
554
    Eval(RequestId, NomiError),
555
    Engine(RequestId, EngineError),
556
    Interrupted(RequestId),
557
    Refused(RequestId, server::command::user::Refusal),
558
}
559

            
560
impl EvalFailure {
561
743
    fn into_response(self) -> Response {
562
743
        match self {
563
30
            EvalFailure::Envelope(err) => Response {
564
30
                id: RequestId::Int(0),
565
30
                payload: ResponsePayload::Error {
566
30
                    code: envelope_error_code(&err),
567
30
                    message: err.to_string(),
568
30
                    detail: Some(format!("{err:?}")),
569
30
                },
570
30
            },
571
92
            EvalFailure::Eval(id, err) => Response {
572
92
                id,
573
92
                payload: ResponsePayload::Error {
574
92
                    code: nomi_error_code(&err),
575
92
                    message: err.to_string(),
576
92
                    detail: Some(format!("{err:?}")),
577
92
                },
578
92
            },
579
482
            EvalFailure::Engine(id, err) => Response {
580
482
                id,
581
482
                payload: ResponsePayload::Error {
582
482
                    code: engine_error_code(&err),
583
482
                    message: err.to_string(),
584
482
                    detail: Some(format!("{err:?}")),
585
482
                },
586
482
            },
587
113
            EvalFailure::Interrupted(id) => Response {
588
113
                id,
589
113
                payload: ResponsePayload::Error {
590
113
                    code: ErrorCode::new(ErrorCode::INTERRUPTED),
591
113
                    message: "evaluation interrupted before start".into(),
592
113
                    detail: None,
593
113
                },
594
113
            },
595
26
            EvalFailure::Refused(id, reason) => Response {
596
26
                id,
597
                payload: ResponsePayload::Error {
598
26
                    code: ErrorCode::new(ErrorCode::AUTH),
599
26
                    message: match reason {
600
                        server::command::user::Refusal::Quarantined => {
601
                            "This account is undergoing maintenance and is temporarily unavailable"
602
                        }
603
26
                        server::command::user::Refusal::Disabled => "This account is disabled",
604
                    }
605
26
                    .into(),
606
26
                    detail: None,
607
                },
608
            },
609
        }
610
743
    }
611
}
612

            
613
30
fn envelope_error_code(err: &EnvelopeError) -> ErrorCode {
614
30
    let symbol = match err {
615
3
        EnvelopeError::Parse(_) => ErrorCode::PARSE,
616
        EnvelopeError::NotSingleExpr
617
        | EnvelopeError::NotPlist
618
        | EnvelopeError::MissingKey(_)
619
27
        | EnvelopeError::InvalidValue(_, _) => ErrorCode::ARGS,
620
    };
621
30
    ErrorCode::new(symbol)
622
30
}
623

            
624
92
fn nomi_error_code(err: &NomiError) -> ErrorCode {
625
92
    let symbol = match err {
626
        NomiError::Parse(_) => ErrorCode::PARSE,
627
91
        NomiError::Compile(_) | NomiError::UndefinedSymbol(_) => ErrorCode::COMPILE,
628
        NomiError::Runtime(_) => ErrorCode::RUNTIME,
629
1
        NomiError::Type { .. } | NomiError::Arity { .. } => ErrorCode::ARGS,
630
    };
631
92
    ErrorCode::new(symbol)
632
92
}
633

            
634
482
fn engine_error_code(err: &EngineError) -> ErrorCode {
635
482
    match err {
636
        EngineError::Compile(_) => ErrorCode::new(ErrorCode::COMPILE),
637
315
        EngineError::OutOfFuel | EngineError::Trap(_) => ErrorCode::new(ErrorCode::RUNTIME),
638
1
        EngineError::EpochInterrupt => ErrorCode::new(ErrorCode::INTERRUPTED),
639
        EngineError::Instantiate(_) | EngineError::MissingExport(_) => {
640
            ErrorCode::new(ErrorCode::SERVER)
641
        }
642
        EngineError::Fuel(_) | EngineError::Config(_) | EngineError::CachePoisoned => {
643
            ErrorCode::new(ErrorCode::SERVER)
644
        }
645
52
        EngineError::NoConversion(_) => ErrorCode::new(ErrorCode::NO_CONVERSION),
646
        // Commodity mismatch now arrives as a `ScriptRaised` carrying the
647
        // reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026): it `throw`s
648
        // `$nomi_error` in-guest and the boundary wrapper bridges the uncaught
649
        // throw to `__nomi_raise`. The code flows through verbatim like any
650
        // script raise; the wire `:code` is the upper-cased symbol form.
651
114
        EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
652
    }
653
482
}
654

            
655
#[cfg(test)]
656
mod tests;