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