1
use std::cell::RefCell;
2
use std::sync::{Arc, Mutex};
3

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

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

            
20
const EPOCH_DEADLINE_TICKS: u64 = 1;
21

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

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

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

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

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

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

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

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

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

            
141
#[derive(Debug, Error)]
142
pub enum SessionError {
143
    #[error("engine init failed: {0}")]
144
    Engine(#[from] EngineError),
145
}
146

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

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

            
205
    #[must_use]
206
    pub fn ctx(&self) -> &ScriptCtx {
207
        &self.ctx
208
    }
209

            
210
    #[must_use]
211
283
    pub fn interrupt_handle(&self) -> InterruptHandle {
212
283
        self.interrupt.clone()
213
283
    }
214

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

            
241
    /// Cooperative cancel handle for an in-flight `nomi-eval`. Clone
242
    /// and hand to the transport layer: when the client sends a
243
    /// cancel signal (e.g. emacs `C-g`), call `.bump()` and the
244
    /// awaiting evaluation traps with `EngineError::EpochInterrupt`,
245
    /// surfacing on the wire as `(:error (:code interrupted ...))`.
246
    #[must_use]
247
277
    pub fn epoch_bumper(&self) -> EpochBumper {
248
277
        EpochBumper::new(self.engine.clone())
249
277
    }
250

            
251
    /// Number of pre-compiled wasm modules currently cached.
252
    /// Used by tests to confirm the phase-4 pre-warm step ran and
253
    /// that subsequent `handle_form` calls of cached forms don't
254
    /// trigger a cold compile.
255
175
    pub fn cache_size(&self) -> Result<usize, EngineError> {
256
175
        self.cache.len()
257
175
    }
258

            
259
6011
    pub async fn handle_form(&mut self, frame: &str) -> String {
260
1199
        let response = match self.evaluate(frame).await {
261
1050
            Ok(resp) => resp,
262
149
            Err(err) => err.into_response(),
263
        };
264
1199
        format_response(&response)
265
1199
    }
266

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

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

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

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

            
348
    /// If an interrupt is pending, ack it (using the one generation snapshot
349
    /// that decided it) and yield the interrupted error; otherwise `None`. The
350
    /// single checkpoint used everywhere a form can abort on a `C-g` before the
351
    /// Wasm call.
352
11907
    fn check_interrupt(&mut self, id: &RequestId) -> Option<EvalFailure> {
353
11907
        let observed = self.interrupt.generation();
354
11907
        (observed > self.interrupt_ack).then(|| {
355
109
            self.ack_interrupt(observed);
356
109
            EvalFailure::Interrupted(id.clone())
357
109
        })
358
11907
    }
359

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

            
389
    /// Evaluates a single already-parsed form (mREPL input). The interrupt
390
    /// pre-start check lives in `run`.
391
6
    async fn eval_one_form(&mut self, form: Expr) -> Result<Value, EvalFailure> {
392
6
        self.run(&Request {
393
6
            id: RequestId::Int(0),
394
6
            form,
395
6
        })
396
6
        .await
397
6
    }
398

            
399
6011
    async fn evaluate(&mut self, frame: &str) -> Result<Response, EvalFailure> {
400
1199
        let request = parse_request(frame).map_err(EvalFailure::Envelope)?;
401
1196
        let value = self.run(&request).await?;
402
1050
        Ok(Response {
403
1050
            id: request.id,
404
1050
            payload: ResponsePayload::Value(value),
405
1050
        })
406
1199
    }
407

            
408
5994
    async fn run(&mut self, request: &Request) -> Result<Value, EvalFailure> {
409
1206
        debug!(user_id = %self.ctx.user_id, "evaluating form");
410
        // A `C-g` that landed before this form started (pre-armed, or during an
411
        // earlier form of a load) aborts here.
412
1206
        if let Some(err) = self.check_interrupt(&request.id) {
413
19
            return Err(err);
414
1187
        }
415
1187
        let program = Program::new(vec![request.form.clone()]);
416
1187
        let (bytes, result_ty) = self
417
1187
            .compiler
418
1187
            .compile_eval_with_type(&program, &mut self.symbols)
419
1187
            .map_err(|err| EvalFailure::Eval(request.id.clone(), err))?;
420
1179
        let module = self
421
1179
            .cache
422
1179
            .get_or_compile(&self.engine, &bytes)
423
1179
            .map_err(|err| EvalFailure::Engine(request.id.clone(), err))?;
424

            
425
1179
        let mut linker: Linker<SessionData> = Linker::new(&self.engine);
426
1179
        crate::natives::link(&mut linker).map_err(|err| {
427
            EvalFailure::Engine(
428
                request.id.clone(),
429
                EngineError::Instantiate(err.to_string()),
430
            )
431
        })?;
432

            
433
1179
        let mut store: Store<SessionData> = Store::new(
434
1179
            &self.engine,
435
1179
            SessionData::new(self.ctx.clone(), Arc::clone(&self.output)),
436
        );
437
1179
        store.set_fuel(self.ctx.limits.fuel).map_err(|err| {
438
            EvalFailure::Engine(request.id.clone(), EngineError::Fuel(err.to_string()))
439
        })?;
440
1179
        store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
441

            
442
1179
        let instance = linker
443
1179
            .instantiate_async(&mut store, &module)
444
1179
            .await
445
1179
            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
446
1179
        let func = instance.get_func(&mut store, "nomi-eval").ok_or_else(|| {
447
            EvalFailure::Engine(
448
                request.id.clone(),
449
                EngineError::MissingExport("nomi-eval".into()),
450
            )
451
        })?;
452
        // An interrupt that arrived during compile/link (the epoch bump only
453
        // cancels a running call) — abort before entering Wasm.
454
1179
        if let Some(err) = self.check_interrupt(&request.id) {
455
41
            return Err(err);
456
1138
        }
457
1138
        let mut results = [Val::AnyRef(None)];
458
1138
        let call_result = func.call_async(&mut store, &[], &mut results).await;
459
        // The reader bumps the epoch AND signals the interrupt together on
460
        // `(:emacs-interrupt)`. If a C-g arrived during THIS call it cancelled it
461
        // (epoch trap → `call_result` is `Err`); attribute it to this request so
462
        // it can't also abort the next one. We ack only on the error path, so a
463
        // C-g just after a clean success stays pending for the next form. This
464
        // covers every terminal exit uniformly (epoch cancel, the
465
        // out-of-fuel/runtime-trap-wins-the-race case) with no per-exit cleanup.
466
        //
467
        // Attribution cutoff (deliberate): the ack snapshot is taken AFTER the
468
        // call returns, so a C-g landing in the sub-µs window between the call
469
        // returning `Err` and this load is attributed to THIS (failing) form,
470
        // not the next one. That is correct REPL semantics — at that instant the
471
        // failing form is still the in-flight request (its error hasn't reached
472
        // the client), so the user can't yet have meant the C-g for a later
473
        // form. A C-g pressed after the error is surfaced necessarily arrives
474
        // after `run` returns, advancing the generation again, and the next
475
        // form's pre-start `check_interrupt` catches it. The exact instant of
476
        // call return is unobservable, so this boundary is irreducible, not a
477
        // lost interrupt.
478
1138
        if call_result.is_err() {
479
81
            let observed = self.interrupt.generation();
480
81
            self.ack_interrupt(observed);
481
1057
        }
482
1138
        call_result
483
1138
            .map_err(|err| EvalFailure::Engine(request.id.clone(), classify_runtime_error(&err)))?;
484

            
485
1057
        let any: Option<Rooted<AnyRef>> = match &results[0] {
486
1057
            Val::AnyRef(a) => *a,
487
            _ => {
488
                return Err(EvalFailure::Engine(
489
                    request.id.clone(),
490
                    EngineError::Trap("nomi-eval did not return anyref".into()),
491
                ));
492
            }
493
        };
494
1057
        let captured = decode_eval_result(&mut store, any, result_ty).map_err(|err| {
495
            EvalFailure::Engine(
496
                request.id.clone(),
497
                EngineError::Trap(format!("decoding nomi-eval result: {err}")),
498
            )
499
        })?;
500
1057
        Ok(Value::from(captured))
501
1206
    }
502
}
503

            
504
enum EvalFailure {
505
    Envelope(EnvelopeError),
506
    Eval(RequestId, NomiError),
507
    Engine(RequestId, EngineError),
508
    Interrupted(RequestId),
509
}
510

            
511
impl EvalFailure {
512
623
    fn into_response(self) -> Response {
513
623
        match self {
514
27
            EvalFailure::Envelope(err) => Response {
515
27
                id: RequestId::Int(0),
516
27
                payload: ResponsePayload::Error {
517
27
                    code: envelope_error_code(&err),
518
27
                    message: err.to_string(),
519
27
                    detail: Some(format!("{err:?}")),
520
27
                },
521
27
            },
522
58
            EvalFailure::Eval(id, err) => Response {
523
58
                id,
524
58
                payload: ResponsePayload::Error {
525
58
                    code: nomi_error_code(&err),
526
58
                    message: err.to_string(),
527
58
                    detail: Some(format!("{err:?}")),
528
58
                },
529
58
            },
530
429
            EvalFailure::Engine(id, err) => Response {
531
429
                id,
532
429
                payload: ResponsePayload::Error {
533
429
                    code: engine_error_code(&err),
534
429
                    message: err.to_string(),
535
429
                    detail: Some(format!("{err:?}")),
536
429
                },
537
429
            },
538
109
            EvalFailure::Interrupted(id) => Response {
539
109
                id,
540
109
                payload: ResponsePayload::Error {
541
109
                    code: ErrorCode::new(ErrorCode::INTERRUPTED),
542
109
                    message: "evaluation interrupted before start".into(),
543
109
                    detail: None,
544
109
                },
545
109
            },
546
        }
547
623
    }
548
}
549

            
550
27
fn envelope_error_code(err: &EnvelopeError) -> ErrorCode {
551
27
    let symbol = match err {
552
1
        EnvelopeError::Parse(_) => ErrorCode::PARSE,
553
        EnvelopeError::NotSingleExpr
554
        | EnvelopeError::NotPlist
555
        | EnvelopeError::MissingKey(_)
556
26
        | EnvelopeError::InvalidValue(_, _) => ErrorCode::ARGS,
557
    };
558
27
    ErrorCode::new(symbol)
559
27
}
560

            
561
58
fn nomi_error_code(err: &NomiError) -> ErrorCode {
562
58
    let symbol = match err {
563
        NomiError::Parse(_) => ErrorCode::PARSE,
564
57
        NomiError::Compile(_) | NomiError::UndefinedSymbol(_) => ErrorCode::COMPILE,
565
        NomiError::Runtime(_) => ErrorCode::RUNTIME,
566
1
        NomiError::Type { .. } | NomiError::Arity { .. } => ErrorCode::ARGS,
567
    };
568
58
    ErrorCode::new(symbol)
569
58
}
570

            
571
429
fn engine_error_code(err: &EngineError) -> ErrorCode {
572
429
    match err {
573
        EngineError::Compile(_) => ErrorCode::new(ErrorCode::COMPILE),
574
303
        EngineError::OutOfFuel | EngineError::Trap(_) => ErrorCode::new(ErrorCode::RUNTIME),
575
1
        EngineError::EpochInterrupt => ErrorCode::new(ErrorCode::INTERRUPTED),
576
        EngineError::Instantiate(_) | EngineError::MissingExport(_) => {
577
            ErrorCode::new(ErrorCode::SERVER)
578
        }
579
        EngineError::Fuel(_) | EngineError::Config(_) | EngineError::CachePoisoned => {
580
            ErrorCode::new(ErrorCode::SERVER)
581
        }
582
50
        EngineError::NoConversion(_) => ErrorCode::new(ErrorCode::NO_CONVERSION),
583
        // Commodity mismatch now arrives as a `ScriptRaised` carrying the
584
        // reader-folded symbol `COMMODITY-MISMATCH` (ADR-0026): it `throw`s
585
        // `$nomi_error` in-guest and the boundary wrapper bridges the uncaught
586
        // throw to `__nomi_raise`. The code flows through verbatim like any
587
        // script raise; the wire `:code` is the upper-cased symbol form.
588
75
        EngineError::ScriptRaised { code, .. } => ErrorCode::new(code.clone()),
589
    }
590
429
}
591

            
592
#[cfg(test)]
593
mod tests {
594
    use super::*;
595
    use crate::ctx::ScriptLimits;
596
    use nomiscript::{Fraction, Reader};
597

            
598
40
    async fn handle_form_smoke(frame: &str) -> String {
599
40
        let ctx = ScriptCtx::new(uuid::Uuid::nil());
600
40
        let mut session = Session::new(ctx).expect("Session::new");
601
40
        session.handle_form(frame).await
602
40
    }
603

            
604
2
    fn parse_to_value(input: &str) -> Result<Value, NomiError> {
605
2
        let program = Reader::parse(input)?;
606
2
        let mut symbols = SymbolTable::with_builtins();
607
2
        nomiscript::eval_program(&mut symbols, &program)
608
2
    }
609

            
610
    #[tokio::test]
611
1
    async fn evaluates_arithmetic_and_returns_value_envelope() {
612
1
        let response = handle_form_smoke("(:id 1 :form (+ 1 2))").await;
613
1
        assert_eq!(response, "(:id 1 :value 3)");
614
1
    }
615

            
616
    #[tokio::test]
617
1
    async fn evaluates_nested_arithmetic() {
618
1
        let response = handle_form_smoke("(:id 5 :form (* (+ 1 2) (- 10 4)))").await;
619
1
        assert_eq!(response, "(:id 5 :value 18)");
620
1
    }
621

            
622
    #[tokio::test]
623
1
    async fn print_in_eval_mode_does_not_panic() {
624
        // Regression: PRINT / DISPLAY / NEWLINE / DEBUG lower to `env.log`,
625
        // which was script-mode-only. In eval mode the missing func index used
626
        // to SIGABRT the compiler (`registry.rs` `HashMap[key]`). The eval-mode
627
        // `log` import + the rpc `env.log` host fn now make it compile + run.
628
1
        let response = handle_form_smoke("(:id 1 :form (print \"hi\"))").await;
629
1
        assert!(response.contains(":id 1"), "got: {response}");
630
1
        assert!(!response.contains(":code"), "must not error: {response}");
631
1
    }
632

            
633
    #[tokio::test]
634
1
    async fn dolist_with_print_in_eval_mode_runs() {
635
        // The exact shape from the Metro script that first surfaced the panic.
636
1
        let response = handle_form_smoke("(:id 2 :form (dolist (x (list 1 2 3)) (print x)))").await;
637
1
        assert!(response.contains(":id 2"), "got: {response}");
638
1
        assert!(!response.contains(":code"), "must not error: {response}");
639
1
    }
640

            
641
    #[tokio::test]
642
1
    async fn handle_request_captures_output_and_value() {
643
        // `(print "hi")` writes "hi" to the per-request buffer (via env.log) and
644
        // its own return value is nil; the structured outcome must carry the
645
        // captured text in `output` AND the value payload — the SLYNK mREPL
646
        // renders these as `:write-string` + `:write-values` respectively.
647
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
648
1
        let outcome = session.handle_request("(print \"hi\")").await;
649
1
        assert!(
650
1
            outcome.output.contains("hi"),
651
            "captured output should contain the printed text, got: {:?}",
652
            outcome.output
653
        );
654
1
        assert!(
655
1
            matches!(outcome.payload, ResponsePayload::Value(_)),
656
1
            "payload should be a Value, got: {:?}",
657
1
            outcome.payload
658
1
        );
659
1
    }
660

            
661
    #[tokio::test]
662
1
    async fn handle_request_value_only_has_empty_output() {
663
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
664
1
        let outcome = session.handle_request("(+ 1 2)").await;
665
1
        assert!(outcome.output.is_empty(), "got: {:?}", outcome.output);
666
1
        assert_eq!(
667
1
            outcome.payload,
668
1
            ResponsePayload::Value(Value::Number(Fraction::from_integer(3)))
669
1
        );
670
1
    }
671

            
672
    #[tokio::test]
673
1
    async fn handle_request_rejects_plist_shaped_injection() {
674
        // Adversarial review: the source must be parsed as a standalone AST, not
675
        // interpolated into a `(:id 0 :form …)` envelope string — otherwise
676
        // `1 :form (+ 2 3)` would re-read as a plist and eval to `1`. It must
677
        // instead error (more than one top-level form), never silently return 1.
678
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
679
1
        let outcome = session.handle_request("1 :form (+ 2 3)").await;
680
1
        assert!(
681
1
            matches!(outcome.payload, ResponsePayload::Error { .. }),
682
1
            "plist-shaped input must error, got: {:?}",
683
1
            outcome.payload
684
1
        );
685
1
    }
686

            
687
    #[tokio::test]
688
1
    async fn handle_request_interrupt_latch_aborts_before_eval() {
689
        // An interrupt latched before the request (the SLYNK reader arms it on
690
        // `(:emacs-interrupt)`) must abort the eval at the pre-start check, even
691
        // for a form that would otherwise compile fine.
692
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
693
1
        session.interrupt_handle().interrupt();
694
1
        let outcome = session.handle_request("(+ 1 2)").await;
695
1
        match outcome.payload {
696
1
            ResponsePayload::Error { code, .. } => {
697
1
                assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED);
698
1
            }
699
1
            other => panic!("expected interrupted error, got: {other:?}"),
700
1
        }
701
1
    }
