Skip to main content

rpc/
session.rs

1use std::cell::RefCell;
2use std::sync::{Arc, Mutex};
3use std::time::{Duration, Instant};
4
5use nomiscript::{
6    Compiler, Error as NomiError, Expr, HostFnSpec, Program, Reader, SymbolTable, Value,
7};
8use scripting::runtime::{
9    EngineError, EngineOpts, ModuleCache, build_engine, classify_runtime_error, decode_eval_result,
10};
11use thiserror::Error;
12use tracing::debug;
13use wasmtime::{AnyRef, Engine, Linker, Rooted, Store, Val};
14
15use crate::ctx::{EpochBumper, InterruptHandle, ScriptCtx};
16use crate::envelope::{
17    EnvelopeError, ErrorCode, Request, RequestId, Response, ResponsePayload, format_response,
18    parse_request,
19};
20
21const 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.
34pub 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
49impl SessionData {
50    pub(crate) fn new(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
51        Self {
52            ctx,
53            output,
54            draft: None,
55        }
56    }
57
58    /// Builds session data with a draft accumulator armed — the render path's
59    /// constructor. Draft natives require `draft` to be `Some`.
60    pub(crate) fn for_render(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
61        Self {
62            ctx,
63            output,
64            draft: Some(RefCell::new(crate::draft::TransactionDraft::new())),
65        }
66    }
67
68    #[must_use]
69    pub fn ctx(&self) -> &ScriptCtx {
70        &self.ctx
71    }
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    pub fn with_draft<F>(&self, f: F) -> wasmtime::Result<()>
77    where
78        F: FnOnce(&mut crate::draft::TransactionDraft),
79    {
80        let cell = self
81            .draft
82            .as_ref()
83            .ok_or_else(|| wasmtime::Error::msg("draft native invoked outside render mode"))?;
84        f(&mut cell.borrow_mut());
85        Ok(())
86    }
87
88    /// Consumes the accumulated draft, if any. Called after a render run via
89    /// `store.into_data()`.
90    #[must_use]
91    pub fn into_draft(self) -> Option<crate::draft::TransactionDraft> {
92        self.draft.map(RefCell::into_inner)
93    }
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    pub fn push_output(&self, msg: &str) {
99        if let Ok(mut buf) = self.output.lock() {
100            buf.push_str(msg);
101        }
102    }
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`].
114pub 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.
136const 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)]
143pub struct EvalOutcome {
144    pub output: String,
145    pub payload: ResponsePayload,
146}
147
148#[derive(Debug, Error)]
149pub enum SessionError {
150    #[error("engine init failed: {0}")]
151    Engine(#[from] EngineError),
152}
153
154impl Session {
155    pub fn new(ctx: ScriptCtx) -> Result<Self, SessionError> {
156        let engine = build_engine(EngineOpts::baseline().with_fuel())?;
157        let host_fns = crate::natives::all_compiler_specs();
158        let mut symbols = SymbolTable::with_builtins();
159        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        crate::host_prelude::load(&mut symbols);
164        let mut session = Self {
165            ctx,
166            engine,
167            compiler: Compiler::with_host_fns(host_fns.clone()),
168            cache: ModuleCache::new(),
169            symbols,
170            interrupt: InterruptHandle::new(),
171            interrupt_ack: 0,
172            output: Arc::new(Mutex::new(String::new())),
173            status_check_after: None,
174        };
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        session.warm_bare_call_cache(&host_fns);
184        Ok(session)
185    }
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    fn warm_bare_call_cache(&mut self, host_fns: &[HostFnSpec]) {
197        for spec in host_fns {
198            if !spec.params.is_empty() || spec.result.is_none() {
199                continue;
200            }
201            let form = Expr::List(vec![Expr::Symbol(spec.nomi_name.clone())]);
202            let program = Program::new(vec![form]);
203            let Ok((bytes, _ty)) = self
204                .compiler
205                .compile_eval_with_type(&program, &mut self.symbols)
206            else {
207                continue;
208            };
209            let _ = self.cache.get_or_compile(&self.engine, &bytes);
210        }
211    }
212
213    #[must_use]
214    pub fn ctx(&self) -> &ScriptCtx {
215        &self.ctx
216    }
217
218    #[must_use]
219    pub fn interrupt_handle(&self) -> InterruptHandle {
220        self.interrupt.clone()
221    }
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    pub fn completions(&self, prefix: &str) -> Vec<String> {
233        let needle = prefix.to_ascii_uppercase();
234        let mut names: Vec<String> = self
235            .symbols
236            .iter()
237            .map(|(name, _)| name.as_str())
238            .filter(|name| {
239                !name.starts_with('$') && !name.starts_with("__") && !name.starts_with("(SETF")
240            })
241            .filter(|name| name.starts_with(&needle))
242            .map(str::to_owned)
243            .collect();
244        names.sort_unstable();
245        names.dedup();
246        names
247    }
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    pub fn epoch_bumper(&self) -> EpochBumper {
256        EpochBumper::new(self.engine.clone())
257    }
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    pub fn cache_size(&self) -> Result<usize, EngineError> {
264        self.cache.len()
265    }
266
267    pub async fn handle_form(&mut self, frame: &str) -> String {
268        let response = match self.evaluate(frame).await {
269            Ok(resp) => resp,
270            Err(err) => err.into_response(),
271        };
272        format_response(&response)
273    }
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    pub async fn handle_request(&mut self, source: &str) -> EvalOutcome {
281        if let Ok(mut buf) = self.output.lock() {
282            buf.clear();
283        }
284        let payload = match self.eval_source(source).await {
285            Ok(value) => ResponsePayload::Value(value),
286            Err(err) => err.into_response().payload,
287        };
288        let output = self
289            .output
290            .lock()
291            .map(|buf| buf.clone())
292            .unwrap_or_default();
293        EvalOutcome { output, payload }
294    }
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    async fn eval_source(&mut self, source: &str) -> Result<Value, EvalFailure> {
303        let id = RequestId::Int(0);
304        let program = Reader::parse(source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
305        let mut exprs = program.exprs;
306        let form = match exprs.len() {
307            0 => return Ok(Value::Nil),
308            1 => exprs.remove(0),
309            _ => {
310                return Err(EvalFailure::Eval(
311                    id,
312                    NomiError::Compile("expected a single form".to_string()),
313                ));
314            }
315        };
316        self.eval_one_form(form).await
317    }
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    pub async fn handle_file(&mut self, path: &str) -> EvalOutcome {
325        if let Ok(mut buf) = self.output.lock() {
326            buf.clear();
327        }
328        let payload = match self.load_path(path).await {
329            Ok(summary) => ResponsePayload::Value(Value::String(summary)),
330            Err(err) => err.into_response().payload,
331        };
332        let output = self
333            .output
334            .lock()
335            .map(|buf| buf.clone())
336            .unwrap_or_default();
337        EvalOutcome { output, payload }
338    }
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    fn ack_interrupt(&mut self, observed: u64) {
351        if observed > self.interrupt_ack {
352            self.interrupt_ack = observed;
353        }
354    }
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    fn check_interrupt(&mut self, id: &RequestId) -> Option<EvalFailure> {
361        let observed = self.interrupt.generation();
362        (observed > self.interrupt_ack).then(|| {
363            self.ack_interrupt(observed);
364            EvalFailure::Interrupted(id.clone())
365        })
366    }
367
368    async fn load_path(&mut self, path: &str) -> Result<String, EvalFailure> {
369        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        if let Some(err) = self.check_interrupt(&id) {
374            return Err(err);
375        }
376        let source = std::fs::read_to_string(path).map_err(|err| {
377            EvalFailure::Eval(
378                id.clone(),
379                NomiError::Compile(format!("cannot read {path}: {err}")),
380            )
381        })?;
382        if let Some(err) = self.check_interrupt(&id) {
383            return Err(err);
384        }
385        let program = Reader::parse(&source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
386        let count = program.exprs.len();
387        for form in program.exprs {
388            self.run(&Request {
389                id: id.clone(),
390                form,
391            })
392            .await?;
393        }
394        Ok(format!("loaded {path} ({count} forms)"))
395    }
396
397    /// Evaluates a single already-parsed form (mREPL input). The interrupt
398    /// pre-start check lives in `run`.
399    async fn eval_one_form(&mut self, form: Expr) -> Result<Value, EvalFailure> {
400        self.run(&Request {
401            id: RequestId::Int(0),
402            form,
403        })
404        .await
405    }
406
407    async fn evaluate(&mut self, frame: &str) -> Result<Response, EvalFailure> {
408        let request = parse_request(frame).map_err(EvalFailure::Envelope)?;
409        let value = self.run(&request).await?;
410        Ok(Response {
411            id: request.id,
412            payload: ResponsePayload::Value(value),
413        })
414    }
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    pub fn guarded(ctx: ScriptCtx) -> Result<Self, SessionError> {
427        let mut session = Self::new(ctx)?;
428        session.status_check_after = Some(Instant::now());
429        Ok(session)
430    }
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    async fn check_account(&mut self, id: &RequestId) -> Result<(), EvalFailure> {
435        let Some(due) = self.status_check_after else {
436            return Ok(());
437        };
438        if Instant::now() < due {
439            return Ok(());
440        }
441        // Fails closed: an unreadable directory is not evidence of health.
442        let refusal = server::command::user::account_refusal(self.ctx.user_id)
443            .await
444            .unwrap_or(Some(server::command::user::Refusal::Quarantined));
445
446        match refusal {
447            None => {
448                self.status_check_after = Some(Instant::now() + STATUS_RECHECK);
449                Ok(())
450            }
451            Some(reason) => Err(EvalFailure::Refused(id.clone(), reason)),
452        }
453    }
454
455    async fn run(&mut self, request: &Request) -> Result<Value, EvalFailure> {
456        debug!(user_id = %self.ctx.user_id, "evaluating form");
457        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        if let Some(err) = self.check_interrupt(&request.id) {
461            return Err(err);
462        }
463        let program = Program::new(vec![request.form.clone()]);
464        let (bytes, result_ty) = self
465            .compiler
466            .compile_eval_with_type(&program, &mut self.symbols)
467            .map_err(|err| EvalFailure::Eval(request.id.clone(), err))?;
468        let module = self
469            .cache
470            .get_or_compile(&self.engine, &bytes)
471            .map_err(|err| EvalFailure::Engine(request.id.clone(), err))?;
472
473        let mut linker: Linker<SessionData> = Linker::new(&self.engine);
474        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        let mut store: Store<SessionData> = Store::new(
482            &self.engine,
483            SessionData::new(self.ctx.clone(), Arc::clone(&self.output)),
484        );
485        store.set_fuel(self.ctx.limits.fuel).map_err(|err| {
486            EvalFailure::Engine(request.id.clone(), EngineError::Fuel(err.to_string()))
487        })?;
488        store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
489
490        let instance = linker
491            .instantiate_async(&mut store, &module)
492            .await
493            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
494        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        if let Some(err) = self.check_interrupt(&request.id) {
503            return Err(err);
504        }
505        let mut results = [Val::AnyRef(None)];
506        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        if call_result.is_err() {
527            let observed = self.interrupt.generation();
528            self.ack_interrupt(observed);
529        }
530        call_result
531            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
532
533        let any: Option<Rooted<AnyRef>> = match &results[0] {
534            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        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        Ok(Value::from(captured))
549    }
550}
551
552enum EvalFailure {
553    Envelope(EnvelopeError),
554    Eval(RequestId, NomiError),
555    Engine(RequestId, EngineError),
556    Interrupted(RequestId),
557    Refused(RequestId, server::command::user::Refusal),
558}
559
560impl EvalFailure {
561    fn into_response(self) -> Response {
562        match self {
563            EvalFailure::Envelope(err) => Response {
564                id: RequestId::Int(0),
565                payload: ResponsePayload::Error {
566                    code: envelope_error_code(&err),
567                    message: err.to_string(),
568                    detail: Some(format!("{err:?}")),
569                },
570            },
571            EvalFailure::Eval(id, err) => Response {
572                id,
573                payload: ResponsePayload::Error {
574                    code: nomi_error_code(&err),
575                    message: err.to_string(),
576                    detail: Some(format!("{err:?}")),
577                },
578            },
579            EvalFailure::Engine(id, err) => Response {
580                id,
581                payload: ResponsePayload::Error {
582                    code: engine_error_code(&err),
583                    message: err.to_string(),
584                    detail: Some(format!("{err:?}")),
585                },
586            },
587            EvalFailure::Interrupted(id) => Response {
588                id,
589                payload: ResponsePayload::Error {
590                    code: ErrorCode::new(ErrorCode::INTERRUPTED),
591                    message: "evaluation interrupted before start".into(),
592                    detail: None,
593                },
594            },
595            EvalFailure::Refused(id, reason) => Response {
596                id,
597                payload: ResponsePayload::Error {
598                    code: ErrorCode::new(ErrorCode::AUTH),
599                    message: match reason {
600                        server::command::user::Refusal::Quarantined => {
601                            "This account is undergoing maintenance and is temporarily unavailable"
602                        }
603                        server::command::user::Refusal::Disabled => "This account is disabled",
604                    }
605                    .into(),
606                    detail: None,
607                },
608            },
609        }
610    }
611}
612
613fn envelope_error_code(err: &EnvelopeError) -> ErrorCode {
614    let symbol = match err {
615        EnvelopeError::Parse(_) => ErrorCode::PARSE,
616        EnvelopeError::NotSingleExpr
617        | EnvelopeError::NotPlist
618        | EnvelopeError::MissingKey(_)
619        | EnvelopeError::InvalidValue(_, _) => ErrorCode::ARGS,
620    };
621    ErrorCode::new(symbol)
622}
623
624fn nomi_error_code(err: &NomiError) -> ErrorCode {
625    let symbol = match err {
626        NomiError::Parse(_) => ErrorCode::PARSE,
627        NomiError::Compile(_) | NomiError::UndefinedSymbol(_) => ErrorCode::COMPILE,
628        NomiError::Runtime(_) => ErrorCode::RUNTIME,
629        NomiError::Type { .. } | NomiError::Arity { .. } => ErrorCode::ARGS,
630    };
631    ErrorCode::new(symbol)
632}
633
634fn engine_error_code(err: &EngineError) -> ErrorCode {
635    match err {
636        EngineError::Compile(_) => ErrorCode::new(ErrorCode::COMPILE),
637        EngineError::OutOfFuel | EngineError::Trap(_) => ErrorCode::new(ErrorCode::RUNTIME),
638        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        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        EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
652    }
653}
654
655#[cfg(test)]
656mod tests;