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