702

            
703
    #[tokio::test]
704
1
    async fn handle_file_evaluates_all_forms_and_persists_state() {
705
        // A file with a defun + a call to it: state accumulates across forms,
706
        // and the load returns a summary value (not the last form's value).
707
1
        let dir = std::env::temp_dir();
708
1
        let path = dir.join(format!("nms_load_test_{}.nms", std::process::id()));
709
1
        std::fs::write(&path, "(defun dbl (x) (* x 2))\n(dbl 21)\n").unwrap();
710
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
711
1
        let outcome = session.handle_file(path.to_str().unwrap()).await;
712
1
        std::fs::remove_file(&path).ok();
713
1
        match outcome.payload {
714
1
            ResponsePayload::Value(Value::String(s)) => {
715
1
                assert!(s.contains("loaded"), "summary: {s}");
716
1
                assert!(s.contains("2 forms"), "summary: {s}");
717
1
            }
718
1
            other => panic!("expected a load summary string, got: {other:?}"),
719
1
        }
720
1
    }
721

            
722
    #[tokio::test]
723
1
    async fn handle_file_missing_path_errors() {
724
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
725
1
        let outcome = session.handle_file("/no/such/nms/file.nms").await;
726
1
        assert!(
727
1
            matches!(outcome.payload, ResponsePayload::Error { .. }),
728
1
            "got: {:?}",
729
1
            outcome.payload
730
1
        );
731
1
    }
