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