Skip to main content

submilli_engine/runtime/
exec.rs

1//! Program instantiation, `main` invocation and JSON-encoded result capture.
2
3use wasmtime::{ExnRef, Instance, Linker, Module, Rooted, Store, Val};
4
5use crate::runtime::{StoreData, read_submilli_string};
6
7#[derive(Debug, Clone)]
8pub struct RunResult {
9    pub value: Option<String>,
10    pub console: String,
11}
12
13/// Instantiates the program's module. A script's top-level statements are its
14/// Wasm start function, so they run here rather than under
15/// [`dispatch_main_async`]. Arm the execution deadline before this call, and
16/// treat its error as `main`'s: it is shaped the same way.
17///
18/// Every thread polling this future must meet
19/// [`RuntimeConfig::native_stack_size`](crate::runtime::RuntimeConfig::native_stack_size)'s
20/// caller contract for the store's Wasm stack budget.
21pub async fn instantiate_program_async(
22    linker: &Linker<StoreData>,
23    store: &mut Store<StoreData>,
24    module: &Module,
25) -> wasmtime::Result<Instance> {
26    let outcome = linker.instantiate_async(&mut *store, module).await;
27    outcome.map_err(|err| uncaught_error(store, err))
28}
29
30/// Calls the instantiated program's `main` and captures its JSON-encoded result.
31///
32/// Every thread polling this future must meet
33/// [`RuntimeConfig::native_stack_size`](crate::runtime::RuntimeConfig::native_stack_size)'s
34/// caller contract, as for [`instantiate_program_async`].
35pub async fn dispatch_main_async(
36    store: &mut Store<StoreData>,
37    instance: &Instance,
38) -> wasmtime::Result<Option<String>> {
39    let main = instance
40        .get_func(&mut *store, "main")
41        .ok_or_else(|| wasmtime::Error::msg("module has no `main` export"))?;
42
43    // The output shim encodes a concrete result as a string and undefined as
44    // a null reference in this private ABI. Language null encodes as "null".
45    if let Some(to_output) = instance.get_func(&mut *store, "__main_output") {
46        let mut out = [Val::null_any_ref()];
47        let r = to_output.call_async(&mut *store, &[], &mut out).await;
48        r.map_err(|err| uncaught_error(store, err))?;
49        if matches!(super::host::abi_arg(&out, 0)?, Val::AnyRef(None)) {
50            return Ok(None);
51        }
52        Ok(Some(read_main_string(&mut *store, &out)?))
53    } else {
54        let r = main.call_async(&mut *store, &[], &mut []).await;
55        r.map_err(|err| uncaught_error(store, err))?;
56        Ok(None)
57    }
58}
59
60/// An uncaught Submilli `throw` surfaces from a guest call as wasmtime's opaque
61/// `ThrownException` (Display: "thrown Wasm exception") — the real message lives
62/// only in the pending exception on the store. Pull the thrown `Error`'s
63/// `name`/`message` out and re-shape the error around them, so the runtime and
64/// CLI render something actionable instead of the placeholder.
65pub(crate) fn uncaught_error(
66    store: &mut Store<StoreData>,
67    err: wasmtime::Error,
68) -> wasmtime::Error {
69    if err.is::<super::host::FatalHostError>() || super::limits::is_memory_exhausted(&err) {
70        // A pending guest exception must not replace the actual fatal cause.
71        // Memory exhaustion is named; a fatal host error passes unchanged.
72        store.take_pending_exception();
73        return super::limits::name_memory_exhaustion(err);
74    }
75    let Some(exn) = store.take_pending_exception() else {
76        return err;
77    };
78    let denial = thrown_denial(store, exn);
79    // An already-thrown error may follow a completed effect. Diagnostic
80    // decoding must preserve its message even after settlement takes fuel to zero.
81    let text = super::fuel::settle_result(store, |store| Ok(read_thrown_error_text(store, exn)))
82        .ok()
83        .flatten();
84    let Some(text) = text else {
85        return err;
86    };
87    // The engine captures the throw-site backtrace into the exception and
88    // attaches it to the escaped error (host throws get the host-call site);
89    // re-wrap it so the CLI renders a source backtrace like a trap.
90    let backtrace = err.downcast_ref::<wasmtime::WasmBacktrace>().cloned();
91    wasmtime::Error::new(crate::backtrace::ThrownError {
92        message: text,
93        backtrace,
94        denial,
95    })
96}
97
98/// The denial the runtime threw, when `exn` carries that very object.
99fn thrown_denial(store: &mut Store<StoreData>, exn: Rooted<ExnRef>) -> Option<super::host::Denial> {
100    let Val::AnyRef(Some(thrown)) = exn.field(&mut *store, 0).ok()? else {
101        return None;
102    };
103    super::host::with_thrown_denials(store, |table, store| table.find(store, &thrown))
104}
105
106/// Reads `"name: message"` from a thrown exception's `$Error` payload, plus a
107/// `fields:` line for any extra primitive data fields an error subclass carries
108/// (`CalendarError.code`, `.status`, …). Returns `None` if the shape doesn't
109/// match (e.g. a future non-Error throw), leaving the caller to fall back to
110/// the original error.
111fn read_thrown_error_text(store: &mut Store<StoreData>, exn: Rooted<ExnRef>) -> Option<String> {
112    // The error tag carries a single param: (ref $Error).
113    let Val::AnyRef(Some(error_ref)) = exn.field(&mut *store, 0).ok()? else {
114        return None;
115    };
116    let error = error_ref.unwrap_struct(&mut *store).ok()?;
117    // $Error is class-shaped: field 2 is the object-fields payload array.
118    let Val::AnyRef(Some(payload_ref)) = error.field(&mut *store, 2).ok()? else {
119        return None;
120    };
121    let payload = payload_ref.unwrap_array(&mut *store).ok()?;
122    let message_field = payload
123        .get(&mut *store, super::prelude::error::MESSAGE_SLOT)
124        .ok()?;
125    let name_field = payload
126        .get(&mut *store, super::prelude::error::NAME_SLOT)
127        .ok()?;
128    let name = read_string_value(store, name_field)?;
129    let message = read_string_value(store, message_field)?;
130    let header = if name.is_empty() {
131        message
132    } else {
133        format!("{name}: {message}")
134    };
135    match read_error_fields_line(store, error) {
136        Some(fields) => Some(format!("{header}\n{fields}")),
137        None => Some(header),
138    }
139}
140
141const MAX_ERROR_FIELDS: usize = 8;
142const MAX_ERROR_FIELD_CHARS: usize = 120;
143
144/// Renders an error subclass's own data fields (`  fields: code = "x", …`).
145///
146/// The class layout puts field names in slot 1 and values in slot 2, data
147/// fields first (the inherited `cause`, `message` and `name`) with method closures after them —
148/// and the data/method boundary is not recoverable from the struct. Rendering
149/// only values whose vtable matches a host primitive singleton skips closures
150/// and nested objects for free. Any shape surprise degrades to `None` (the
151/// header renders as before).
152fn read_error_fields_line(
153    store: &mut Store<StoreData>,
154    error: Rooted<wasmtime::StructRef>,
155) -> Option<String> {
156    use crate::runtime::host::{
157        host_boxed_boolean_vtable, host_boxed_number_vtable, host_string_vtable,
158    };
159
160    let Val::AnyRef(Some(names_ref)) = error.field(&mut *store, 1).ok()? else {
161        return None;
162    };
163    let names = names_ref.unwrap_array(&mut *store).ok()?;
164    let Val::AnyRef(Some(payload_ref)) = error.field(&mut *store, 2).ok()? else {
165        return None;
166    };
167    let payload = payload_ref.unwrap_array(&mut *store).ok()?;
168
169    let string_vt = rooted_any(host_string_vtable(store).ok()?)?;
170    let number_vt = rooted_any(host_boxed_number_vtable(store).ok()?)?;
171    let boolean_vt = rooted_any(host_boxed_boolean_vtable(store).ok()?)?;
172
173    let len = names
174        .len(&mut *store)
175        .ok()?
176        .min(payload.len(&mut *store).ok()?);
177    let mut parts: Vec<String> = Vec::new();
178    let mut truncated = false;
179    for i in super::prelude::error::OWN_SLOT_BASE..len {
180        if parts.len() == MAX_ERROR_FIELDS {
181            truncated = true;
182            break;
183        }
184        let name_val = names.get(&mut *store, i).ok()?;
185        let Some(field_name) = read_string_value(store, name_val) else {
186            continue;
187        };
188        let value = payload.get(&mut *store, i).ok()?;
189        let Some(rendered) =
190            render_primitive_field(store, value, &string_vt, &number_vt, &boolean_vt)
191        else {
192            continue;
193        };
194        parts.push(format!("{field_name} = {rendered}"));
195    }
196    if parts.is_empty() {
197        return None;
198    }
199    let suffix = if truncated { ", …" } else { "" };
200    Some(format!("  fields: {}{suffix}", parts.join(", ")))
201}
202
203fn rooted_any(val: Val) -> Option<Rooted<wasmtime::AnyRef>> {
204    match val {
205        Val::AnyRef(Some(any)) => Some(any),
206        _ => None,
207    }
208}
209
210/// `null`, `undefined`, or the field's value when its vtable is a host primitive singleton;
211/// `None` for everything else (closures, arrays, nested objects, bigints).
212fn render_primitive_field(
213    store: &mut Store<StoreData>,
214    value: Val,
215    string_vt: &Rooted<wasmtime::AnyRef>,
216    number_vt: &Rooted<wasmtime::AnyRef>,
217    boolean_vt: &Rooted<wasmtime::AnyRef>,
218) -> Option<String> {
219    use crate::runtime::json::{boxed_bool, boxed_number, boxed_string};
220
221    let any = match value {
222        Val::AnyRef(None) => return Some("null".to_string()),
223        Val::AnyRef(Some(any)) => any,
224        _ => return None,
225    };
226    let value_struct = any.unwrap_struct(&mut *store).ok()?;
227    let undefined = crate::runtime::intrinsic_types::intrinsic_types(&mut *store)
228        .ok()?
229        .undefined
230        .clone();
231    if wasmtime::StructType::eq(&value_struct.ty(&*store).ok()?, &undefined) {
232        return Some("undefined".to_string());
233    }
234    let Val::AnyRef(Some(vt)) = value_struct.field(&mut *store, 0).ok()? else {
235        return None;
236    };
237    let boxed = Val::AnyRef(Some(any));
238    if Rooted::ref_eq(&*store, &vt, string_vt).ok()? {
239        let text = boxed_string(store, boxed).ok()?;
240        return Some(quoted_truncated(&text));
241    }
242    if Rooted::ref_eq(&*store, &vt, number_vt).ok()? {
243        let n = boxed_number(store, boxed).ok()?;
244        return Some(crate::runtime::number::format_number_js(n));
245    }
246    if Rooted::ref_eq(&*store, &vt, boolean_vt).ok()? {
247        let b = boxed_bool(store, boxed).ok()?;
248        return Some(b.to_string());
249    }
250    None
251}
252
253fn quoted_truncated(text: &str) -> String {
254    if text.chars().count() <= MAX_ERROR_FIELD_CHARS {
255        return format!("{text:?}");
256    }
257    let cut: String = text.chars().take(MAX_ERROR_FIELD_CHARS).collect();
258    format!("{:?}", format!("{cut}…"))
259}
260
261/// Decodes a `$string` GC ref (as a [`Val`]) into a Rust `String`.
262fn read_string_value(store: &mut Store<StoreData>, value: Val) -> Option<String> {
263    let Val::AnyRef(Some(s_ref)) = value else {
264        return None;
265    };
266    let s_struct = s_ref.unwrap_struct(&mut *store).ok()?;
267    // Field 1 of `$string` is the packed-UTF-16 `(ref $rawString)` array.
268    let Val::AnyRef(Some(raw_ref)) = s_struct.field(&mut *store, 1).ok()? else {
269        return None;
270    };
271    let arr = raw_ref.unwrap_array(&mut *store).ok()?;
272    read_submilli_string(&mut *store, arr).ok()
273}
274
275/// Reads the `$string` produced by the `__main_output` shim (in `out[0]`) into a
276/// Rust `String`, returned verbatim. Errors with a `main`-specific message if the
277/// ref slot isn't the expected non-null `$string`.
278fn read_main_string(store: &mut Store<StoreData>, out: &[Val]) -> wasmtime::Result<String> {
279    let s_struct = match super::host::abi_arg(out, 0)? {
280        Val::AnyRef(Some(any)) => any.unwrap_struct(&mut *store)?,
281        Val::AnyRef(None) => {
282            return Err(wasmtime::Error::msg(
283                "main returned null where the wasm signature is non-nullable",
284            ));
285        }
286        other => wasmtime::bail!("unexpected ref-slot value: {other:?}"),
287    };
288    // Field 1 of `$string` is `(ref $rawString)` — the raw
289    // packed-UTF-16 array. Field 0 is the vtable (skipped).
290    let raw_field = s_struct.field(&mut *store, 1)?;
291    let arr = match raw_field {
292        Val::AnyRef(Some(any)) => any.unwrap_array(&mut *store)?,
293        Val::AnyRef(None) => {
294            return Err(wasmtime::Error::msg(
295                "main's $string had null raw-array field",
296            ));
297        }
298        other => wasmtime::bail!("$string field 1 not an arrayref: {other:?}"),
299    };
300    read_submilli_string(&mut *store, arr)
301}
302
303#[cfg(test)]
304mod tests {
305    use std::time::Duration;
306
307    use crate::codegen::tests::compile;
308    use crate::runtime::RuntimeConfig;
309
310    // A string return is emitted verbatim — no JSON quoting or escaping — so a
311    // quarter-megabyte payload (a realistic web result, e.g. ~235 KB markdown from
312    // `jina.search`) costs no per-byte encoding pass and stays within the default
313    // fuel budget. The mid-string `"` is part of the output, not an escape.
314    #[tokio::test]
315    async fn large_string_return_fits_default_fuel() {
316        let bytes = compile(
317            r#"function main(): string {
318                let s: string = "x".repeat(250000);
319                return s + "\"";
320            }"#,
321        );
322        let result = RuntimeConfig::default()
323            .run(&bytes)
324            .await
325            .expect("250 KB string returns within the default fuel budget");
326        // 250k x's + the literal `"` — no surrounding quotes, no escaping.
327        assert_eq!(result.value.map(|v| v.len()), Some(250_001));
328    }
329
330    #[tokio::test]
331    async fn fixture_returns_string_and_captures_console() {
332        let bytes =
333            compile(r#"function main(): string { console.log("debug"); return "result"; }"#);
334        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
335        assert_eq!(result.value.as_deref(), Some("result"));
336        assert_eq!(result.console, "debug\n");
337    }
338
339    #[tokio::test]
340    async fn void_main_returns_none() {
341        let bytes = compile("function main(): void { }");
342        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
343        assert!(result.value.is_none());
344        assert_eq!(result.console, "");
345    }
346
347    #[tokio::test]
348    async fn undefined_main_results_are_absent_and_null_is_concrete() {
349        for source in [
350            "function main(): undefined { return undefined; }",
351            "function main(): unknown { return undefined; }",
352            "function main(): unknown { return; }",
353            "function main(): string | undefined { return undefined; }",
354        ] {
355            let result = RuntimeConfig::default()
356                .run(&compile(source))
357                .await
358                .expect("runs");
359            assert_eq!(result.value, None, "{source}");
360        }
361        let result = RuntimeConfig::default()
362            .run(&compile("function main(): null { return null; }"))
363            .await
364            .expect("runs");
365        assert_eq!(result.value.as_deref(), Some("null"));
366    }
367
368    #[tokio::test]
369    async fn optional_string_main_preserves_verbatim_output() {
370        let result = RuntimeConfig::default()
371            .run(&compile(
372                "function main(): string | undefined { return 'hello'; }",
373            ))
374            .await
375            .expect("runs");
376        assert_eq!(result.value.as_deref(), Some("hello"));
377    }
378
379    #[tokio::test]
380    async fn number_main_returns_json_number() {
381        let bytes = compile("function main(): number { return 42; }");
382        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
383        assert_eq!(result.value.as_deref(), Some("42"));
384        assert_eq!(result.console, "");
385    }
386
387    #[tokio::test]
388    async fn boolean_main_returns_json_boolean() {
389        let bytes = compile("function main(): boolean { return 1 < 2; }");
390        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
391        assert_eq!(result.value.as_deref(), Some("true"));
392        assert_eq!(result.console, "");
393    }
394
395    #[tokio::test]
396    async fn widened_boolean_literal_main_preserves_scalar_output() {
397        let source = include_str!("../../tests/fixtures/narrowing/live_boolean_literal_result.ts");
398        for source in [source.to_owned(), source.replace("true", "false")] {
399            let bytes = compile(&source);
400            let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
401            assert_eq!(result.value.as_deref(), Some("changed"));
402        }
403    }
404
405    #[tokio::test]
406    async fn console_and_return_are_independent_streams() {
407        let bytes = compile(r#"function main(): number { console.log("hi"); return 7; }"#);
408        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
409        assert_eq!(result.value.as_deref(), Some("7"));
410        assert_eq!(result.console, "hi\n");
411    }
412
413    #[tokio::test]
414    async fn string_return_is_verbatim_not_escaped() {
415        // A `string` return is the program's output as-is — no quoting, no escaping.
416        let bytes = compile(r#"function main(): string { return "a\"b\\c\n"; }"#);
417        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
418        assert_eq!(result.value.as_deref(), Some("a\"b\\c\n"));
419    }
420
421    #[tokio::test]
422    async fn nan_returns_nan_via_to_string() {
423        let bytes = compile("function main(): number { return 0 / 0; }");
424        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
425        assert_eq!(result.value.as_deref(), Some("NaN"));
426    }
427
428    #[tokio::test]
429    async fn infinity_returns_infinity_via_to_string() {
430        let bytes = compile("function main(): number { return 1 / 0; }");
431        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
432        assert_eq!(result.value.as_deref(), Some("Infinity"));
433    }
434
435    #[tokio::test]
436    async fn object_main_returns_json_object() {
437        let compiled =
438            compile_full("function main(): { a: number; b: number } { return { a: 1, b: 2 }; }");
439        let result = RuntimeConfig::default()
440            .run_compiled(&compiled)
441            .await
442            .expect("runs");
443        assert_eq!(result.value.as_deref(), Some(r#"{"a":1,"b":2}"#));
444    }
445
446    #[tokio::test]
447    async fn object_via_local_returns_json_object() {
448        let compiled = compile_full(
449            "function main(): { a: number } { const o: { a: number } = { a: 1 }; return o; }",
450        );
451        let result = RuntimeConfig::default()
452            .run_compiled(&compiled)
453            .await
454            .expect("runs");
455        assert_eq!(result.value.as_deref(), Some(r#"{"a":1}"#));
456    }
457
458    #[tokio::test]
459    async fn number_array_main_returns_json_array() {
460        let bytes = compile("function main(): number[] { return [1, 2, 3]; }");
461        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
462        assert_eq!(result.value.as_deref(), Some("[1,2,3]"));
463    }
464
465    #[tokio::test]
466    async fn string_field_object_is_json_escaped() {
467        let compiled = compile_full(r#"function main(): { n: string } { return { n: "a\"b" }; }"#);
468        let result = RuntimeConfig::default()
469            .run_compiled(&compiled)
470            .await
471            .expect("runs");
472        assert_eq!(result.value.as_deref(), Some(r#"{"n":"a\"b"}"#));
473    }
474
475    #[tokio::test]
476    async fn nested_object_main_returns_json() {
477        let compiled = compile_full(
478            "function main(): { inner: { v: number } } { return { inner: { v: 1 } }; }",
479        );
480        let result = RuntimeConfig::default()
481            .run_compiled(&compiled)
482            .await
483            .expect("runs");
484        assert_eq!(result.value.as_deref(), Some(r#"{"inner":{"v":1}}"#));
485    }
486
487    #[tokio::test]
488    async fn array_of_objects_main_returns_json() {
489        let compiled =
490            compile_full("function main(): { v: number }[] { return [{ v: 1 }, { v: 2 }]; }");
491        let result = RuntimeConfig::default()
492            .run_compiled(&compiled)
493            .await
494            .expect("runs");
495        assert_eq!(result.value.as_deref(), Some(r#"[{"v":1},{"v":2}]"#));
496    }
497
498    #[tokio::test]
499    async fn interface_instance_main_returns_json() {
500        let compiled = compile_full(
501            "interface Point { x: number; y: number; }\n\
502             function main(): Point { return { x: 1, y: 2 }; }",
503        );
504        let result = RuntimeConfig::default()
505            .run_compiled(&compiled)
506            .await
507            .expect("runs");
508        assert_eq!(result.value.as_deref(), Some(r#"{"x":1,"y":2}"#));
509    }
510
511    fn compile_full(source: &str) -> crate::compile::CompiledScript {
512        crate::compile::compile_script(source, "script.subm", crate::FileId(0), &[], &[])
513            .expect("test-only compile expects no diagnostics")
514    }
515
516    #[tokio::test]
517    async fn string_main_returned_verbatim() {
518        // A string return is the output itself — emitted verbatim, no quotes.
519        let bytes = compile(r#"function main(): string { return "hi"; }"#);
520        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
521        assert_eq!(result.value.as_deref(), Some("hi"));
522    }
523
524    #[tokio::test]
525    async fn negative_zero_main_normalizes_to_zero() {
526        // `(-0).toString()` is "0", matching ECMAScript.
527        let bytes = compile("function main(): number { return 0 * -1; }");
528        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
529        assert_eq!(result.value.as_deref(), Some("0"));
530    }
531
532    #[tokio::test]
533    async fn timeout_interrupts_infinite_loop() {
534        // Fuel is bumped so the loop outlives the watchdog tick; otherwise fuel exhaustion races the timeout.
535        let bytes = compile("function main(): void { while (true) { } }");
536        let cfg = RuntimeConfig {
537            fuel: u64::MAX,
538            timeout: Some(Duration::from_millis(50)),
539            ..RuntimeConfig::default()
540        };
541        let err = cfg.run(&bytes).await.expect_err("must trap");
542        let trap = err.downcast_ref::<wasmtime::Trap>();
543        assert_eq!(
544            trap,
545            Some(&wasmtime::Trap::Interrupt),
546            "expected timeout interrupt, got {trap:?} (full error: {err:?})",
547        );
548    }
549
550    #[tokio::test]
551    async fn failed_assert_in_main_propagates_as_error() {
552        let bytes = compile(r#"function main(): void { assert(false, "boom"); }"#);
553        let err = RuntimeConfig::default()
554            .run(&bytes)
555            .await
556            .expect_err("failed assert should bubble");
557        let rendered = format!("{err:?}");
558        assert!(
559            rendered.contains("boom"),
560            "expected failed assert to surface its thrown message, got: {rendered}",
561        );
562    }
563
564    #[tokio::test]
565    async fn uncaught_throw_surfaces_error_message() {
566        let bytes = compile(r#"function main(): void { throw new Error("boom message"); }"#);
567        let err = RuntimeConfig::default()
568            .run(&bytes)
569            .await
570            .expect_err("uncaught throw should surface as an error");
571        let rendered = format!("{err}");
572        assert!(
573            rendered.contains("boom message"),
574            "uncaught exception should surface its message, got: {rendered}",
575        );
576        assert!(
577            !rendered.contains("thrown Wasm exception"),
578            "the opaque placeholder should be replaced, got: {rendered}",
579        );
580    }
581
582    #[tokio::test]
583    async fn uncaught_throw_renders_source_backtrace() {
584        // Tier 2: a user `throw` carries a backtrace captured at the throw site.
585        let src = "function deep(): void {\n  throw new Error(\"boom\");\n}\nfunction main(): void {\n  deep();\n}";
586        let bytes = crate::codegen::tests::compile(src);
587        let err = RuntimeConfig::default()
588            .run(&bytes)
589            .await
590            .expect_err("uncaught throw");
591        let (sources, file) = crate::Sources::single("script.subm", src).unwrap();
592        let rendered =
593            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
594                .expect("an uncaught throw should render a backtrace");
595        assert!(
596            rendered.contains("error: Error: boom"),
597            "message header missing: {rendered}"
598        );
599        assert!(
600            rendered.contains("at deep ("),
601            "throw-site frame: {rendered}"
602        );
603        assert!(rendered.contains("at main ("), "caller frame: {rendered}");
604        assert!(
605            rendered.contains("thrown here"),
606            "frame-0 label: {rendered}"
607        );
608        assert!(
609            rendered.contains("throw new Error"),
610            "source context: {rendered}"
611        );
612        assert!(
613            !rendered.contains("fields:"),
614            "a plain Error has no extra data fields: {rendered}"
615        );
616    }
617
618    #[tokio::test]
619    async fn uncaught_top_level_throw_renders_its_statement() {
620        // Top-level statements run in the start function, during instantiation.
621        let src = "const n: number = 1;\nif (n === 1) { throw new Error(\"boom\"); }\nfunction main(): void {}";
622        let bytes = crate::codegen::tests::compile(src);
623        let err = RuntimeConfig::default()
624            .run(&bytes)
625            .await
626            .expect_err("uncaught top-level throw");
627        let (sources, file) = crate::Sources::single("script.subm", src).unwrap();
628        let rendered =
629            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
630                .expect("a top-level throw should render a backtrace");
631        assert!(
632            rendered.starts_with(
633                "error: Error: boom\n  at <top level> (script.subm:2:22)  [thrown here]\n"
634            ),
635            "{rendered}"
636        );
637        assert!(
638            rendered.contains("2 | if (n === 1) { throw new Error(\"boom\"); }"),
639            "source context: {rendered}"
640        );
641    }
642
643    #[tokio::test]
644    async fn a_top_level_statement_is_the_entry_frame_below_its_callee() {
645        let src = "function refuse(): number { throw new Error(\"boom\"); }\nconst n: number = refuse();\nfunction main(): void {}";
646        let bytes = crate::codegen::tests::compile(src);
647        let err = RuntimeConfig::default()
648            .run(&bytes)
649            .await
650            .expect_err("uncaught top-level throw");
651        let (sources, file) = crate::Sources::single("script.subm", src).unwrap();
652        let rendered =
653            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
654                .expect("a top-level throw should render a backtrace");
655        assert!(
656            rendered.contains("  at refuse (script.subm:1:")
657                && rendered.contains("[thrown here]")
658                && rendered.contains("  at <top level> (script.subm:2:19)  [entry]\n"),
659            "{rendered}"
660        );
661    }
662
663    #[tokio::test]
664    async fn uncaught_class_error_renders_data_fields() {
665        let src = r#"class ApiError extends Error {
666            code: string;
667            status: number;
668            retryable: boolean;
669            requestId: string | null;
670            constructor(code: string, message: string, status: number) {
671                super(message);
672                this.name = "ApiError";
673                this.code = code;
674                this.status = status;
675                this.retryable = false;
676                this.requestId = null;
677            }
678        }
679        function main(): void { throw new ApiError("badRequest", "Bad Request", 400); }"#;
680        let bytes = compile(src);
681        let err = RuntimeConfig::default()
682            .run(&bytes)
683            .await
684            .expect_err("uncaught throw");
685        let rendered = format!("{err}");
686        assert!(
687            rendered.contains("ApiError: Bad Request"),
688            "header: {rendered}"
689        );
690        assert!(
691            rendered.contains(
692                "  fields: code = \"badRequest\", requestId = null, retryable = false, status = 400"
693            ),
694            "fields line: {rendered}"
695        );
696    }
697
698    #[tokio::test]
699    async fn error_fields_skip_methods_and_objects() {
700        let src = r#"class DetailError extends Error {
701            code: string;
702            detail: number[];
703            constructor() {
704                super("nope");
705                this.name = "DetailError";
706                this.code = "bad";
707                this.detail = [1, 2];
708            }
709            describe(): string { return this.code; }
710        }
711        function main(): void { throw new DetailError(); }"#;
712        let bytes = compile(src);
713        let err = RuntimeConfig::default()
714            .run(&bytes)
715            .await
716            .expect_err("uncaught throw");
717        let rendered = format!("{err}");
718        assert!(
719            rendered.contains("fields: code = \"bad\""),
720            "primitive field renders: {rendered}"
721        );
722        assert!(
723            !rendered.contains("detail") && !rendered.contains("describe"),
724            "object fields and methods stay out of the dump: {rendered}"
725        );
726    }
727
728    #[tokio::test]
729    async fn error_field_strings_truncate() {
730        let src = r#"class BigError extends Error {
731            body: string;
732            constructor() {
733                super("big");
734                this.name = "BigError";
735                this.body = "x".repeat(500);
736            }
737        }
738        function main(): void { throw new BigError(); }"#;
739        let bytes = compile(src);
740        let err = RuntimeConfig::default()
741            .run(&bytes)
742            .await
743            .expect_err("uncaught throw");
744        let rendered = format!("{err}");
745        assert!(
746            rendered.contains("…") && !rendered.contains(&"x".repeat(200)),
747            "long string field truncates: {rendered}"
748        );
749    }
750
751    #[tokio::test]
752    async fn uncaught_host_throw_renders_clean_backtrace() {
753        // Tier 1: a Temporal host error, captured in the host throw path. The
754        // message is Temporal-native (no backing-crate leak) and a backtrace shows.
755        let src = "function main(): void {\n  Temporal.Instant.from(\"nope\");\n}";
756        let bytes = crate::codegen::tests::compile(src);
757        let err = RuntimeConfig::default()
758            .run(&bytes)
759            .await
760            .expect_err("uncaught host throw");
761        let (sources, file) = crate::Sources::single("script.subm", src).unwrap();
762        let rendered =
763            crate::backtrace::render(&err, &sources, file, crate::backtrace::BacktraceMode::Full)
764                .expect("a host throw should render a backtrace");
765        assert!(
766            rendered.contains("not a valid ISO 8601 instant"),
767            "clean message: {rendered}"
768        );
769        assert!(!rendered.contains("jiff"), "must not leak jiff: {rendered}");
770        assert!(rendered.contains("at main ("), "frame: {rendered}");
771    }
772
773    #[tokio::test]
774    async fn uncaught_host_throw_surfaces_message() {
775        // JSON.parse failure throws host-side; uncaught, it must
776        // still surface serde's detail rather than the opaque placeholder.
777        let bytes = compile(r#"function main(): void { JSON.parse("not json") as number; }"#);
778        let err = RuntimeConfig::default()
779            .run(&bytes)
780            .await
781            .expect_err("uncaught host throw should surface as an error");
782        let rendered = format!("{err}");
783        assert!(
784            rendered.contains("JSON.parse:"),
785            "uncaught host exception should surface its message, got: {rendered}",
786        );
787    }
788
789    #[tokio::test]
790    async fn uncaught_json_shape_mismatch_surfaces_message() {
791        // Codegen-side throw (`as` validator), uncaught: must surface its
792        // mismatch message rather than the opaque placeholder.
793        let bytes = compile(
794            r#"function main(): number { const n = JSON.parse("\"x\"") as number; return n; }"#,
795        );
796        let err = RuntimeConfig::default()
797            .run(&bytes)
798            .await
799            .expect_err("shape mismatch should surface as an error");
800        let rendered = format!("{err}");
801        assert!(
802            rendered.contains("type mismatch: expected number, got string"),
803            "uncaught shape mismatch should surface its message, got: {rendered}",
804        );
805    }
806
807    #[tokio::test]
808    async fn number_to_string_arithmetic() {
809        let bytes = compile("function main(): string { return (1 + 2 * 3).toString(); }");
810        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
811        assert_eq!(result.value.as_deref(), Some("7"));
812    }
813
814    #[tokio::test]
815    async fn number_to_string_double_precision() {
816        // ECMAScript shortest round-trip: 0.1 + 0.2 yields the
817        // canonical "0.30000000000000004" form.
818        let bytes = compile("function main(): string { return (0.1 + 0.2).toString(); }");
819        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
820        assert_eq!(result.value.as_deref(), Some("0.30000000000000004"));
821    }
822
823    #[tokio::test]
824    async fn string_coercion_alias_for_to_string() {
825        let bytes = compile("function main(): string { return String(42); }");
826        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
827        assert_eq!(result.value.as_deref(), Some("42"));
828    }
829
830    #[tokio::test]
831    async fn number_coercion_parses_decimal() {
832        let bytes = compile(r#"function main(): number { return Number("1.3"); }"#);
833        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
834        assert_eq!(result.value.as_deref(), Some("1.3"));
835    }
836
837    #[tokio::test]
838    async fn number_coercion_empty_string_is_zero() {
839        // JS-compat: Number("") returns 0 (not NaN).
840        let bytes = compile(r#"function main(): number { return Number(""); }"#);
841        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
842        assert_eq!(result.value.as_deref(), Some("0"));
843    }
844
845    #[tokio::test]
846    async fn number_coercion_invalid_returns_nan() {
847        let bytes = compile(r#"function main(): number { return Number("abc"); }"#);
848        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
849        assert_eq!(result.value.as_deref(), Some("NaN"));
850    }
851
852    #[tokio::test]
853    async fn parse_int_prefix_decimal() {
854        let bytes = compile(r#"function main(): number { return parseInt("42px"); }"#);
855        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
856        assert_eq!(result.value.as_deref(), Some("42"));
857    }
858
859    #[tokio::test]
860    async fn parse_int_hex_with_explicit_radix() {
861        let bytes = compile(r#"function main(): number { return parseInt("0xff", 16); }"#);
862        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
863        assert_eq!(result.value.as_deref(), Some("255"));
864    }
865
866    #[tokio::test]
867    async fn parse_int_no_digits_is_nan() {
868        let bytes = compile(r#"function main(): number { return parseInt("abc"); }"#);
869        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
870        assert_eq!(result.value.as_deref(), Some("NaN"));
871    }
872
873    #[tokio::test]
874    async fn parse_float_prefix() {
875        let bytes = compile(r#"function main(): number { return parseFloat("1.5x"); }"#);
876        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
877        assert_eq!(result.value.as_deref(), Some("1.5"));
878    }
879
880    #[tokio::test]
881    async fn parse_float_leading_decimal() {
882        let bytes = compile(r#"function main(): number { return parseFloat(".5"); }"#);
883        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
884        assert_eq!(result.value.as_deref(), Some("0.5"));
885    }
886
887    #[tokio::test]
888    async fn number_to_string_round_trip() {
889        let bytes = compile("function main(): boolean { return Number((42).toString()) === 42; }");
890        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
891        assert_eq!(result.value.as_deref(), Some("true"));
892    }
893
894    #[tokio::test]
895    async fn object_structural_equality_distinct_allocations() {
896        let bytes = compile(
897            "function main(): boolean { let p = { a: 1 }; let q = { a: 1 }; return p === q; }",
898        );
899        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
900        assert_eq!(result.value.as_deref(), Some("true"));
901    }
902
903    #[tokio::test]
904    async fn object_structural_inequality_different_field_values() {
905        let bytes = compile(
906            "function main(): boolean { let p = { a: 1 }; let q = { a: 2 }; return p === q; }",
907        );
908        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
909        assert_eq!(result.value.as_deref(), Some("false"));
910    }
911
912    #[tokio::test]
913    async fn object_ref_eq_fast_path() {
914        let bytes = compile("function main(): boolean { let p = { a: 1 }; return p === p; }");
915        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
916        assert_eq!(result.value.as_deref(), Some("true"));
917    }
918
919    #[tokio::test]
920    async fn object_with_string_field_structural_equals() {
921        let bytes = compile(
922            r#"function main(): boolean { let p = { n: "x" }; let q = { n: "x" }; return p === q; }"#,
923        );
924        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
925        assert_eq!(result.value.as_deref(), Some("true"));
926    }
927
928    #[tokio::test]
929    async fn nested_object_equal() {
930        let bytes = compile(
931            "function main(): boolean { let p = { inner: { v: 1 } }; let q = { inner: { v: 1 } }; return p === q; }",
932        );
933        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
934        assert_eq!(result.value.as_deref(), Some("true"));
935    }
936
937    #[tokio::test]
938    async fn nested_object_inner_differs() {
939        let bytes = compile(
940            "function main(): boolean { let p = { inner: { v: 1 } }; let q = { inner: { v: 2 } }; return p === q; }",
941        );
942        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
943        assert_eq!(result.value.as_deref(), Some("false"));
944    }
945
946    #[tokio::test]
947    async fn array_structural_equality_distinct_allocations() {
948        let bytes = compile(
949            "function main(): boolean { let a = [1, 2, 3]; let b = [1, 2, 3]; return a === b; }",
950        );
951        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
952        assert_eq!(result.value.as_deref(), Some("true"));
953    }
954
955    #[tokio::test]
956    async fn array_inequality_same_length_different_elements() {
957        let bytes = compile(
958            "function main(): boolean { let a = [1, 2, 3]; let b = [1, 2, 4]; return a === b; }",
959        );
960        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
961        assert_eq!(result.value.as_deref(), Some("false"));
962    }
963
964    #[tokio::test]
965    async fn array_inequality_different_lengths() {
966        let bytes = compile(
967            "function main(): boolean { let a = [1, 2]; let b = [1, 2, 3]; return a === b; }",
968        );
969        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
970        assert_eq!(result.value.as_deref(), Some("false"));
971    }
972
973    #[tokio::test]
974    async fn array_of_strings_structural_equals() {
975        let bytes = compile(
976            r#"function main(): boolean { let a = ["x", "y"]; let b = ["x", "y"]; return a === b; }"#,
977        );
978        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
979        assert_eq!(result.value.as_deref(), Some("true"));
980    }
981
982    #[tokio::test]
983    async fn array_of_objects_structural_equals() {
984        let bytes = compile(
985            "function main(): boolean { let a = [{ v: 1 }, { v: 2 }]; let b = [{ v: 1 }, { v: 2 }]; return a === b; }",
986        );
987        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
988        assert_eq!(result.value.as_deref(), Some("true"));
989    }
990
991    #[tokio::test]
992    async fn double_eq_same_as_strict_eq() {
993        let bytes = compile(
994            "function main(): boolean { const two: number = 2; return (1 === 1) && (1 == 1) && (1 !== two) && (1 != two); }",
995        );
996        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
997        assert_eq!(result.value.as_deref(), Some("true"));
998    }
999
1000    #[tokio::test]
1001    async fn object_not_eq_round_trip() {
1002        let bytes = compile(
1003            "function main(): boolean { let p = { a: 1 }; let q = { a: 2 }; return p !== q; }",
1004        );
1005        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
1006        assert_eq!(result.value.as_deref(), Some("true"));
1007    }
1008
1009    #[tokio::test]
1010    async fn null_self_equality() {
1011        let bytes = compile("function main(): boolean { return null === null; }");
1012        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
1013        assert_eq!(result.value.as_deref(), Some("true"));
1014    }
1015
1016    #[tokio::test]
1017    async fn null_not_equal_to_self_is_false() {
1018        let bytes = compile("function main(): boolean { return null !== null; }");
1019        let result = RuntimeConfig::default().run(&bytes).await.expect("runs");
1020        assert_eq!(result.value.as_deref(), Some("false"));
1021    }
1022}