732

            
733
    #[tokio::test]
734
1
    async fn handle_file_aborts_on_a_bad_form() {
735
1
        let dir = std::env::temp_dir();
736
1
        let path = dir.join(format!("nms_load_bad_{}.nms", std::process::id()));
737
1
        std::fs::write(&path, "(+ 1 2)\n(undefined-symbol-here)\n").unwrap();
738
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
739
1
        let outcome = session.handle_file(path.to_str().unwrap()).await;
740
1
        std::fs::remove_file(&path).ok();
741
1
        assert!(
742
1
            matches!(outcome.payload, ResponsePayload::Error { .. }),
743
1
            "a bad form must abort the load, got: {:?}",
744
1
            outcome.payload
745
1
        );
746
1
    }
747

            
748
    #[tokio::test]
749
1
    async fn handle_file_honours_interrupt_armed_before_load() {
750
        // A `C-g` that lands before/during the synchronous read+parse must abort
751
        // the load (the pre-read latch check), not be ignored until the first
752
        // form reaches the Wasm call.
753
1
        let dir = std::env::temp_dir();
754
1
        let path = dir.join(format!("nms_load_intr_{}.nms", std::process::id()));
755
1
        std::fs::write(&path, "(+ 1 2)\n(+ 3 4)\n").unwrap();
756
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
757
1
        session.interrupt_handle().interrupt();
758
1
        let outcome = session.handle_file(path.to_str().unwrap()).await;
759
1
        std::fs::remove_file(&path).ok();
760
1
        match outcome.payload {
761
1
            ResponsePayload::Error { code, .. } => {
762
1
                assert_eq!(code.as_symbol(), ErrorCode::INTERRUPTED, "got: {code:?}");
763
1
            }
764
1
            other => panic!("interrupt should abort the load, got: {other:?}"),
765
1
        }
766
1
    }
