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;