Lines
95.7 %
Functions
46.36 %
Branches
100 %
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
use nomiscript::{
Compiler, Error as NomiError, Expr, HostFnSpec, Program, Reader, SymbolTable, Value,
};
use scripting::runtime::{
EngineError, EngineOpts, ModuleCache, build_engine, classify_runtime_error, decode_eval_result,
use thiserror::Error;
use tracing::debug;
use wasmtime::{AnyRef, Engine, Linker, Rooted, Store, Val};
use crate::ctx::{EpochBumper, InterruptHandle, ScriptCtx};
use crate::envelope::{
EnvelopeError, ErrorCode, Request, RequestId, Response, ResponsePayload, format_response,
parse_request,
const EPOCH_DEADLINE_TICKS: u64 = 1;
/// Wasmtime Store data type for the rpc eval channel. Carries the
/// per-session user context (`ScriptCtx`) so native fns reach
/// `caller.data().ctx().user_id` directly. The legacy
/// `EvalContext` capture-protocol companion field retired alongside
/// the capture imports in P4 A6.c — `nomi-eval` now returns its
/// final value via the function's `(ref null any)` return slot.
///
/// Native fns are async (`Linker::func_wrap_async`) — they `.await`
/// `server::command::*` futures directly on whatever runtime drives the
/// surrounding `Session::handle_form` call. No owned runtime, no
/// `spawn_blocking`, no thread-local pool concerns.
pub struct SessionData {
ctx: ScriptCtx,
/// Per-request stdout sink. `env.log` (PRINT/DISPLAY/NEWLINE) appends here so
/// the mREPL (`nms --slynk-port`) can surface script output as
/// `:write-string` separately from the final value. Shared with the owning
/// `Session`, which drains it per request. The non-mrepl paths (sshd / the
/// rpc text REPL) ignore it; `env.log` still tees to `tracing` for them.
output: Arc<Mutex<String>>,
/// Render-only draft accumulator. `Some` only on the restricted template
/// render path ([`crate::template`]); the draft natives mutate it and the
/// render entry point reads it back via `store.into_data()`. `None` on the
/// normal eval channel, where the draft natives are not even linked.
draft: Option<RefCell<crate::draft::TransactionDraft>>,
}
impl SessionData {
pub(crate) fn new(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
Self {
ctx,
output,
draft: None,
/// Builds session data with a draft accumulator armed — the render path's
/// constructor. Draft natives require `draft` to be `Some`.
pub(crate) fn for_render(ctx: ScriptCtx, output: Arc<Mutex<String>>) -> Self {
draft: Some(RefCell::new(crate::draft::TransactionDraft::new())),
#[must_use]
pub fn ctx(&self) -> &ScriptCtx {
&self.ctx
/// Mutates the draft accumulator if armed (render path). Returns an error
/// on the normal eval path where no draft is present — a draft native
/// reaching a non-render Store is a wiring bug, surfaced as a trap.
pub fn with_draft<F>(&self, f: F) -> wasmtime::Result<()>
where
F: FnOnce(&mut crate::draft::TransactionDraft),
{
let cell = self
.draft
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("draft native invoked outside render mode"))?;
f(&mut cell.borrow_mut());
Ok(())
/// Consumes the accumulated draft, if any. Called after a render run via
/// `store.into_data()`.
pub fn into_draft(self) -> Option<crate::draft::TransactionDraft> {
self.draft.map(RefCell::into_inner)
/// Appends a script-output line to the per-request buffer. Called by the
/// `env.log` host fn. A poisoned lock is swallowed (output capture is
/// best-effort telemetry, never a reason to fail an eval).
pub fn push_output(&self, msg: &str) {
if let Ok(mut buf) = self.output.lock() {
buf.push_str(msg);
/// Per-channel evaluator. Owns mutable state across forms (defun-defined symbols
/// persist between requests in the same session via `SymbolTable`; the wasm
/// `ModuleCache` reuses compilations of structurally identical forms) and an
/// interrupt handle that cooperatively short-circuits the next form on demand.
/// Eval pipeline: `nomiscript::Compiler::compile_with_mode(Eval)` emits a
/// module exporting `nomi-eval`; the form's final value rides the function's
/// `(ref null any)` return slot and the host walks it into an `EvalValue`
/// via [`decode_eval_result`].
pub struct Session {
engine: Engine,
compiler: Compiler,
cache: ModuleCache,
symbols: SymbolTable,
interrupt: InterruptHandle,
/// Watermark of the highest interrupt generation already attributed to a
/// finished request (see [`Session::check_interrupt`] / [`Session::ack_interrupt`]).
/// A `C-g` counts only while `interrupt.generation() > interrupt_ack`, so one
/// signal aborts exactly one request and can never linger to poison a later
/// form.
interrupt_ack: u64,
/// Shared with each per-request `SessionData` so `env.log` output lands
/// where [`Session::handle_request`] can drain it.
/// A structured eval result: the captured script output plus the typed
/// value/error payload. The SLYNK mREPL maps `output` → `:write-string` and
/// `payload` → `:write-values` / `:evaluation-aborted`. `handle_form` (the text
/// wire path) does not use this — it formats the payload alone.
#[derive(Debug, Clone, PartialEq)]
pub struct EvalOutcome {
pub output: String,
pub payload: ResponsePayload,
#[derive(Debug, Error)]
pub enum SessionError {
#[error("engine init failed: {0}")]
Engine(#[from] EngineError),
impl Session {
pub fn new(ctx: ScriptCtx) -> Result<Self, SessionError> {
let engine = build_engine(EngineOpts::baseline().with_fuel())?;
let host_fns = crate::natives::all_compiler_specs();
let mut symbols = SymbolTable::with_builtins();
symbols.register_host_fns(&host_fns);
// Host-dependent prelude (ADR-0029): loaded only here, after the RPC
// host fns it calls are registered. The universal prelude already rode
// in via `with_builtins`.
crate::host_prelude::load(&mut symbols);
let mut session = Self {
engine,
compiler: Compiler::with_host_fns(host_fns.clone()),
cache: ModuleCache::new(),
symbols,
interrupt: InterruptHandle::new(),
interrupt_ack: 0,
output: Arc::new(Mutex::new(String::new())),
// Phase 4: pre-warm the per-Session ModuleCache with the
// bare-call wasm for every zero-arg host fn that has a
// return type. The cache is keyed by full bytecode bytes so
// when a request like `(:id N :form (rpc-protocol-version))`
// lands, `cache.get_or_compile` finds the pre-compiled
// module and `handle_form`'s critical path is
// instantiate-only. Composed forms / arg-bearing calls /
// unseen forms still hit the cold compile path.
session.warm_bare_call_cache(&host_fns);
Ok(session)
/// Pre-compile and cache the bare-call wasm for every zero-arg
/// host fn whose result type is non-None. Skips no-arg fns with
/// `result: None` (their bare call would error at value position)
/// and skips arg-bearing fns (their wasm embeds the literal
/// args, so pre-warming would be wrong-keyed).
/// Failures are silently ignored — pre-warming is an optimization,
/// not a correctness step. If a spec fails to compile here, the
/// runtime path will surface the same error when the form lands.
fn warm_bare_call_cache(&mut self, host_fns: &[HostFnSpec]) {
for spec in host_fns {
if !spec.params.is_empty() || spec.result.is_none() {
continue;
let form = Expr::List(vec![Expr::Symbol(spec.nomi_name.clone())]);
let program = Program::new(vec![form]);
let Ok((bytes, _ty)) = self
.compiler
.compile_eval_with_type(&program, &mut self.symbols)
else {
let _ = self.cache.get_or_compile(&self.engine, &bytes);
pub fn interrupt_handle(&self) -> InterruptHandle {
self.interrupt.clone()
/// Symbol names completing `prefix`, sorted and deduplicated, for the SLYNK
/// completion rex (`M-x sly-complete-symbol` / mREPL TAB). The reader folds
/// symbols with `make_ascii_uppercase` at read time, so the match uppercases
/// the prefix the SAME way (ASCII-only — matching the reader, not full
/// Unicode) and returns the canonical name (it re-reads identically). Skips
/// `(SETF …)` setf-place names and compiler-internal `$…` / `__…` symbols —
/// none are head symbols a user types. An empty `prefix` lists every
/// completable symbol.
pub fn completions(&self, prefix: &str) -> Vec<String> {
let needle = prefix.to_ascii_uppercase();
let mut names: Vec<String> = self
.symbols
.iter()
.map(|(name, _)| name.as_str())
.filter(|name| {
!name.starts_with('$') && !name.starts_with("__") && !name.starts_with("(SETF")
})
.filter(|name| name.starts_with(&needle))
.map(str::to_owned)
.collect();
names.sort_unstable();
names.dedup();
names
/// Cooperative cancel handle for an in-flight `nomi-eval`. Clone
/// and hand to the transport layer: when the client sends a
/// cancel signal (e.g. emacs `C-g`), call `.bump()` and the
/// awaiting evaluation traps with `EngineError::EpochInterrupt`,
/// surfacing on the wire as `(:error (:code interrupted ...))`.
pub fn epoch_bumper(&self) -> EpochBumper {
EpochBumper::new(self.engine.clone())
/// Number of pre-compiled wasm modules currently cached.
/// Used by tests to confirm the phase-4 pre-warm step ran and
/// that subsequent `handle_form` calls of cached forms don't
/// trigger a cold compile.
pub fn cache_size(&self) -> Result<usize, EngineError> {
self.cache.len()
pub async fn handle_form(&mut self, frame: &str) -> String {
let response = match self.evaluate(frame).await {
Ok(resp) => resp,
Err(err) => err.into_response(),
format_response(&response)
/// Structured eval of a single bare `form` (not an envelope frame): clears
/// the output buffer, runs the form, and returns the captured script output
/// alongside the typed value/error payload. The SLYNK mREPL uses this so it
/// can render `:write-string` (output) and `:write-values` /
/// `:evaluation-aborted` (payload) separately. `handle_form` is unchanged.
pub async fn handle_request(&mut self, source: &str) -> EvalOutcome {
buf.clear();
let payload = match self.eval_source(source).await {
Ok(value) => ResponsePayload::Value(value),
Err(err) => err.into_response().payload,
let output = self
.output
.lock()
.map(|buf| buf.clone())
.unwrap_or_default();
EvalOutcome { output, payload }
/// Parses `source` to a single top-level form and evaluates it. Unlike
/// `handle_form`, the source is parsed to an AST directly (NOT interpolated
/// into an envelope string), so plist-shaped input can't hijack the
/// envelope: `1 :form (+ 2 3)` is rejected as "more than one form", not
/// silently re-read as a `(:id 1 :form …)` plist. The fixed `RequestId::Int(0)`
/// is unused downstream — the SLYNK layer tracks its own channel ids.
async fn eval_source(&mut self, source: &str) -> Result<Value, EvalFailure> {
let id = RequestId::Int(0);
let program = Reader::parse(source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
let mut exprs = program.exprs;
let form = match exprs.len() {
0 => return Ok(Value::Nil),
1 => exprs.remove(0),
_ => {
return Err(EvalFailure::Eval(
id,
NomiError::Compile("expected a single form".to_string()),
));
self.eval_one_form(form).await
/// Reads `path`, evaluates every top-level form in source order (state —
/// `defun`s etc. — accumulates across forms, like the sshd channel), and
/// returns a short summary. Powers SLY's `M-x sly-load-file`
/// (`slynk:load-file`). Captured output across all forms is returned for the
/// caller to surface; a failing form aborts the load at that point.
pub async fn handle_file(&mut self, path: &str) -> EvalOutcome {
let payload = match self.load_path(path).await {
Ok(summary) => ResponsePayload::Value(Value::String(summary)),
/// Attributes all interrupts up to `observed` to the current request, so
/// they aren't counted again. Crucially the caller passes the SAME
/// generation it used to decide an interrupt is pending — never a fresh
/// reload — so a `C-g` that lands strictly after that observation point is
/// NOT folded into this request; it stays pending for the next form. The
/// guard keeps the watermark monotonic.
/// Coalescing is intended: several `C-g`s observed together (generation
/// jumped by >1) cancel the one in-flight form, they do not pre-arm
/// cancellation of distinct future forms — that is the REPL `C-g` contract.
fn ack_interrupt(&mut self, observed: u64) {
if observed > self.interrupt_ack {
self.interrupt_ack = observed;
/// If an interrupt is pending, ack it (using the one generation snapshot
/// that decided it) and yield the interrupted error; otherwise `None`. The
/// single checkpoint used everywhere a form can abort on a `C-g` before the
/// Wasm call.
fn check_interrupt(&mut self, id: &RequestId) -> Option<EvalFailure> {
let observed = self.interrupt.generation();
(observed > self.interrupt_ack).then(|| {
self.ack_interrupt(observed);
EvalFailure::Interrupted(id.clone())
async fn load_path(&mut self, path: &str) -> Result<String, EvalFailure> {
// The synchronous file read + parse aren't covered by `run`'s checks, so
// guard them explicitly; a `C-g` during a later form is caught by that
// form's `run`, and the failing form aborts the whole load via `?`.
if let Some(err) = self.check_interrupt(&id) {
return Err(err);
let source = std::fs::read_to_string(path).map_err(|err| {
EvalFailure::Eval(
id.clone(),
NomiError::Compile(format!("cannot read {path}: {err}")),
)
})?;
let program = Reader::parse(&source).map_err(|err| EvalFailure::Eval(id.clone(), err))?;
let count = program.exprs.len();
for form in program.exprs {
self.run(&Request {
id: id.clone(),
form,
.await?;
Ok(format!("loaded {path} ({count} forms)"))
/// Evaluates a single already-parsed form (mREPL input). The interrupt
/// pre-start check lives in `run`.
async fn eval_one_form(&mut self, form: Expr) -> Result<Value, EvalFailure> {
id: RequestId::Int(0),
.await
async fn evaluate(&mut self, frame: &str) -> Result<Response, EvalFailure> {
let request = parse_request(frame).map_err(EvalFailure::Envelope)?;
let value = self.run(&request).await?;
Ok(Response {
id: request.id,
payload: ResponsePayload::Value(value),
async fn run(&mut self, request: &Request) -> Result<Value, EvalFailure> {
debug!(user_id = %self.ctx.user_id, "evaluating form");
// A `C-g` that landed before this form started (pre-armed, or during an
// earlier form of a load) aborts here.
if let Some(err) = self.check_interrupt(&request.id) {
let program = Program::new(vec![request.form.clone()]);
let (bytes, result_ty) = self
.map_err(|err| EvalFailure::Eval(request.id.clone(), err))?;
let module = self
.cache
.get_or_compile(&self.engine, &bytes)
.map_err(|err| EvalFailure::Engine(request.id.clone(), err))?;
let mut linker: Linker<SessionData> = Linker::new(&self.engine);
crate::natives::link(&mut linker).map_err(|err| {
EvalFailure::Engine(
request.id.clone(),
EngineError::Instantiate(err.to_string()),
let mut store: Store<SessionData> = Store::new(
&self.engine,
SessionData::new(self.ctx.clone(), Arc::clone(&self.output)),
);
store.set_fuel(self.ctx.limits.fuel).map_err(|err| {
EvalFailure::Engine(request.id.clone(), EngineError::Fuel(err.to_string()))
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let instance = linker
.instantiate_async(&mut store, &module)
.map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
let func = instance.get_func(&mut store, "nomi-eval").ok_or_else(|| {
EngineError::MissingExport("nomi-eval".into()),
// An interrupt that arrived during compile/link (the epoch bump only
// cancels a running call) — abort before entering Wasm.
let mut results = [Val::AnyRef(None)];
let call_result = func.call_async(&mut store, &[], &mut results).await;
// The reader bumps the epoch AND signals the interrupt together on
// `(:emacs-interrupt)`. If a C-g arrived during THIS call it cancelled it
// (epoch trap → `call_result` is `Err`); attribute it to this request so
// it can't also abort the next one. We ack only on the error path, so a
// C-g just after a clean success stays pending for the next form. This
// covers every terminal exit uniformly (epoch cancel, the
// out-of-fuel/runtime-trap-wins-the-race case) with no per-exit cleanup.
//
// Attribution cutoff (deliberate): the ack snapshot is taken AFTER the
// call returns, so a C-g landing in the sub-µs window between the call
// returning `Err` and this load is attributed to THIS (failing) form,
// not the next one. That is correct REPL semantics — at that instant the
// failing form is still the in-flight request (its error hasn't reached
// the client), so the user can't yet have meant the C-g for a later
// form. A C-g pressed after the error is surfaced necessarily arrives
// after `run` returns, advancing the generation again, and the next
// form's pre-start `check_interrupt` catches it. The exact instant of
// call return is unobservable, so this boundary is irreducible, not a
// lost interrupt.
if call_result.is_err() {
call_result
let any: Option<Rooted<AnyRef>> = match &results[0] {
Val::AnyRef(a) => *a,
return Err(EvalFailure::Engine(
EngineError::Trap("nomi-eval did not return anyref".into()),
let captured = decode_eval_result(&mut store, any, result_ty).map_err(|err| {
EngineError::Trap(format!("decoding nomi-eval result: {err}")),
Ok(Value::from(captured))
enum EvalFailure {
Envelope(EnvelopeError),
Eval(RequestId, NomiError),
Engine(RequestId, EngineError),
Interrupted(RequestId),
impl EvalFailure {
fn into_response(self) -> Response {
match self {
EvalFailure::Envelope(err) => Response {
payload: ResponsePayload::Error {
code: envelope_error_code(&err),
message: err.to_string(),
detail: Some(format!("{err:?}")),
},
EvalFailure::Eval(id, err) => Response {
code: nomi_error_code(&err),
EvalFailure::Engine(id, err) => Response {
code: engine_error_code(&err),
EvalFailure::Interrupted(id) => Response {
code: ErrorCode::new(ErrorCode::INTERRUPTED),
message: "evaluation interrupted before start".into(),
detail: None,
fn envelope_error_code(err: &EnvelopeError) -> ErrorCode {
let symbol = match err {
EnvelopeError::Parse(_) => ErrorCode::PARSE,
EnvelopeError::NotSingleExpr
| EnvelopeError::NotPlist
| EnvelopeError::MissingKey(_)
| EnvelopeError::InvalidValue(_, _) => ErrorCode::ARGS,
ErrorCode::new(symbol)
fn nomi_error_code(err: &NomiError) -> ErrorCode {
NomiError::Parse(_) => ErrorCode::PARSE,
NomiError::Compile(_) | NomiError::UndefinedSymbol(_) => ErrorCode::COMPILE,
NomiError::Runtime(_) => ErrorCode::RUNTIME,
NomiError::Type { .. } | NomiError::Arity { .. } => ErrorCode::ARGS,
fn engine_error_code(err: &EngineError) -> ErrorCode {
match err {
EngineError::Compile(_) => ErrorCode::new(ErrorCode::COMPILE),
EngineError::OutOfFuel | EngineError::Trap(_) => ErrorCode::new(ErrorCode::RUNTIME),
EngineError::EpochInterrupt => ErrorCode::new(ErrorCode::INTERRUPTED),
EngineError::Instantiate(_) | EngineError::MissingExport(_) => {
ErrorCode::new(ErrorCode::SERVER)
EngineError::Fuel(_) | EngineError::Config(_) | EngineError::CachePoisoned => {
EngineError::NoConversion(_) => ErrorCode::new(ErrorCode::NO_CONVERSION),
// Commodity mismatch now arrives as a `ScriptRaised` carrying the
// reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026): it `throw`s
// `$nomi_error` in-guest and the boundary wrapper bridges the uncaught
// throw to `__nomi_raise`. The code flows through verbatim like any
// script raise; the wire `:code` is the upper-cased symbol form.
EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
#[cfg(test)]
mod tests {
use super::*;
use nomiscript::{Fraction, Reader};
async fn handle_form_smoke(frame: &str) -> String {
let ctx = ScriptCtx::new(uuid::Uuid::nil());
let mut session = Session::new(ctx).expect("Session::new");
session.handle_form(frame).await
fn parse_to_value(input: &str) -> Result<Value, NomiError> {
let program = Reader::parse(input)?;
nomiscript::eval_program(&mut symbols, &program)
#[tokio::test]
async fn evaluates_arithmetic_and_returns_value_envelope() {
let response = handle_form_smoke("(:id 1 :form (+ 1 2))").await;
assert_eq!(response, "(:id 1 :value 3)");
async fn evaluates_nested_arithmetic() {
let response = handle_form_smoke("(:id 5 :form (* (+ 1 2) (- 10 4)))").await;
assert_eq!(response, "(:id 5 :value 18)");
async fn print_in_eval_mode_does_not_panic() {
// Regression: PRINT / DISPLAY / NEWLINE / DEBUG lower to `env.log`,
// which was script-mode-only. In eval mode the missing func index used
// to SIGABRT the compiler (`registry.rs` `HashMap[key]`). The eval-mode
// `log` import + the rpc `env.log` host fn now make it compile + run.
let response = handle_form_smoke("(:id 1 :form (print \"hi\"))").await;
assert!(response.contains(":id 1"), "got: {response}");
assert!(!response.contains(":code"), "must not error: {response}");
async fn dolist_with_print_in_eval_mode_runs() {
// The exact shape from the Metro script that first surfaced the panic.
let response = handle_form_smoke("(:id 2 :form (dolist (x (list 1 2 3)) (print x)))").await;
assert!(response.contains(":id 2"), "got: {response}");
async fn handle_request_captures_output_and_value() {
// `(print "hi")` writes "hi" to the per-request buffer (via env.log) and
// its own return value is nil; the structured outcome must carry the
// captured text in `output` AND the value payload — the SLYNK mREPL
// renders these as `:write-string` + `:write-values` respectively.
let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
let outcome = session.handle_request("(print \"hi\")").await;
assert!(
outcome.output.contains("hi"),
"captured output should contain the printed text, got: {:?}",
outcome.output
matches!(outcome.payload, ResponsePayload::Value(_)),
"payload should be a Value, got: {:?}",
outcome.payload
async fn handle_request_value_only_has_empty_output() {
let outcome = session.handle_request("(+ 1 2)").await;
assert!(outcome.output.is_empty(), "got: {:?}", outcome.output);
assert_eq!(
outcome.payload,
ResponsePayload::Value(Value::Number(Fraction::from_integer(3)))
async fn handle_request_rejects_plist_shaped_injection() {
// Adversarial review: the source must be parsed as a standalone AST, not
// interpolated into a `(:id 0 :form …)` envelope string — otherwise
// `1 :form (+ 2 3)` would re-read as a plist and eval to `1`. It must
// instead error (more than one top-level form), never silently return 1.
let outcome = session.handle_request("1 :form (+ 2 3)").await;
matches!(outcome.payload, ResponsePayload::Error { .. }),
"plist-shaped input must error, got: {:?}",
async fn handle_request_interrupt_latch_aborts_before_eval() {
// An interrupt latched before the request (the SLYNK reader arms it on
// `(:emacs-interrupt)`) must abort the eval at the pre-start check, even
// for a form that would otherwise compile fine.
session.interrupt_handle().interrupt();
match outcome.payload {
ResponsePayload::Error { code, .. } => {
assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED);
other => panic!("expected interrupted error, got: {other:?}"),
async fn handle_file_evaluates_all_forms_and_persists_state() {
// A file with a defun + a call to it: state accumulates across forms,
// and the load returns a summary value (not the last form's value).
let dir = std::env::temp_dir();
let path = dir.join(format!("nms_load_test_{}.nms", std::process::id()));
std::fs::write(&path, "(defun dbl (x) (* x 2))\n(dbl 21)\n").unwrap();
let outcome = session.handle_file(path.to_str().unwrap()).await;
std::fs::remove_file(&path).ok();
ResponsePayload::Value(Value::String(s)) => {
assert!(s.contains("loaded"), "summary: {s}");
assert!(s.contains("2 forms"), "summary: {s}");
other => panic!("expected a load summary string, got: {other:?}"),
async fn handle_file_missing_path_errors() {
let outcome = session.handle_file("/no/such/nms/file.nms").await;
"got: {:?}",
async fn handle_file_aborts_on_a_bad_form() {
let path = dir.join(format!("nms_load_bad_{}.nms", std::process::id()));
std::fs::write(&path, "(+ 1 2)\n(undefined-symbol-here)\n").unwrap();
"a bad form must abort the load, got: {:?}",
async fn handle_file_honours_interrupt_armed_before_load() {
// A `C-g` that lands before/during the synchronous read+parse must abort
// the load (the pre-read latch check), not be ignored until the first
// form reaches the Wasm call.
let path = dir.join(format!("nms_load_intr_{}.nms", std::process::id()));
std::fs::write(&path, "(+ 1 2)\n(+ 3 4)\n").unwrap();
assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED, "got: {code:?}");
other => panic!("interrupt should abort the load, got: {other:?}"),
async fn handle_request_surfaces_error_payload() {
let outcome = session.handle_request("does-not-exist").await;
"payload should be an Error, got: {:?}",
async fn handle_request_clears_output_between_calls() {
// The buffer must not leak across requests: a print then a pure value.
let _ = session.handle_request("(print \"first\")").await;
let second = session.handle_request("(+ 1 1)").await;
second.output.is_empty(),
"output leaked from prior request: {:?}",
second.output
async fn returns_value_for_literal_form() {
let response = handle_form_smoke("(:id 9 :form 42)").await;
assert_eq!(response, "(:id 9 :value 42)");
async fn returns_value_for_string_literal() {
let response = handle_form_smoke("(:id 9 :form \"hello\")").await;
assert_eq!(response, "(:id 9 :value \"hello\")");
#[test]
fn round_trips_bytes_through_eval() {
let value = parse_to_value("'#u8(1 2 3)").unwrap();
assert_eq!(value, Value::Bytes(vec![1, 2, 3]));
async fn bad_envelope_emits_envelope_error() {
let response = handle_form_smoke("(:form (+ 1 2))").await;
assert!(response.contains(":code args"));
assert!(response.contains(":id 0"));
async fn malformed_envelope_emits_parse_error() {
let response = handle_form_smoke("(((((").await;
assert!(response.contains(":code parse"));
async fn undefined_symbol_emits_compile_error() {
let response = handle_form_smoke("(:id 7 :form does-not-exist)").await;
assert!(response.contains(":id 7"));
assert!(response.contains(":code compile"));
async fn user_function_arity_violation_emits_args_error() {
let _ = session
.handle_form("(:id 1 :form (defun id-fn (x) x))")
.await;
let response = session.handle_form("(:id 2 :form (id-fn))").await;
assert!(response.contains(":id 2"));
fn completions_match_case_insensitively_and_skip_internal() {
let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
// Lower-case input matches the upper-case folded symbol; the canonical
// upper-case name is returned.
let defuns = session.completions("def");
assert!(defuns.contains(&"DEFUN".to_string()), "got: {defuns:?}");
defuns.iter().all(|n| n.starts_with("DEF")),
"got: {defuns:?}"
// Sorted; no internal `$`/`__` or `(SETF …)` place names.
let all = session.completions("");
assert!(all.windows(2).all(|w| w[0] <= w[1]), "must be sorted");
all.iter()
.all(|n| !n.starts_with('$') && !n.starts_with("__") && !n.starts_with("(SETF")),
"internal/setf symbols must be filtered: {all:?}"
async fn completions_include_a_user_defined_symbol() {
.handle_form("(:id 1 :form (defun my-helper (x) x))")
// The defun's name is folded to upper-case; a lower-case prefix finds it.
let hits = session.completions("my-");
assert!(hits.contains(&"MY-HELPER".to_string()), "got: {hits:?}");
fn completions_unknown_prefix_is_empty() {
assert!(session.completions("zzz-no-such-symbol-").is_empty());
async fn interrupt_before_form_short_circuits_with_interrupted() {
let handle = session.interrupt_handle();
handle.interrupt();
let response = session.handle_form("(:id 11 :form (+ 1 2))").await;
assert!(response.contains(":id 11"));
assert!(response.contains(":code interrupted"));
async fn coalesced_interrupts_abort_one_form_each_in_order() {
// Two `C-g`s observed together before a form cancel THAT form and are
// coalesced (they do not pre-arm cancellation of a later one); a fresh,
// distinct `C-g` later still cancels the next form. This pins the REPL
// contract against both a "lost interrupt" and an "over-eager next-form
// abort" regression.
handle.interrupt(); // two presses before the form
let first = session.handle_form("(:id 60 :form (+ 1 2))").await;
first.contains(":code interrupted"),
"first form must abort: {first}"
// Both presses were coalesced into the one abort — the next form is clean.
let second = session.handle_form("(:id 61 :form (+ 1 2))").await;
second, "(:id 61 :value 3)",
"next form coalesced-poisoned: {second}"
// A fresh, distinct press still aborts the following form.
let third = session.handle_form("(:id 62 :form (+ 1 2))").await;
third.contains(":code interrupted"),
"a distinct later interrupt must still abort: {third}"
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inflight_interrupt_does_not_poison_next_form() {
// The SLYNK reader arms BOTH the epoch bump and the interrupt signal on
// `(:emacs-interrupt)` (mod.rs). When that lands mid-eval the epoch trap
// cancels the running call; that interrupt generation must then be acked
// so the SAME `C-g` can't also abort the next request.
let bumper = session.epoch_bumper();
let interrupt = session.interrupt_handle();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
bumper.bump();
interrupt.interrupt();
});
let cancelled = session
.handle_form("(:id 30 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
cancel_task.await.unwrap();
cancelled.contains(":code interrupted") || cancelled.contains(":code runtime"),
"in-flight eval should have been cancelled: {cancelled}"
// The next form must evaluate normally — the acked interrupt must not abort it.
let next = session.handle_form("(:id 31 :form (+ 1 2))").await;
assert_eq!(next, "(:id 31 :value 3)", "next form was poisoned: {next}");
async fn non_interrupt_failure_still_consumes_a_concurrent_interrupt() {
// Race: an interrupt is signalled WHILE a form is in flight, but the
// form loses to its own terminal error (out-of-fuel) before any epoch
// bump could win. The in-flight form is finished, so the interrupt must
// be acked on the error path too — otherwise it poisons the next form.
let latch_task = tokio::spawn(async move {
// Latch only (no epoch bump): the form trips out-of-fuel on its own.
let failed = session
.handle_form("(:id 50 :form (do ((i 0 (+ i 1))) ((>= i 100000000) i)))")
latch_task.await.unwrap();
failed.contains(":code runtime") || failed.contains(":code interrupted"),
"in-flight form should fail terminally: {failed}"
let next = session.handle_form("(:id 51 :form (+ 1 2))").await;
assert_eq!(next, "(:id 51 :value 3)", "next form was poisoned: {next}");
async fn interrupt_after_clean_eval_aborts_next_form() {
// The mirror of the above: after a form completes NORMALLY, an interrupt
// armed before the next form must still abort it. The in-flight cleanup
// only consumes the latch on an actual epoch-interrupt trap, so a `C-g`
// that lands post-completion is NOT swallowed.
let clean = session.handle_form("(:id 40 :form (+ 1 2))").await;
assert_eq!(clean, "(:id 40 :value 3)");
let interrupted = session.handle_form("(:id 41 :form (+ 4 5))").await;
interrupted.contains(":code interrupted"),
"post-completion interrupt must abort the next form: {interrupted}"
async fn epoch_bumper_cancels_inflight_long_eval() {
// Concurrent cancel scenario: one task drives a CPU-bound eval,
// another task bumps the epoch shortly after. Verifies the
// engine clone really does share state across threads so emacs
// C-g can land mid-eval.
let bump_task = tokio::spawn(async move {
// Tight loop that'd otherwise exhaust default fuel before
// returning. Either fuel or epoch will fire — the assert below
// tolerates both possibilities by checking the response is an
// error envelope, but in practice the 20ms bump arrives first.
let response = session
.handle_form("(:id 22 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
bump_task.await.unwrap();
assert!(response.contains(":id 22"), "{response}");
response.contains(":code interrupted") || response.contains(":code runtime"),
"{response}"
fn host_prelude_helper_is_loaded_and_compiles() {
// ADR-0029 host-dependent prelude: split:list-for-transaction is loaded
// on the Session path (after register_host_fns) and is callable. We
// compile a form referencing it — proving load + name resolution + the
// qualified native dispatch wire up — WITHOUT running it (it bottoms out
// in the DB-backed list-splits-by-transaction native; execution is
// covered by the db-gated integration test).
session.symbols.contains("SPLIT:LIST-FOR-TRANSACTION"),
"host prelude helper not registered"
// Compile (not run) a real call: lowering emits the qualified-name
// dispatch + the native import, proving resolution wires up. Host fns
// never execute at compile time, so no DB is touched.
let program =
Reader::parse("(split:list-for-transaction (car (list-transactions)))").expect("parse");
if let Err(e) = session
.compile_eval_with_type(&program, &mut session.symbols)
panic!("host prelude helper failed to compile: {e:?}");
async fn car_of_quoted_constant_list_compiles_on_eval_path() {
// Regression: CAR/CDR of a quoted CONSTANT list folds on the codegen
// path but used to trap on the eval-with-type (Session) path because
// the stack handler called compile_for_stack on the quoted arg, which
// has no Quote arm. The stack handlers now const-fold first.
let response = handle_form_smoke("(:id 1 :form (car '(1 2 3)))").await;
assert_eq!(response, "(:id 1 :value 1)");
async fn car_of_quoted_heterogeneous_list_compiles_on_eval_path() {
// Mixed number + string quoted list — the element is extracted by fold.
let response = handle_form_smoke("(:id 1 :form (car '(7 \"x\")))").await;
assert_eq!(response, "(:id 1 :value 7)");
async fn car_of_cdr_of_quoted_constant_compiles_on_eval_path() {
// CDR folds to the quoted tail, then CAR extracts its head — exercises
// the cdr fold-first path feeding car.
let response = handle_form_smoke("(:id 1 :form (car (cdr '(1 2 3))))").await;
assert_eq!(response, "(:id 1 :value 2)");
async fn cdr_of_quoted_constant_renders_tail_on_eval_path() {
// The bare-CDR case (no enclosing CAR): folds to a quoted tail and
// renders to its printed form rather than trapping.
handle_form_smoke("(:id 1 :form (cdr '(1 2 3)))").await,
"(:id 1 :value \"(2 3)\")"
handle_form_smoke("(:id 1 :form (cdr '(1)))").await,
"(:id 1 :value NIL)"
async fn car_of_quoted_compound_and_symbol_heads_render_as_data() {
// A compound or symbol head is quoted DATA, not code — it renders to
// its printed form (not resolved as a call / variable).
handle_form_smoke("(:id 1 :form (car '((1 2) 3)))").await,
"(:id 1 :value \"(1 2)\")"
handle_form_smoke("(:id 1 :form (car '(x y)))").await,
"(:id 1 :value \"X\")"
async fn reverse_of_constant_list_renders_on_eval_path() {
// Regression (same class as CAR/CDR): REVERSE of a constant or
// runtime-builder list folded but the stack handler rejected the
// non-runtime-pair result on the eval-with-type path. Now it renders
// the reversed datum on both surfaces.
handle_form_smoke("(:id 1 :form (reverse '(1 2 3)))").await,
"(:id 1 :value \"(3 2 1)\")"
handle_form_smoke("(:id 1 :form (reverse (list 1 2 3)))").await,
// Composition still folds through to the element.
handle_form_smoke("(:id 1 :form (car (reverse '(1 2 3))))").await,
"(:id 1 :value 3)"
async fn cons_onto_constant_list_renders_on_eval_path() {
// Regression: CONS with a constant / runtime-builder list cdr trapped
// in push_pair_cdr on the eval-with-type path. A fully-constant cons
// now folds and renders the list datum; a dotted pair renders too.
handle_form_smoke("(:id 1 :form (cons 0 '(1 2 3)))").await,
"(:id 1 :value \"(0 1 2 3)\")"
handle_form_smoke("(:id 1 :form (cons 0 (list 1 2 3)))").await,
handle_form_smoke("(:id 1 :form (cons 1 2))").await,
"(:id 1 :value \"(1 . 2)\")"
async fn append_of_constant_lists_renders_on_eval_path() {
// Regression: all-constant APPEND folds to a quoted list; the stack
// handler used to force it through runtime materialization (which can't
// represent symbols) instead of rendering the folded datum.
handle_form_smoke("(:id 1 :form (append '(1 2) '(3)))").await,
"(:id 1 :value \"(1 2 3)\")"
handle_form_smoke("(:id 1 :form (append '(a b) '(c)))").await,
"(:id 1 :value \"(A B C)\")"
async fn universal_prelude_helper_runs_end_to_end() {
// The universal prelude is loaded on the Session path too; a math:*
// helper executes through wasm and returns its value. No DB.
let response = handle_form_smoke("(:id 9 :form (math:square 9))").await;
assert_eq!(response, "(:id 9 :value 81)");
async fn pp_form_at_value_position_returns_string() {
// Exercises compile_pp_for_stack — the path nms / emacs see
// when `(pp 42)` is the request form.
let resp = handle_form_smoke("(:id 7 :form (pp 42))").await;
assert!(resp.contains(":id 7"), "{resp}");
assert!(resp.contains("\"42\""), "{resp}");
async fn describe_form_at_value_position_returns_doc() {
// compile_describe_for_stack — same shape.
let resp = handle_form_smoke("(:id 8 :form (describe '+))").await;
assert!(resp.contains(":id 8"), "{resp}");
assert!(!resp.contains(":error"), "{resp}");
async fn apropos_form_at_value_position_returns_list() {
let resp = handle_form_smoke("(:id 9 :form (apropos \"entity\"))").await;
assert!(resp.contains(":id 9"), "{resp}");
async fn deftest_form_at_value_position_returns_quoted_name() {
let resp = handle_form_smoke("(:id 10 :form (deftest sanity (assert-equal 1 1)))").await;
assert!(resp.contains(":id 10"), "{resp}");
async fn assert_equal_pass_form_at_value_position() {
let resp = handle_form_smoke("(:id 11 :form (assert-equal 2 2))").await;
assert!(resp.contains(":id 11"), "{resp}");
async fn assert_equal_fail_surfaces_as_error() {
// assert_equal returns Err on mismatch; Session maps to
// :code compile error envelope (it's a NomiError::Compile).
let resp = handle_form_smoke("(:id 12 :form (assert-equal 1 2))").await;
assert!(resp.contains(":id 12"), "{resp}");
assert!(resp.contains(":error"), "{resp}");
async fn coverage_dump_lists_called_natives() {
.handle_form("(:id 1 :form (rpc-protocol-version))")
let dump = session.handle_form("(:id 2 :form (coverage-dump))").await;
assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
assert!(dump.contains(":id 2"), "{dump}");
async fn coverage_dump_reports_pre_warmed_natives() {
// Session::new pre-compiles every zero-arg native fn (phase 4
// fast-path stubs), so coverage-dump is non-empty even before
// a user form lands. This is the desired semantic: it
// reflects compile-time reference counts, including pre-warm
// compilations, which is what the parity contract gates on.
let dump = session.handle_form("(:id 1 :form (coverage-dump))").await;
assert!(dump.contains(":id 1"), "{dump}");
async fn interrupt_does_not_persist_across_forms() {
let _ = session.handle_form("(:id 11 :form (+ 1 2))").await;
let response = session.handle_form("(:id 12 :form (+ 1 2))").await;
assert_eq!(response, "(:id 12 :value 3)");
async fn session_state_persists_across_forms() {
let defun = session
.handle_form("(:id 1 :form (defun double (x) (* 2 x)))")
assert!(defun.contains(":id 1"));
let call = session.handle_form("(:id 2 :form (double 21))").await;
assert_eq!(call, "(:id 2 :value 42)");
async fn fraction_results_format_canonically() {
// A fractional (Scalar) literal renders canonically as `n/d`. (Integer
// `(/ 1 4)` is now Index division → 0 per ADR-0028; the canonical
// fraction idiom is the `1/4` Scalar literal.)
let response = handle_form_smoke("(:id 3 :form 1/4)").await;
assert_eq!(response, "(:id 3 :value 1/4)");
fn nomi_runtime_value_carries_through() {
let value = parse_to_value("(+ 0.5 0.25)").unwrap();
assert_eq!(value, Value::Number(Fraction::new(3, 4)));
async fn calls_meta_native_from_nomiscript_source() {
let response = handle_form_smoke("(:id 1 :form (rpc-protocol-version))").await;
let expected_version = crate::natives::meta::PROTOCOL_VERSION;
assert_eq!(response, format!("(:id 1 :value {expected_version})"));
async fn calls_server_get_version_from_nomiscript_source() {
let response = handle_form_smoke("(:id 1 :form (get-version))").await;
// GIT_HASH is baked at server crate build time via env!. We don't
// assert its exact value (changes per build) — just that the
// envelope round-trips a non-empty :value string.
response.starts_with("(:id 1 :value \""),
"expected string response, got: {response}"
assert!(response.ends_with("\")"));
async fn calls_server_get_build_date_from_nomiscript_source() {
let response = handle_form_smoke("(:id 2 :form (get-build-date))").await;
response.starts_with("(:id 2 :value \""),
async fn cons_list_surfaces_as_printable_string() {
// First WasmGC sub-slice: eval-mode `(cons ...)` chains now
// capture through pending_string instead of erroring at compile
// time. The result is a textual `(1 2 3)` value the emacs client
// can (read) back into a real list. Heterogeneous car types ride
// a follow-up slice once Pair/Vector/Closure/Struct share a
// tagged union — today's cons cell stores i32 payloads only.
let response = handle_form_smoke("(:id 12 :form (cons 1 (cons 2 (cons 3 nil))))").await;
response.contains(":value \"(1 2 3)\""),
"expected :value \"(1 2 3)\", got: {response}"
async fn count_native_cannot_mix_with_ratio_arithmetic() {
// account-count returns i32 (a count / Index, not a Scalar). Mixing it
// with a fractional Scalar literal must fail to compile — the design
// forbids accidental arithmetic across the Index/Scalar strata. (An
// integer literal like `10` is itself an Index now (ADR-0028), so
// `(+ 10 (account-count))` is valid Index arithmetic; the genuine
// stratum clash needs a fractional `1/2` Scalar operand.) The explicit
// `index->scalar` bridge is the only legal crossing.
let response = handle_form_smoke("(:id 11 :form (+ 1/2 (account-count)))").await;
assert!(response.contains(":code compile"), "got: {response}");
response.contains("scalar") && response.contains("index"),
"expected Index/Scalar stratum-separation error, got: {response}"
async fn get_commodity_with_non_uuid_arg_falls_back_to_symbol_lookup() {
// get-commodity now accepts a uuid OR a symbol (mirroring get-account's
// name fallback), so a non-uuid arg is NO LONGER an "invalid uuid"
// error — it's treated as a symbol and routed to a DB lookup. On this
// no-DB smoke harness that lookup surfaces a runtime DB-access error
// (not a parse error); a DB-backed test
// (`get_commodity_resolves_by_symbol` in tests-integration) covers the
// successful resolution.
let response = handle_form_smoke("(:id 9 :form (get-commodity \"USD\"))").await;
assert!(response.contains(":id 9"), "got: {response}");
response.contains(":code runtime") && response.contains("get-commodity"),
"expected a get-commodity runtime error (symbol path hits the DB), got: {response}"
!response.contains("invalid uuid"),
"a non-uuid arg must no longer short-circuit as an invalid-uuid error: {response}"
fn meta_native_unknown_in_script_mode_compile() {
// host_fns are only registered in eval-mode contexts; the compiler
// built without with_host_fns shouldn't see them. Sanity that the
// mode flag actually gates the registration.
use nomiscript::CompileMode;
let mut compiler = Compiler::new();
let program = nomiscript::Reader::parse("(rpc-protocol-version)").unwrap();
let result = compiler.compile_with_mode(&program, &mut symbols, CompileMode::Script);
result.is_err(),
"host fn should not be callable when compiler has no specs"