767

            
768
    #[tokio::test]
769
1
    async fn handle_request_surfaces_error_payload() {
770
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
771
1
        let outcome = session.handle_request("does-not-exist").await;
772
1
        assert!(
773
1
            matches!(outcome.payload, ResponsePayload::Error { .. }),
774
1
            "payload should be an Error, got: {:?}",
775
1
            outcome.payload
776
1
        );
777
1
    }
778

            
779
    #[tokio::test]
780
1
    async fn handle_request_clears_output_between_calls() {
781
        // The buffer must not leak across requests: a print then a pure value.
782
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
783
1
        let _ = session.handle_request("(print \"first\")").await;
784
1
        let second = session.handle_request("(+ 1 1)").await;
785
1
        assert!(
786
1
            second.output.is_empty(),
787
1
            "output leaked from prior request: {:?}",
788
1
            second.output
789
1
        );
790
1
    }
791

            
792
    #[tokio::test]
793
1
    async fn returns_value_for_literal_form() {
794
1
        let response = handle_form_smoke("(:id 9 :form 42)").await;
795
1
        assert_eq!(response, "(:id 9 :value 42)");
796
1
    }
797

            
798
    #[tokio::test]
799
1
    async fn returns_value_for_string_literal() {
800
1
        let response = handle_form_smoke("(:id 9 :form \"hello\")").await;
801
1
        assert_eq!(response, "(:id 9 :value \"hello\")");
802
1
    }
803

            
804
    #[test]
805
1
    fn round_trips_bytes_through_eval() {
806
1
        let value = parse_to_value("'#u8(1 2 3)").unwrap();
807
1
        assert_eq!(value, Value::Bytes(vec![1, 2, 3]));
808
1
    }
809

            
810
    #[tokio::test]
811
1
    async fn bad_envelope_emits_envelope_error() {
812
1
        let response = handle_form_smoke("(:form (+ 1 2))").await;
813
1
        assert!(response.contains(":code args"));
814
1
        assert!(response.contains(":id 0"));
815
1
    }
816

            
817
    #[tokio::test]
818
1
    async fn malformed_envelope_emits_parse_error() {
819
1
        let response = handle_form_smoke("(((((").await;
820
1
        assert!(response.contains(":code parse"));
821
1
    }
822

            
823
    #[tokio::test]
824
1
    async fn undefined_symbol_emits_compile_error() {
825
1
        let response = handle_form_smoke("(:id 7 :form does-not-exist)").await;
826
1
        assert!(response.contains(":id 7"));
827
1
        assert!(response.contains(":code compile"));
828
1
    }
829

            
830
    #[tokio::test]
831
1
    async fn user_function_arity_violation_emits_args_error() {
832
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
833
1
        let _ = session
834
1
            .handle_form("(:id 1 :form (defun id-fn (x) x))")
835
1
            .await;
836
1
        let response = session.handle_form("(:id 2 :form (id-fn))").await;
837
1
        assert!(response.contains(":id 2"));
838
1
        assert!(response.contains(":code args"));
839
1
    }
840

            
841
    #[test]
842
1
    fn completions_match_case_insensitively_and_skip_internal() {
843
1
        let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
844
        // Lower-case input matches the upper-case folded symbol; the canonical
845
        // upper-case name is returned.
846
1
        let defuns = session.completions("def");
847
1
        assert!(defuns.contains(&"DEFUN".to_string()), "got: {defuns:?}");
848
1
        assert!(
849
7
            defuns.iter().all(|n| n.starts_with("DEF")),
850
            "got: {defuns:?}"
851
        );
852
        // Sorted; no internal `$`/`__` or `(SETF …)` place names.
853
1
        let all = session.completions("");
854
224
        assert!(all.windows(2).all(|w| w[0] <= w[1]), "must be sorted");
855
1
        assert!(
856
1
            all.iter()
857
225
                .all(|n| !n.starts_with('$') && !n.starts_with("__") && !n.starts_with("(SETF")),
858
            "internal/setf symbols must be filtered: {all:?}"
859
        );
860
1
    }
861

            
862
    #[tokio::test]
863
1
    async fn completions_include_a_user_defined_symbol() {
864
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
865
1
        let _ = session
866
1
            .handle_form("(:id 1 :form (defun my-helper (x) x))")
867
1
            .await;
868
        // The defun's name is folded to upper-case; a lower-case prefix finds it.
869
1
        let hits = session.completions("my-");
870
1
        assert!(hits.contains(&"MY-HELPER".to_string()), "got: {hits:?}");
871
1
    }
872

            
873
    #[test]
874
1
    fn completions_unknown_prefix_is_empty() {
875
1
        let session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
876
1
        assert!(session.completions("zzz-no-such-symbol-").is_empty());
877
1
    }
878

            
879
    #[tokio::test]
880
1
    async fn interrupt_before_form_short_circuits_with_interrupted() {
881
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
882
1
        let handle = session.interrupt_handle();
883
1
        handle.interrupt();
884
1
        let response = session.handle_form("(:id 11 :form (+ 1 2))").await;
885
1
        assert!(response.contains(":id 11"));
886
1
        assert!(response.contains(":code interrupted"));
887
1
    }
888

            
889
    #[tokio::test]
890
1
    async fn coalesced_interrupts_abort_one_form_each_in_order() {
891
        // Two `C-g`s observed together before a form cancel THAT form and are
892
        // coalesced (they do not pre-arm cancellation of a later one); a fresh,
893
        // distinct `C-g` later still cancels the next form. This pins the REPL
894
        // contract against both a "lost interrupt" and an "over-eager next-form
895
        // abort" regression.
896
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
897
1
        let handle = session.interrupt_handle();
898
1
        handle.interrupt();
899
1
        handle.interrupt(); // two presses before the form
900
1
        let first = session.handle_form("(:id 60 :form (+ 1 2))").await;
901
1
        assert!(
902
1
            first.contains(":code interrupted"),
903
            "first form must abort: {first}"
904
        );
905
        // Both presses were coalesced into the one abort — the next form is clean.
906
1
        let second = session.handle_form("(:id 61 :form (+ 1 2))").await;
907
1
        assert_eq!(
908
            second, "(:id 61 :value 3)",
909
            "next form coalesced-poisoned: {second}"
910
        );
911
        // A fresh, distinct press still aborts the following form.
912
1
        handle.interrupt();
913
1
        let third = session.handle_form("(:id 62 :form (+ 1 2))").await;
914
1
        assert!(
915
1
            third.contains(":code interrupted"),
916
1
            "a distinct later interrupt must still abort: {third}"
917
1
        );
918
1
    }
919

            
920
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
921
1
    async fn inflight_interrupt_does_not_poison_next_form() {
922
        // The SLYNK reader arms BOTH the epoch bump and the interrupt signal on
923
        // `(:emacs-interrupt)` (mod.rs). When that lands mid-eval the epoch trap
924
        // cancels the running call; that interrupt generation must then be acked
925
        // so the SAME `C-g` can't also abort the next request.
926
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
927
1
        let bumper = session.epoch_bumper();
928
1
        let interrupt = session.interrupt_handle();
929
1
        let cancel_task = tokio::spawn(async move {
930
1
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
931
1
            bumper.bump();
932
1
            interrupt.interrupt();
933
1
        });
934
1
        let cancelled = session
935
1
            .handle_form("(:id 30 :form (do ((i 0 (+ i 1))) ((>= i 1000000) i)))")
936
1
            .await;
937
1
        cancel_task.await.unwrap();
938
1
        assert!(
939
1
            cancelled.contains(":code interrupted") || cancelled.contains(":code runtime"),
940
            "in-flight eval should have been cancelled: {cancelled}"
941
        );
942
        // The next form must evaluate normally — the acked interrupt must not abort it.
943
1
        let next = session.handle_form("(:id 31 :form (+ 1 2))").await;
944
1
        assert_eq!(next, "(:id 31 :value 3)", "next form was poisoned: {next}");
945
1
    }
946

            
947
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
948
1
    async fn non_interrupt_failure_still_consumes_a_concurrent_interrupt() {
949
        // Race: an interrupt is signalled WHILE a form is in flight, but the
950
        // form loses to its own terminal error (out-of-fuel) before any epoch
951
        // bump could win. The in-flight form is finished, so the interrupt must
952
        // be acked on the error path too — otherwise it poisons the next form.
953
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
954
1
        let interrupt = session.interrupt_handle();
955
1
        let latch_task = tokio::spawn(async move {
956
            // Latch only (no epoch bump): the form trips out-of-fuel on its own.
957
1
            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
958
1
            interrupt.interrupt();
959
1
        });
960
1
        let failed = session
961
1
            .handle_form("(:id 50 :form (do ((i 0 (+ i 1))) ((>= i 100000000) i)))")
962
1
            .await;
963
1
        latch_task.await.unwrap();
964
1
        assert!(
965
1
            failed.contains(":code runtime") || failed.contains(":code interrupted"),
966
            "in-flight form should fail terminally: {failed}"
967
        );
968
1
        let next = session.handle_form("(:id 51 :form (+ 1 2))").await;
969
1
        assert_eq!(next, "(:id 51 :value 3)", "next form was poisoned: {next}");
970
1
    }
971

            
972
    #[tokio::test]
973
1
    async fn interrupt_after_clean_eval_aborts_next_form() {
974
        // The mirror of the above: after a form completes NORMALLY, an interrupt
975
        // armed before the next form must still abort it. The in-flight cleanup
976
        // only consumes the latch on an actual epoch-interrupt trap, so a `C-g`
977
        // that lands post-completion is NOT swallowed.
978
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
979
1
        let clean = session.handle_form("(:id 40 :form (+ 1 2))").await;
980
1
        assert_eq!(clean, "(:id 40 :value 3)");
981
1
        session.interrupt_handle().interrupt();
982
1
        let interrupted = session.handle_form("(:id 41 :form (+ 4 5))").await;
983
1
        assert!(
984
1
            interrupted.contains(":code interrupted"),
985
1
            "post-completion interrupt must abort the next form: {interrupted}"
986
1
        );
987
1
    }
988

            
989
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
990
1
    async fn epoch_bumper_cancels_inflight_long_eval() {
991
        // The one test that pins the EPOCH-TRAP cancel path specifically.
992
        //
993
        // Two things can end an in-flight eval with `:code interrupted`: the
994
        // pre-start `check_interrupt` latch, and the epoch trap inside Wasm.
995
        // They are easy to confuse, and a test that accepts either silently
996
        // stops covering the trap the moment timing drifts — which is exactly
997
        // what a coverage-instrumented build does to it.
998
        //
999
        // This isolates the trap structurally rather than by timing:
        //   - the interrupt handle is never touched, so `check_interrupt` can
        //     not fire at all and the pre-start path is unreachable;
        //   - fuel is effectively unbounded and the loop bound is just under
        //     i32::MAX, so the form can neither finish nor exhaust fuel, ruling
        //     out `:code runtime`;
        //   - the bumper runs in a loop rather than after a fixed sleep, so a
        //     slow host widens the window instead of missing it.
        // The assertions then pin the trap by its wire signature: EpochInterrupt
        // carries a `:detail`, whereas the pre-start latch reports
        // "evaluation interrupted before start" with none.
1
        let ctx = ScriptCtx::new(uuid::Uuid::nil()).with_limits(ScriptLimits {
1
            fuel: u64::MAX,
1
            ..ScriptLimits::default()
1
        });
1
        let mut session = Session::new(ctx).expect("Session::new");
1
        let bumper = session.epoch_bumper();
1
        let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
1
        let bump_until_done = done.clone();
1
        let bump_task = tokio::spawn(async move {
112
            while !bump_until_done.load(std::sync::atomic::Ordering::Relaxed) {
111
                bumper.bump();
111
                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
            }
1
        });
        // Bounded: if epoch interruption regresses, the form runs to a bound just
        // under i32::MAX with unbounded fuel and never returns. Without this the
        // test would stop being a test and become a job that burns until the k8s
        // deadline kills it — silently, since it produces no output meanwhile.
1
        let response = tokio::time::timeout(
1
            std::time::Duration::from_secs(60),
1
            session.handle_form("(:id 22 :form (do ((i 0 (+ i 1))) ((>= i 2000000000) i)))"),
1
        )
1
        .await
1
        .expect("epoch bump did not cancel the eval within 60s — interruption has regressed");
1
        done.store(true, std::sync::atomic::Ordering::Relaxed);
1
        bump_task.await.unwrap();
1
        assert!(response.contains(":id 22"), "{response}");
1
        assert!(
1
            response.contains(":code interrupted"),
            "epoch bump must cancel the in-flight eval: {response}"
        );
1
        assert!(
1
            !response.contains("evaluation interrupted before start"),
            "took the pre-start latch path, so the epoch trap went untested: {response}"
        );
1
        assert!(
1
            response.contains(":detail"),
1
            "EpochInterrupt must surface its detail; got: {response}"
1
        );
1
    }
    #[test]
1
    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).
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1
        assert!(
1
            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.
1
        let program = Reader::parse("(split:list-for-transaction (car (list-transactions \"\")))")
1
            .expect("parse");
1
        if let Err(e) = session
1
            .compiler
1
            .compile_eval_with_type(&program, &mut session.symbols)
        {
            panic!("host prelude helper failed to compile: {e:?}");
1
        }
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 1 :form (car '(1 2 3)))").await;
1
        assert_eq!(response, "(:id 1 :value 1)");
1
    }
    #[tokio::test]
1
    async fn car_of_quoted_heterogeneous_list_compiles_on_eval_path() {
        // Mixed number + string quoted list — the element is extracted by fold.
1
        let response = handle_form_smoke("(:id 1 :form (car '(7 \"x\")))").await;
1
        assert_eq!(response, "(:id 1 :value 7)");
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 1 :form (car (cdr '(1 2 3))))").await;
1
        assert_eq!(response, "(:id 1 :value 2)");
1
    }
    #[tokio::test]
1
    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.
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (cdr '(1 2 3)))").await,
            "(:id 1 :value \"(2 3)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (cdr '(1)))").await,
1
            "(:id 1 :value NIL)"
1
        );
1
    }
    #[tokio::test]
1
    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).
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (car '((1 2) 3)))").await,
            "(:id 1 :value \"(1 2)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (car '(x y)))").await,
1
            "(:id 1 :value \"X\")"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (reverse '(1 2 3)))").await,
            "(:id 1 :value \"(3 2 1)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (reverse (list 1 2 3)))").await,
            "(:id 1 :value \"(3 2 1)\")"
        );
        // Composition still folds through to the element.
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (car (reverse '(1 2 3))))").await,
1
            "(:id 1 :value 3)"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (cons 0 '(1 2 3)))").await,
            "(:id 1 :value \"(0 1 2 3)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (cons 0 (list 1 2 3)))").await,
            "(:id 1 :value \"(0 1 2 3)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (cons 1 2))").await,
1
            "(:id 1 :value \"(1 . 2)\")"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (append '(1 2) '(3)))").await,
            "(:id 1 :value \"(1 2 3)\")"
        );
1
        assert_eq!(
1
            handle_form_smoke("(:id 1 :form (append '(a b) '(c)))").await,
1
            "(:id 1 :value \"(A B C)\")"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 9 :form (math:square 9))").await;
1
        assert_eq!(response, "(:id 9 :value 81)");
1
    }
    #[tokio::test]
1
    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.
1
        let resp = handle_form_smoke("(:id 7 :form (pp 42))").await;
1
        assert!(resp.contains(":id 7"), "{resp}");
1
        assert!(resp.contains("\"42\""), "{resp}");
1
    }
    #[tokio::test]
1
    async fn describe_form_at_value_position_returns_doc() {
        // compile_describe_for_stack — same shape.
1
        let resp = handle_form_smoke("(:id 8 :form (describe '+))").await;
1
        assert!(resp.contains(":id 8"), "{resp}");
1
        assert!(!resp.contains(":error"), "{resp}");
1
    }
    #[tokio::test]
1
    async fn apropos_form_at_value_position_returns_list() {
1
        let resp = handle_form_smoke("(:id 9 :form (apropos \"entity\"))").await;
1
        assert!(resp.contains(":id 9"), "{resp}");
1
        assert!(!resp.contains(":error"), "{resp}");
1
    }
    #[tokio::test]
1
    async fn deftest_form_at_value_position_returns_quoted_name() {
1
        let resp = handle_form_smoke("(:id 10 :form (deftest sanity (assert-equal 1 1)))").await;
1
        assert!(resp.contains(":id 10"), "{resp}");
1
        assert!(!resp.contains(":error"), "{resp}");
1
    }
    #[tokio::test]
1
    async fn assert_equal_pass_form_at_value_position() {
1
        let resp = handle_form_smoke("(:id 11 :form (assert-equal 2 2))").await;
1
        assert!(resp.contains(":id 11"), "{resp}");
1
        assert!(!resp.contains(":error"), "{resp}");
1
    }
    #[tokio::test]
1
    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).
1
        let resp = handle_form_smoke("(:id 12 :form (assert-equal 1 2))").await;
1
        assert!(resp.contains(":id 12"), "{resp}");
1
        assert!(resp.contains(":error"), "{resp}");
1
    }
    #[tokio::test]
1
    async fn coverage_dump_lists_called_natives() {
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1
        let _ = session
1
            .handle_form("(:id 1 :form (rpc-protocol-version))")
1
            .await;
1
        let dump = session.handle_form("(:id 2 :form (coverage-dump))").await;
1
        assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1
        assert!(dump.contains(":id 2"), "{dump}");
1
    }
    #[tokio::test]
1
    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.
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1
        let dump = session.handle_form("(:id 1 :form (coverage-dump))").await;
1
        assert!(dump.contains(":id 1"), "{dump}");
1
        assert!(dump.contains("RPC-PROTOCOL-VERSION"), "{dump}");
1
    }
    #[tokio::test]
1
    async fn interrupt_does_not_persist_across_forms() {
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1
        session.interrupt_handle().interrupt();
1
        let _ = session.handle_form("(:id 11 :form (+ 1 2))").await;
1
        let response = session.handle_form("(:id 12 :form (+ 1 2))").await;
1
        assert_eq!(response, "(:id 12 :value 3)");
1
    }
    #[tokio::test]
1
    async fn session_state_persists_across_forms() {
1
        let mut session = Session::new(ScriptCtx::new(uuid::Uuid::nil())).expect("Session::new");
1
        let defun = session
1
            .handle_form("(:id 1 :form (defun double (x) (* 2 x)))")
1
            .await;
1
        assert!(defun.contains(":id 1"));
1
        let call = session.handle_form("(:id 2 :form (double 21))").await;
1
        assert_eq!(call, "(:id 2 :value 42)");
1
    }
    #[tokio::test]
1
    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.)
1
        let response = handle_form_smoke("(:id 3 :form 1/4)").await;
1
        assert_eq!(response, "(:id 3 :value 1/4)");
1
    }
    #[test]
1
    fn nomi_runtime_value_carries_through() {
1
        let value = parse_to_value("(+ 0.5 0.25)").unwrap();
1
        assert_eq!(value, Value::Number(Fraction::new(3, 4)));
1
    }
    #[tokio::test]
1
    async fn calls_meta_native_from_nomiscript_source() {
1
        let response = handle_form_smoke("(:id 1 :form (rpc-protocol-version))").await;
1
        let expected_version = crate::natives::meta::PROTOCOL_VERSION;
1
        assert_eq!(response, format!("(:id 1 :value {expected_version})"));
1
    }
    #[tokio::test]
1
    async fn calls_server_get_version_from_nomiscript_source() {
1
        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.
1
        assert!(
1
            response.starts_with("(:id 1 :value \""),
            "expected string response, got: {response}"
        );
1
        assert!(response.ends_with("\")"));
1
    }
    #[tokio::test]
1
    async fn calls_server_get_build_date_from_nomiscript_source() {
1
        let response = handle_form_smoke("(:id 2 :form (get-build-date))").await;
1
        assert!(
1
            response.starts_with("(:id 2 :value \""),
            "expected string response, got: {response}"
        );
1
        assert!(response.ends_with("\")"));
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 12 :form (cons 1 (cons 2 (cons 3 nil))))").await;
1
        assert!(
1
            response.contains(":value \"(1 2 3)\""),
1
            "expected :value \"(1 2 3)\", got: {response}"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 11 :form (+ 1/2 (account-count)))").await;
1
        assert!(response.contains(":code compile"), "got: {response}");
1
        assert!(
1
            response.contains("scalar") && response.contains("index"),
1
            "expected Index/Scalar stratum-separation error, got: {response}"
1
        );
1
    }
    #[tokio::test]
1
    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.
1
        let response = handle_form_smoke("(:id 9 :form (get-commodity \"USD\"))").await;
1
        assert!(response.contains(":id 9"), "got: {response}");
1
        assert!(
1
            response.contains(":code runtime") && response.contains("get-commodity"),
            "expected a get-commodity runtime error (symbol path hits the DB), got: {response}"
        );
1
        assert!(
1
            !response.contains("invalid uuid"),
1
            "a non-uuid arg must no longer short-circuit as an invalid-uuid error: {response}"
1
        );
1
    }
    #[test]
1
    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;
1
        let mut compiler = Compiler::new();
1
        let mut symbols = SymbolTable::with_builtins();
1
        let program = nomiscript::Reader::parse("(rpc-protocol-version)").unwrap();
1
        let result = compiler.compile_with_mode(&program, &mut symbols, CompileMode::Script);
1
        assert!(
1
            result.is_err(),
            "host fn should not be callable when compiler has no specs"
        );
1
    }
    #[tokio::test]
1
    async fn get_config_missing_name_yields_error_envelope() {
1
        let resp = handle_form_smoke("(:id 20 :form (get-config \"\"))").await;
1
        assert!(resp.contains(":id 20"), "{resp}");
1
        assert!(resp.contains(":error"), "{resp}");
1
        assert!(!resp.contains(":value"), "{resp}");
1
    }
    #[tokio::test]
1
    async fn get_config_success_shape_is_config_value() {
        // Passes a non-empty name; the DB call fails (no pool) and surfaces
        // as an :error envelope — we verify the SUCCESS wire shape is only
        // emitted when the command actually succeeds. Here we just confirm
        // the argument-validation path (empty string) always errors.
1
        let resp = handle_form_smoke("(:id 21 :form (get-config \"\"))").await;
1
        assert!(resp.contains(":id 21"), "{resp}");
1
        assert!(!resp.contains(":value"), "{resp}");
1
    }
}