diff --git a/CHANGELOG.md b/CHANGELOG.md index 717e3799..14674bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to Aver are documented here. Starting with 0.10.0, minor rel ## Unreleased +## 0.16.2 (unreleased) + +> _Record/replay correctness across all three backends — and a tidier wasm-gc imports tree along the way._ + +### Added +- **wasm-gc independent products (`?!` / `!`) record/replay parity.** Codegen now emits `enter_group` / `set_branch(i)` / `exit_group` host calls around independent-product literals, so contained effects pick up the same `(group_id, branch_path, effect_occurrence)` tuple the VM annotates. Cross-backend replay (VM → wasm-gc and wasm-gc → VM) round-trips cleanly on `?!` programs. Previously wasm-gc traces were flat and group-tagged VM recordings broke under wasm-gc replay. +- **Self-host real output value comparison.** Self-host's CLI plumbing (`runFromFileWithRest` / `runCliFile` / `runGuestCliProgram` / `finishCliRun`) now propagates the user `main()`'s `Val` up to the wrapping replay scope instead of dropping it to Unit, and the replay-template runtime emits a `__aver_return__:` stdout marker for the host to parse. `aver replay --self-host` now reports a real `MATCH` / `DIFFERS` instead of always claiming MATCH. +- **Playground record/replay runs natively under V8 wasm-gc.** Trace capture and replay used to bounce through the VM-in-wasm32 bridge; the playground now compiles user source to wasm-gc bytes and drives `--record` / `--replay` on a WebWorker via a JS-side `EffectReplayState` mirror of the CLI host. Trace JSON is byte-compatible with `aver run --record`, so a downloaded `.replay.json` from the playground replays under the CLI replayer (and vice versa). Independent-product (`?!`) markers are wired so cross-backend traces match end-to-end. `--expr` per-fn recordings (`add(7, 35)`) ride the same path: the compiler injects a synthetic `__entry__()` fn that wraps the call with literal args, `_start` is wired through it, and the recording's `entry_fn` reflects the user-facing target — no JS-side argument encoder, no VM-in-wasm32 fallback. +- **`caller_fn` stamped on every recorded effect under wasm-gc.** Trace events now carry the originating Aver fn name (`"caller_fn": "renderRoom"` instead of the universal `"main"`); the playground trace panel and CLI dumps show real per-function labels. One shared global per effect-emitting fn, init at instantiation — hot path is a single `global.get` per call, zero alloc. +- **Playground compiler trimmed of dead VM-in-wasm32 paths.** Two cuts: the legacy NaN-boxed wasm32 emitter (`codegen::wasm`, gated behind a new `wasm-legacy` Cargo feature that the CLI's `wasm` feature pulls in but `playground` does not — so `--target wasm` / `--bridge {wasip1,fetch}` keep working on the CLI), and the unused `aver_run_record` / `aver_run_record_entry` / `aver_replay_run` wasm-bindgen bindings plus their `run_record_project*` / `replay_run_project` Rust hosts (record/replay now runs natively under V8 wasm-gc on a WebWorker — see the playground bullet above). `aver_bg.wasm` shrinks 4811 KiB → 4556 KiB after `wasm-opt -Oz` (-255 KiB / -5.3% on first-load). + +### Changed +- **`run_wasm_gc/imports.rs` split into 13 per-domain submodules** (`args.rs` / `console.rs` / `disk.rs` / `env.rs` / `factories.rs` / `groups.rs` / `http.rs` / `lm.rs` / `numeric.rs` / `replay_glue.rs` / `tcp.rs` / `terminal.rs` / `time.rs`). The 1711-line dispatch monolith is now a 101-line chain that hands off to per-namespace `dispatch(...)` functions; new effects live next to their decoders and factories. + ## 0.16.1 — wasm-gc record/replay parity (2026-05-05) ### Added diff --git a/Cargo.toml b/Cargo.toml index 88271dc0..ddfb87d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,15 @@ runtime = ["dep:aver-rt", "aver-memory/runtime", "aver-rt/random", "dep:clap", " runtime-net = ["runtime", "aver-rt/http"] terminal = ["aver-rt/terminal"] tty-render = ["dep:colored"] -wasm = ["wasm-compile", "dep:wasmtime"] +wasm = ["wasm-compile", "wasm-legacy", "dep:wasmtime"] wasm-compile = ["dep:wasm-encoder", "dep:wasmprinter", "dep:wasmparser", "dep:wat"] +# Pre-2024 NaN-boxed wasm32 backend (`--target wasm`, +# `--bridge {wasip1,fetch}`, standalone `aver_runtime.wasm`). Pulled +# in by the CLI `wasm` feature so legacy targets keep building, but +# NOT by `playground` — the browser host only speaks wasm-gc, so +# `aver_bg.wasm` doesn't need to ship the legacy emitter +# (~9.4 kLoC of Rust + the embedded runtime blob). +wasm-legacy = ["wasm-compile"] playground = ["wasm-compile", "dep:wasm-bindgen", "runtime", "tty-render"] [dependencies] diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index 0bf2ce5a..170a883f 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -14,7 +14,7 @@ pub mod lean; pub mod recursion; #[cfg(feature = "runtime")] pub mod rust; -#[cfg(feature = "wasm-compile")] +#[cfg(feature = "wasm-legacy")] pub mod wasm; #[cfg(feature = "wasm-compile")] pub mod wasm_gc; diff --git a/src/codegen/wasm_gc/body/builtins.rs b/src/codegen/wasm_gc/body/builtins.rs index 659aa6ef..3079b7dd 100644 --- a/src/codegen/wasm_gc/body/builtins.rs +++ b/src/codegen/wasm_gc/body/builtins.rs @@ -52,13 +52,15 @@ pub(super) fn emit_dotted_builtin( return Ok(()); } - // Registered effect import? Same shape — push args, call by idx. - // Effects return Unit; the trailing instruction sequence works - // identically to a Unit-returning user fn call. + // Registered effect import? Same shape — push args, push the + // current fn name via `global.get` (one immutable global per fn + // name, init by `array.new_data` at instantiation) so the host + // can stamp `caller_fn` on the recorded effect, then call by idx. if let Some(&wasm_idx) = ctx.fn_map.effects.get(&dotted) { for arg in args { emit_expr(func, arg, slots, ctx)?; } + super::emit::emit_caller_fn_global(func, ctx)?; func.instruction(&Instruction::Call(wasm_idx)); return Ok(()); } @@ -490,6 +492,7 @@ pub(super) fn emit_args_get_inline( ))?; // len = args_len() + super::emit::emit_caller_fn_global(func, ctx)?; func.instruction(&Instruction::Call(args_len_idx)); func.instruction(&Instruction::LocalSet(len_slot)); // i = len - 1 @@ -512,6 +515,7 @@ pub(super) fn emit_args_get_inline( func.instruction(&Instruction::BrIf(1)); // s = args_get(i) func.instruction(&Instruction::LocalGet(i_slot)); + super::emit::emit_caller_fn_global(func, ctx)?; func.instruction(&Instruction::Call(args_get_idx)); func.instruction(&Instruction::LocalSet(s_slot)); // acc = struct.new List { head: s, tail: acc } diff --git a/src/codegen/wasm_gc/body/emit.rs b/src/codegen/wasm_gc/body/emit.rs index f8fc88f9..0b5f31bd 100644 --- a/src/codegen/wasm_gc/body/emit.rs +++ b/src/codegen/wasm_gc/body/emit.rs @@ -1316,19 +1316,32 @@ pub(super) fn emit_independent_product_unwrap( /// Emit `call $aver/` if the program registered the /// structural-scope marker imports (i.e. discovery saw any `?!` / /// `!`). No-op otherwise — programs that never use independent -/// products don't pay the import slot. +/// products don't pay the import slot. Trailing String arg is the +/// `caller_fn` stamp every effect import now carries; the host +/// ignores it for group markers but the wasm signature still has +/// to match. fn emit_group_call(func: &mut Function, ctx: &EmitCtx<'_>, op: &str) { if let Some(idx) = ctx.effect_idx_lookup.get(op) { + if emit_caller_fn_global(func, ctx).is_err() { + // Should never trip — every fn def has a global allocated + // in `TypeRegistry::build`. + return; + } func.instruction(&Instruction::Call(*idx)); } } -/// Emit `i64.const i; call $aver/__record_set_branch` if the markers -/// are registered. Used inside `?!` / `!` lowering to switch the -/// recorder's active branch before evaluating each element. +/// Emit `i64.const i; ; call $aver/__record_set_branch` +/// if the markers are registered. Used inside `?!` / `!` lowering +/// to switch the recorder's active branch before evaluating each +/// element. Trailing caller_fn ignored host-side but its slot is +/// part of the import signature. fn emit_branch_marker(func: &mut Function, ctx: &EmitCtx<'_>, branch_idx: u32) { if let Some(idx) = ctx.effect_idx_lookup.get("__record_set_branch") { func.instruction(&Instruction::I64Const(branch_idx as i64)); + if emit_caller_fn_global(func, ctx).is_err() { + return; + } func.instruction(&Instruction::Call(*idx)); } } @@ -2339,6 +2352,30 @@ pub(super) fn emit_string_match( Ok(()) } +/// Push the caller-fn String ref via `global.get`. Each fn def in +/// the program owns one immutable `(ref null $string)` global, +/// `array.new_data`-init from the fn-name passive segment at module +/// instantiation. The hot path is a single `global.get` per effect +/// call — zero alloc, ~3 bytes per call site. +pub(super) fn emit_caller_fn_global( + func: &mut Function, + ctx: &EmitCtx<'_>, +) -> Result<(), WasmGcError> { + let idx = ctx + .registry + .caller_fn_globals + .get(ctx.self_fn_name) + .copied() + .ok_or_else(|| { + WasmGcError::Validation(format!( + "no caller_fn global registered for fn `{}`", + ctx.self_fn_name + )) + })?; + func.instruction(&Instruction::GlobalGet(idx)); + Ok(()) +} + /// Push a `(ref null $string)` onto the wasm stack from a UTF-8 byte /// slice. Looks up the literal in the registry's passive-segment /// table; the segment is intern-ed by `collect_string_literals_in_*` diff --git a/src/codegen/wasm_gc/effects.rs b/src/codegen/wasm_gc/effects.rs index 7e7c93b4..1bd98a82 100644 --- a/src/codegen/wasm_gc/effects.rs +++ b/src/codegen/wasm_gc/effects.rs @@ -399,7 +399,23 @@ impl EffectName { /// idx. The headers Map crossing uses the registered concrete /// `Map>` ref so the host bridge has a /// type-safe handle. + /// + /// **Trailing `caller_fn: any_ref`.** Every host import gains a + /// trailing String-ref param identifying the Aver fn that + /// emitted the call. The codegen pushes the current fn name + /// right before `call`, the host pulls it via `lm_string_to_host` + /// and stamps the resulting record's `caller_fn` field with it. + /// Without this surface, `caller_fn` was always `"main"` because + /// wasm-gc executes natively and the host has no other way to + /// recover the originating Aver fn — VM/self-host get it from + /// their interpreter stack frames, wasm-gc has to be told. pub(super) fn params(self, registry: &TypeRegistry) -> Result, WasmGcError> { + let mut p = self.params_without_caller(registry)?; + p.push(any_ref_ty()); + Ok(p) + } + + fn params_without_caller(self, registry: &TypeRegistry) -> Result, WasmGcError> { match self { Self::ConsolePrint | Self::ConsoleError | Self::ConsoleWarn => Ok(vec![any_ref_ty()]), Self::TimeUnixMs => Ok(vec![]), diff --git a/src/codegen/wasm_gc/module.rs b/src/codegen/wasm_gc/module.rs index ec976ff1..b40b80e4 100644 --- a/src/codegen/wasm_gc/module.rs +++ b/src/codegen/wasm_gc/module.rs @@ -119,11 +119,17 @@ pub(super) fn emit_module( "module has no fn definitions".into(), )); } - // `main` is optional — modules that act as a Worker handler - // (e.g. `tools/edge/handler.av`) export `handler` instead and - // never run `_start`. When absent, `_start` is emitted as a no-op - // so the module shape stays valid. - let main_idx: Option = fn_defs.iter().position(|fd| fd.name == "main"); + // `_start` calls `__entry__` if present (synthesised by the + // playground / `--expr` path to wrap a user fn call with literal + // args), otherwise `main`. Both are optional — modules that act + // as a Worker handler (e.g. `tools/edge/handler.av`) export + // `handler` instead and never run `_start`; when neither is + // present, `_start` is emitted as a no-op so the module shape + // stays valid. + let main_idx: Option = fn_defs + .iter() + .position(|fd| fd.name == "__entry__") + .or_else(|| fn_defs.iter().position(|fd| fd.name == "main")); let mut module = Module::new(); @@ -348,6 +354,20 @@ pub(super) fn emit_module( funcs.function(b.grow_type); } factory_exports.emit_function_entries(&mut funcs); + // `__init_globals`: wasm-level start fn that lazy-fills the + // caller_fn globals via `array.new_data`. Has to be a separate + // fn (not part of `_start`) because the host invokes `main` + // directly when both are exported, bypassing `_start` — + // wasm-level start fns are auto-run at instantiation regardless + // of which export the host calls. Allocated last so its idx is + // import_count + funcs.len() once the entry is appended. + let init_globals_fn_idx: Option = if !registry.caller_fn_global_order.is_empty() { + let idx = import_count + funcs.len(); + funcs.function(start_type_idx); + Some(idx) + } else { + None + }; module.section(&funcs); // ── Memory section (bridge LM only) ──────────────────────────── @@ -371,6 +391,41 @@ pub(super) fn emit_module( module.section(&memories); } + // ── Global section ───────────────────────────────────────────── + // One mutable `(ref null $string)` global per fn def, declared as + // `ref.null` and lazy-initialised at the top of `_start` via + // `array.new_data $string $segment 0 N`. The wasm-gc spec doesn't + // permit `array.new_data` in const-expr position, so the init has + // to land in a code body — `_start` runs once per instantiation + // and dominates every effect call site, which is what we need. + // Body emit pushes the caller-fn ref through + // `global.get $caller_fn_` at every effect call site — the + // alloc happens once at startup, the hot path costs one global + // load. + if !registry.caller_fn_global_order.is_empty() { + let string_idx = registry + .string_array_type_idx + .expect("caller_fn globals require the $string slot — TypeRegistry forces it on"); + let mut globals = wasm_encoder::GlobalSection::new(); + for _ in ®istry.caller_fn_global_order { + let init = wasm_encoder::ConstExpr::extended([Instruction::RefNull( + wasm_encoder::HeapType::Concrete(string_idx), + )]); + globals.global( + wasm_encoder::GlobalType { + val_type: wasm_encoder::ValType::Ref(wasm_encoder::RefType { + nullable: true, + heap_type: wasm_encoder::HeapType::Concrete(string_idx), + }), + mutable: true, + shared: false, + }, + &init, + ); + } + module.section(&globals); + } + // Build the fn-name → wasm-fn-idx map. With K imports: // imports at idx 0..K // _start at K @@ -481,6 +536,15 @@ pub(super) fn emit_module( } module.section(&exports); + // ── Start section ────────────────────────────────────────────── + // Wasm-level start fn — auto-runs once at instantiation, ahead of + // any host-invoked export. Used for caller_fn global init. + if let Some(idx) = init_globals_fn_idx { + module.section(&wasm_encoder::StartSection { + function_index: idx, + }); + } + // ── Data count section (must precede code when using passive // segments via array.new_data / data.drop). if !registry.string_literals.is_empty() { @@ -493,9 +557,10 @@ pub(super) fn emit_module( // ── Code section ─────────────────────────────────────────────── let mut codes = CodeSection::new(); - // _start: call main if present, drop its result on the way out; - // otherwise emit a no-op body. Worker-shaped modules without a - // top-level `main` rely on the host calling a different export. + // _start: call main if present, drop its return value. Caller_fn + // globals are NOT init here — the wasm-level `(start + // __init_globals)` section handles that on instantiation, before + // any export gets called. let mut start = Function::new([]); if let Some(idx) = main_idx { let main_idx_wasm = import_count + 1 + (idx as u32); @@ -593,6 +658,33 @@ pub(super) fn emit_module( factory_exports.emit_bodies(&mut codes, ®istry)?; + // `__init_globals` body — one `array.new_data → global.set` per + // registered caller_fn name. Walks `caller_fn_global_order` so + // global idx i in the section matches the `i`-th name in the + // walker output. Runs at instantiation via the StartSection + // emitted below. + if init_globals_fn_idx.is_some() { + let string_idx = registry + .string_array_type_idx + .expect("caller_fn globals require the $string slot"); + let mut init = Function::new([]); + for (idx, fn_name) in registry.caller_fn_global_order.iter().enumerate() { + let bytes = fn_name.as_bytes(); + let segment_idx = registry + .string_literal_segment(bytes) + .expect("fn-name passive segment registered alongside the global"); + init.instruction(&Instruction::I32Const(0)); + init.instruction(&Instruction::I32Const(bytes.len() as i32)); + init.instruction(&Instruction::ArrayNewData { + array_type_index: string_idx, + array_data_index: segment_idx, + }); + init.instruction(&Instruction::GlobalSet(idx as u32)); + } + init.instruction(&Instruction::End); + codes.function(&init); + } + module.section(&codes); // ── Data section ─────────────────────────────────────────────── diff --git a/src/codegen/wasm_gc/types.rs b/src/codegen/wasm_gc/types.rs index 5ad6772e..8b0582a4 100644 --- a/src/codegen/wasm_gc/types.rs +++ b/src/codegen/wasm_gc/types.rs @@ -115,6 +115,16 @@ pub(super) struct TypeRegistry { /// $string $segment_idx` with offset=0, size=len. pub(super) string_literals: Vec>, pub(super) string_literal_idx: HashMap, u32>, + /// Per-fn `(ref null $string)` global, init by `array.new_data` + /// from the fn-name passive segment. Codegen emits `global.get` + /// at every effect call site instead of allocating a fresh + /// `(array i8)` per call — module-instantiation pays the alloc + /// once, the runtime hot path costs one global load. + pub(super) caller_fn_globals: HashMap, + /// Emit order for the caller-fn globals, matched to the + /// idx values stored above. The `GlobalSection` walks this + /// vector so wasm idx 0..N matches the map values. + pub(super) caller_fn_global_order: Vec, /// Type names that must NOT be erased to their underlying /// primitive by the newtype optimisation. Populated with every /// record/variant used as a `Map` key — Map's open- @@ -217,14 +227,23 @@ impl TypeRegistry { // so any `Vector` registered below sits at a higher // index than `$string` and can reference it without crossing // the rec-group boundary. - let needs_string = items.iter().any(|item| match item { - TopLevel::FnDef(fd) => { - fd.return_type.contains("String") - || fd.params.iter().any(|(_, t)| t.contains("String")) - || fn_body_produces_string(fd) - } - _ => false, - }); + // Force the String type slot whenever the program has any + // fn defs at all — `body/builtins.rs` now pushes the + // current fn name as a String literal before every effect + // import call (the `caller_fn` trailing arg). Without the + // slot, `emit_string_literal_bytes` can't materialise the + // ref and validation fails for trivially-Stringless programs + // like `fn main() -> Int { _ = Time.unixMs(); 42 }`. + let has_fn_defs = items.iter().any(|item| matches!(item, TopLevel::FnDef(_))); + let needs_string = has_fn_defs + || items.iter().any(|item| match item { + TopLevel::FnDef(fd) => { + fd.return_type.contains("String") + || fd.params.iter().any(|(_, t)| t.contains("String")) + || fn_body_produces_string(fd) + } + _ => false, + }); let string_array_type_idx = if needs_string { let idx = next_idx; next_idx += 1; @@ -680,6 +699,33 @@ impl TypeRegistry { collect_string_literals_in_fn(fd, &mut string_literals, &mut string_literal_idx); } } + // Pre-register fn names as passive String literals AND + // allocate `(ref null $string)` wasm globals — but only for + // fns whose body actually emits caller_fn at a call site (a + // dotted call `Foo.bar(...)` or an `?!`/`!` independent + // product, which lowers to group/branch markers). Pure fns + // and plain forwarders that only call other user fns don't + // need a slot. + let mut caller_fn_globals: HashMap = HashMap::new(); + let mut caller_fn_global_order: Vec = Vec::new(); + for item in items { + if let TopLevel::FnDef(fd) = item { + if !fn_body_emits_effect_call(fd) { + continue; + } + let bytes = fd.name.as_bytes().to_vec(); + string_literal_idx.entry(bytes.clone()).or_insert_with(|| { + let idx = string_literals.len() as u32; + string_literals.push(bytes); + idx + }); + caller_fn_globals.entry(fd.name.clone()).or_insert_with(|| { + let idx = caller_fn_global_order.len() as u32; + caller_fn_global_order.push(fd.name.clone()); + idx + }); + } + } // `Int.mod` lowers to a boxed `Result` whose Err // arm carries a fixed "Division by zero" message — register // its bytes as a passive segment so the emitter has an idx @@ -743,6 +789,8 @@ impl TypeRegistry { string_array_type_idx, string_literals, string_literal_idx, + caller_fn_globals, + caller_fn_global_order, non_newtypable_keys, } } @@ -1987,6 +2035,45 @@ fn fn_body_calls_int_mod(fd: &crate::ast::FnDef) -> bool { }) } +/// Returns true when the fn body contains anything that emits a +/// caller_fn slot at codegen — a dotted call (any `Attr(_, _)` +/// callee, including nested-module shape `Module.Sub.fn(args)`) or +/// an independent product (`?!` / `!`, lowers to group/branch +/// markers). Used to skip allocating a global for fns that never +/// need one. Conservative on dotted: builtin namespace calls like +/// `List.length` are also flagged; false positives cost one segment +/// + one global per fn, false negatives crash wasm validation. +fn fn_body_emits_effect_call(fd: &crate::ast::FnDef) -> bool { + use crate::ast::{Expr, FnBody, Stmt}; + fn walk(e: &Expr) -> bool { + match e { + Expr::FnCall(callee, args) => { + let dotted = matches!(&callee.node, Expr::Attr(_, _)); + dotted || walk(&callee.node) || args.iter().any(|a| walk(&a.node)) + } + Expr::IndependentProduct(_, _) => true, + Expr::Match { subject, arms } => { + walk(&subject.node) || arms.iter().any(|a| walk(&a.body.node)) + } + Expr::BinOp(_, l, r) => walk(&l.node) || walk(&r.node), + Expr::Attr(o, _) => walk(&o.node), + Expr::ErrorProp(i) => walk(&i.node), + Expr::TailCall(b) => b.args.iter().any(|a| walk(&a.node)), + Expr::List(xs) | Expr::Tuple(xs) => xs.iter().any(|x| walk(&x.node)), + Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| walk(&e.node)), + Expr::RecordUpdate { base, updates, .. } => { + walk(&base.node) || updates.iter().any(|(_, e)| walk(&e.node)) + } + Expr::Constructor(_, p) => p.as_deref().is_some_and(|x| walk(&x.node)), + _ => false, + } + } + let FnBody::Block(stmts) = fd.body.as_ref(); + stmts.iter().any(|stmt| match stmt { + Stmt::Binding(_, _, e) | Stmt::Expr(e) => walk(&e.node), + }) +} + fn collect_string_literals_in_fn( fd: &crate::ast::FnDef, out: &mut Vec>, diff --git a/src/main/run_wasm_gc/imports.rs b/src/main/run_wasm_gc/imports.rs index 4ffdf602..a76a1fb9 100644 --- a/src/main/run_wasm_gc/imports.rs +++ b/src/main/run_wasm_gc/imports.rs @@ -60,6 +60,7 @@ pub(super) use factories::{ pub(super) use lm::lm_string_from_host; use http::{HttpVerb, http_body_dispatch, http_simple_dispatch}; +use lm::lm_string_to_host; pub(super) fn dispatch_aver_import( name: &str, @@ -67,40 +68,63 @@ pub(super) fn dispatch_aver_import( params: &[wasmtime::Val], results: &mut [wasmtime::Val], ) -> Result { - if args::dispatch(name, caller, params, results)? { + // Every effect import now carries a trailing `caller_fn: + // any_ref` param emitted by the codegen — see + // `effects.rs::params` and `body/builtins.rs::FnCall`. Decode + // it once, stash on the host so per-namespace dispatch arms + // can hand it to `record_effect_if_recording` without each arm + // re-running the LM transport, then forward `real_params` + // (params without the trailing slot) into the per-namespace + // dispatch chain. Falls back to "main" when the trailing arg + // is missing or not decodable — keeps replay of older recorded + // traces / hand-rolled wasm-gc modules working. + let caller_fn = lm_string_to_host(caller, params.last())? + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "main".to_string()); + let caller_fn_ref: &str = &caller_fn; + let real_params: &[wasmtime::Val] = if params.is_empty() { + params + } else { + ¶ms[..params.len() - 1] + }; + let params = real_params; + + if args::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if console::dispatch(name, caller, params, results)? { + if console::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if disk::dispatch(name, caller, params, results)? { + if disk::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if env::dispatch(name, caller, params, results)? { + if env::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } if groups::dispatch(name, caller, params, results)? { return Ok(true); } - if numeric::dispatch(name, caller, params, results)? { + if numeric::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if tcp::dispatch(name, caller, params, results)? { + if tcp::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if terminal::dispatch(name, caller, params, results)? { + if terminal::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } - if time::dispatch(name, caller, params, results)? { + if time::dispatch(name, caller, params, results, caller_fn_ref)? { return Ok(true); } match name { - "http_get" => http_simple_dispatch(caller, params, results, HttpVerb::Get), - "http_head" => http_simple_dispatch(caller, params, results, HttpVerb::Head), - "http_delete" => http_simple_dispatch(caller, params, results, HttpVerb::Delete), - "http_post" => http_body_dispatch(caller, params, results, HttpVerb::Post), - "http_put" => http_body_dispatch(caller, params, results, HttpVerb::Put), - "http_patch" => http_body_dispatch(caller, params, results, HttpVerb::Patch), + "http_get" => http_simple_dispatch(caller, params, results, HttpVerb::Get, caller_fn_ref), + "http_head" => http_simple_dispatch(caller, params, results, HttpVerb::Head, caller_fn_ref), + "http_delete" => { + http_simple_dispatch(caller, params, results, HttpVerb::Delete, caller_fn_ref) + } + "http_post" => http_body_dispatch(caller, params, results, HttpVerb::Post, caller_fn_ref), + "http_put" => http_body_dispatch(caller, params, results, HttpVerb::Put, caller_fn_ref), + "http_patch" => http_body_dispatch(caller, params, results, HttpVerb::Patch, caller_fn_ref), _ => Ok(false), } } diff --git a/src/main/run_wasm_gc/imports/args.rs b/src/main/run_wasm_gc/imports/args.rs index 3b7714a5..f4afc5eb 100644 --- a/src/main/run_wasm_gc/imports/args.rs +++ b/src/main/run_wasm_gc/imports/args.rs @@ -11,6 +11,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -24,7 +25,13 @@ pub(super) fn dispatch( } let n = caller.data().program_args.len() as i64; results[0] = Val::I64(n); - record_effect_if_recording(caller, "Args.len", vec![], aver::replay::JsonValue::Int(n)); + record_effect_if_recording( + caller, + "Args.len", + vec![], + aver::replay::JsonValue::Int(n), + caller_fn, + ); Ok(true) } "args_get" => { @@ -52,6 +59,7 @@ pub(super) fn dispatch( "Args.get", vec![aver::replay::JsonValue::Int(idx)], aver::replay::JsonValue::String(text), + caller_fn, ); Ok(true) } diff --git a/src/main/run_wasm_gc/imports/console.rs b/src/main/run_wasm_gc/imports/console.rs index 7e3bca21..a4320ac8 100644 --- a/src/main/run_wasm_gc/imports/console.rs +++ b/src/main/run_wasm_gc/imports/console.rs @@ -12,6 +12,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -38,6 +39,7 @@ pub(super) fn dispatch( "Console.print", vec![aver::replay::JsonValue::String(text)], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -58,6 +60,7 @@ pub(super) fn dispatch( "Console.error", vec![aver::replay::JsonValue::String(text)], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -78,6 +81,7 @@ pub(super) fn dispatch( "Console.warn", vec![aver::replay::JsonValue::String(text)], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -105,7 +109,7 @@ pub(super) fn dispatch( } }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Console.readLine", vec![], outcome); + record_effect_if_recording(caller, "Console.readLine", vec![], outcome, caller_fn); Ok(true) } _ => Ok(false), diff --git a/src/main/run_wasm_gc/imports/disk.rs b/src/main/run_wasm_gc/imports/disk.rs index 22b3875a..18b681b8 100644 --- a/src/main/run_wasm_gc/imports/disk.rs +++ b/src/main/run_wasm_gc/imports/disk.rs @@ -15,6 +15,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -34,7 +35,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.readText", args, outcome); + record_effect_if_recording(caller, "Disk.readText", args, outcome, caller_fn); Ok(true) } "disk_write_text" => { @@ -57,7 +58,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.writeText", args, outcome); + record_effect_if_recording(caller, "Disk.writeText", args, outcome, caller_fn); Ok(true) } "disk_append_text" => { @@ -80,7 +81,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.appendText", args, outcome); + record_effect_if_recording(caller, "Disk.appendText", args, outcome, caller_fn); Ok(true) } "disk_exists" => { @@ -100,6 +101,7 @@ pub(super) fn dispatch( "Disk.exists", args, aver::replay::JsonValue::Bool(exists), + caller_fn, ); Ok(true) } @@ -119,7 +121,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.delete", args, outcome); + record_effect_if_recording(caller, "Disk.delete", args, outcome, caller_fn); Ok(true) } "disk_delete_dir" => { @@ -138,7 +140,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.deleteDir", args, outcome); + record_effect_if_recording(caller, "Disk.deleteDir", args, outcome, caller_fn); Ok(true) } "disk_list_dir" => { @@ -164,7 +166,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_list_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.listDir", args, outcome); + record_effect_if_recording(caller, "Disk.listDir", args, outcome, caller_fn); Ok(true) } "disk_make_dir" => { @@ -183,7 +185,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Disk.makeDir", args, outcome); + record_effect_if_recording(caller, "Disk.makeDir", args, outcome, caller_fn); Ok(true) } _ => Ok(false), diff --git a/src/main/run_wasm_gc/imports/env.rs b/src/main/run_wasm_gc/imports/env.rs index dd92bb44..970a2a2a 100644 --- a/src/main/run_wasm_gc/imports/env.rs +++ b/src/main/run_wasm_gc/imports/env.rs @@ -11,6 +11,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -30,6 +31,7 @@ pub(super) fn dispatch( "Env.get", args, aver::replay::JsonValue::String(value), + caller_fn, ); Ok(true) } @@ -44,7 +46,13 @@ pub(super) fn dispatch( return Ok(true); } let _ = aver_rt::env_set(&name, &value); - record_effect_if_recording(caller, "Env.set", args, aver::replay::JsonValue::Null); + record_effect_if_recording( + caller, + "Env.set", + args, + aver::replay::JsonValue::Null, + caller_fn, + ); Ok(true) } _ => Ok(false), diff --git a/src/main/run_wasm_gc/imports/http.rs b/src/main/run_wasm_gc/imports/http.rs index 38f57fab..e203647f 100644 --- a/src/main/run_wasm_gc/imports/http.rs +++ b/src/main/run_wasm_gc/imports/http.rs @@ -30,6 +30,7 @@ pub(crate) fn http_simple_dispatch( params: &[wasmtime::Val], results: &mut [wasmtime::Val], verb: HttpVerb, + caller_fn: &str, ) -> Result { use wasmtime::Val; let url = lm_string_to_host(caller, params.first())?.unwrap_or_default(); @@ -54,7 +55,7 @@ pub(crate) fn http_simple_dispatch( let trace_outcome = http_outcome_to_json(&outcome); let result_ref = http_outcome_to_result(caller, outcome)?; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, effect_name, args, trace_outcome); + record_effect_if_recording(caller, effect_name, args, trace_outcome, caller_fn); Ok(true) } @@ -63,6 +64,7 @@ pub(crate) fn http_body_dispatch( params: &[wasmtime::Val], results: &mut [wasmtime::Val], verb: HttpVerb, + caller_fn: &str, ) -> Result { use wasmtime::Val; let url = lm_string_to_host(caller, params.first())?.unwrap_or_default(); @@ -104,7 +106,7 @@ pub(crate) fn http_body_dispatch( let trace_outcome = http_outcome_to_json(&outcome); let result_ref = http_outcome_to_result(caller, outcome)?; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, effect_name, args, trace_outcome); + record_effect_if_recording(caller, effect_name, args, trace_outcome, caller_fn); Ok(true) } diff --git a/src/main/run_wasm_gc/imports/numeric.rs b/src/main/run_wasm_gc/imports/numeric.rs index a6713769..ab1003aa 100644 --- a/src/main/run_wasm_gc/imports/numeric.rs +++ b/src/main/run_wasm_gc/imports/numeric.rs @@ -13,6 +13,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -50,6 +51,7 @@ pub(super) fn dispatch( aver::replay::JsonValue::Int(max), ], aver::replay::JsonValue::Int(v), + caller_fn, ); Ok(true) } @@ -70,6 +72,7 @@ pub(super) fn dispatch( "Random.float", vec![], aver::replay::JsonValue::Float(f), + caller_fn, ); Ok(true) } diff --git a/src/main/run_wasm_gc/imports/replay_glue.rs b/src/main/run_wasm_gc/imports/replay_glue.rs index 028a3ab8..d7d23a1d 100644 --- a/src/main/run_wasm_gc/imports/replay_glue.rs +++ b/src/main/run_wasm_gc/imports/replay_glue.rs @@ -16,6 +16,7 @@ pub(crate) fn record_effect_if_recording( effect_type: &str, args: Vec, outcome: aver::replay::JsonValue, + caller_fn: &str, ) { if let Some(rec) = caller.data_mut().recorder.as_mut() && rec.mode() == aver::replay::EffectReplayMode::Record @@ -24,7 +25,7 @@ pub(crate) fn record_effect_if_recording( effect_type, args, aver::replay::RecordedOutcome::Value(outcome), - "main", + caller_fn, 0, ); } diff --git a/src/main/run_wasm_gc/imports/tcp.rs b/src/main/run_wasm_gc/imports/tcp.rs index 69ffe389..1bd563c2 100644 --- a/src/main/run_wasm_gc/imports/tcp.rs +++ b/src/main/run_wasm_gc/imports/tcp.rs @@ -20,6 +20,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -62,7 +63,7 @@ pub(super) fn dispatch( Err(e) => (host_result_tcp_connection_err(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.connect", args, outcome); + record_effect_if_recording(caller, "Tcp.connect", args, outcome, caller_fn); Ok(true) } "tcp_write_line" => { @@ -98,7 +99,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.writeLine", args, outcome); + record_effect_if_recording(caller, "Tcp.writeLine", args, outcome, caller_fn); Ok(true) } "tcp_read_line" => { @@ -130,7 +131,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.readLine", args, outcome); + record_effect_if_recording(caller, "Tcp.readLine", args, outcome, caller_fn); Ok(true) } "tcp_close" => { @@ -162,7 +163,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.close", args, outcome); + record_effect_if_recording(caller, "Tcp.close", args, outcome, caller_fn); Ok(true) } "tcp_send" => { @@ -187,7 +188,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.send", args, outcome); + record_effect_if_recording(caller, "Tcp.send", args, outcome, caller_fn); Ok(true) } "tcp_ping" => { @@ -210,7 +211,7 @@ pub(super) fn dispatch( Err(e) => (host_result_err_unit_string(caller, &e)?, json_err(&e)), }; results[0] = Val::AnyRef(result_ref); - record_effect_if_recording(caller, "Tcp.ping", args, outcome); + record_effect_if_recording(caller, "Tcp.ping", args, outcome, caller_fn); Ok(true) } _ => Ok(false), diff --git a/src/main/run_wasm_gc/imports/terminal.rs b/src/main/run_wasm_gc/imports/terminal.rs index 697e419d..8019f8ff 100644 --- a/src/main/run_wasm_gc/imports/terminal.rs +++ b/src/main/run_wasm_gc/imports/terminal.rs @@ -14,6 +14,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -27,6 +28,7 @@ pub(super) fn dispatch( "Terminal.enableRawMode", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -40,6 +42,7 @@ pub(super) fn dispatch( "Terminal.disableRawMode", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -53,6 +56,7 @@ pub(super) fn dispatch( "Terminal.clear", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -72,6 +76,7 @@ pub(super) fn dispatch( "Terminal.moveTo", args, aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -90,6 +95,7 @@ pub(super) fn dispatch( "Terminal.print", args, aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -105,6 +111,7 @@ pub(super) fn dispatch( "Terminal.setColor", args, aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -118,6 +125,7 @@ pub(super) fn dispatch( "Terminal.resetColor", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -131,6 +139,7 @@ pub(super) fn dispatch( "Terminal.hideCursor", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -144,6 +153,7 @@ pub(super) fn dispatch( "Terminal.showCursor", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -157,6 +167,7 @@ pub(super) fn dispatch( "Terminal.flush", vec![], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } @@ -175,7 +186,7 @@ pub(super) fn dispatch( None => (host_option_string_none(caller)?, json_none()), }; results[0] = Val::AnyRef(opt_ref); - record_effect_if_recording(caller, "Terminal.readKey", vec![], outcome); + record_effect_if_recording(caller, "Terminal.readKey", vec![], outcome, caller_fn); Ok(true) } "terminal_size" => { @@ -198,6 +209,7 @@ pub(super) fn dispatch( ("height", aver::replay::JsonValue::Int(h)), ], ), + caller_fn, ); Ok(true) } diff --git a/src/main/run_wasm_gc/imports/time.rs b/src/main/run_wasm_gc/imports/time.rs index 9b396be7..35c8af6f 100644 --- a/src/main/run_wasm_gc/imports/time.rs +++ b/src/main/run_wasm_gc/imports/time.rs @@ -10,6 +10,7 @@ pub(super) fn dispatch( caller: &mut wasmtime::Caller<'_, RunWasmGcHost>, params: &[wasmtime::Val], results: &mut [wasmtime::Val], + caller_fn: &str, ) -> Result { use wasmtime::Val; match name { @@ -27,6 +28,7 @@ pub(super) fn dispatch( "Time.now", vec![], aver::replay::JsonValue::String(text), + caller_fn, ); Ok(true) } @@ -51,6 +53,7 @@ pub(super) fn dispatch( "Time.unixMs", vec![], aver::replay::JsonValue::Int(ms), + caller_fn, ); Ok(true) } @@ -67,6 +70,7 @@ pub(super) fn dispatch( "Time.sleep", vec![aver::replay::JsonValue::Int(ms)], aver::replay::JsonValue::Null, + caller_fn, ); Ok(true) } diff --git a/src/playground.rs b/src/playground.rs index d26e53c8..4327e74f 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -7,24 +7,13 @@ use crate::codegen; use crate::diagnostics::{AnalyzeOptions, analyze_source}; use crate::ir::{PipelineConfig, TypecheckMode}; use crate::source::{LoadedModule, load_module_tree_from_map, parse_source}; -#[cfg(feature = "runtime")] -use crate::{nan_value::Arena, vm}; - -/// Build the standalone aver_runtime wasm module bytes. Browser-side -/// hosts instantiate this once, then point every user.wasm's -/// `aver_runtime` import to its exports. -pub fn build_aver_runtime_wasm() -> Result, String> { - codegen::wasm::build_runtime_wasm() -} /// Compile Aver source text to WASM bytes via the wasm-gc backend. -/// -/// Playground migrated from `--target edge-wasm` to `--target wasm-gc` -/// in 0.16; the browser host (`tools/website/playground/wasm_host.js`) -/// expects wasm-gc binaries with engine GC + tail calls + per-type -/// factory exports for structured effect returns. The legacy -/// `codegen::wasm::emit_wasm` path is no longer reachable from this -/// entry point. +/// Playground exclusively targets wasm-gc since 0.16 (engine GC + +/// tail calls + factory exports for structured effect returns); the +/// legacy NaN-boxed emitter and its standalone `aver_runtime.wasm` +/// sidecar aren't reachable from any browser entry point and aren't +/// included in the `playground` feature build. pub fn compile_to_wasm(source: &str) -> Result, String> { let mut items = parse_source(source)?; @@ -106,6 +95,161 @@ pub fn compile_project_to_wasm( .map_err(|e| format!("{e}")) } +/// Multi-file project compile that targets a synthetic `__entry__` +/// fn instead of `main`. `expr` is parsed via `parse_entry_call` +/// (`add(7, 35)` → `("add", [Int(7), Int(35)])`); we look up the +/// target's signature from the entry source / loaded deps, then +/// inject a no-arg `fn __entry__()` whose body is `target(args…)` +/// with each Aver `Value` re-emitted as the corresponding AST +/// `Literal`. The compiler's `_start` synthesis prefers `__entry__` +/// when present, so the host invocation path stays unchanged — +/// `instance.exports._start()` runs the user expression instead of +/// `main`. Returns `(wasm_bytes, target_fn_name)` so callers can +/// label recordings with the user-facing fn name. +pub fn compile_project_to_wasm_with_entry( + files: &HashMap, + entry: &str, + expr: &str, +) -> Result<(Vec, String), String> { + let entry_source = files + .get(entry) + .ok_or_else(|| format!("Entry '{}' not present in file map", entry))?; + let mut entry_items = parse_source(entry_source)?; + let root_depends = module_depends(&entry_items); + let loaded = load_module_tree_from_map(&root_depends, files)?; + + let (target_fn, args) = + crate::replay::parse_entry_call(expr).map_err(|e| format!("--expr parse: {}", e))?; + let (return_type, _effects) = lookup_fn_signature(&entry_items, &loaded, &target_fn) + .ok_or_else(|| format!("entry fn `{}` not found in project", target_fn))?; + + let synth = build_synth_entry_fn(&target_fn, &args, &return_type)?; + entry_items.push(synth); + + let pipeline_result = crate::ir::pipeline::run( + &mut entry_items, + PipelineConfig { + typecheck: Some(TypecheckMode::WithLoaded(&loaded)), + run_interp_lower: false, + run_buffer_build: false, + ..Default::default() + }, + ); + let tc_result = pipeline_result.typecheck.expect("typecheck was requested"); + if !tc_result.errors.is_empty() { + return Err(format_tc_errors(&tc_result.errors)); + } + + let modules: Vec = loaded + .into_iter() + .map(|m| loaded_to_module_info(m, false)) + .collect(); + codegen::wasm_gc::flatten_multimodule(&mut entry_items, &modules); + crate::ir::pipeline::resolve(&mut entry_items); + let bytes = + codegen::wasm_gc::compile_to_wasm_gc(&entry_items, pipeline_result.analysis.as_ref()) + .map_err(|e| format!("{e}"))?; + Ok((bytes, target_fn)) +} + +/// Find a fn def by name across the entry source and any loaded +/// dependency module. Returns `(return_type, effects)` so the +/// synthetic `__entry__` can mirror the target's signature shape. +/// Multi-module flatten happens AFTER synth injection, so dep fns +/// are still siloed under their `LoadedModule.items` here — both +/// places have to be searched. +fn lookup_fn_signature( + entry_items: &[crate::ast::TopLevel], + loaded: &[LoadedModule], + target: &str, +) -> Option<(String, Vec>)> { + let scan = + |items: &[crate::ast::TopLevel]| -> Option<(String, Vec>)> { + for item in items { + if let crate::ast::TopLevel::FnDef(fd) = item + && fd.name == target + { + return Some((fd.return_type.clone(), fd.effects.clone())); + } + } + None + }; + if let Some(s) = scan(entry_items) { + return Some(s); + } + for m in loaded { + if let Some(s) = scan(&m.items) { + return Some(s); + } + } + None +} + +/// Build `fn __entry__() -> : target(args…)` as a +/// `TopLevel::FnDef`. Each `Value` arg lowers to the matching +/// `Expr::Literal`. Compound shapes (`List`, `Tuple`, `Variant`, +/// `Record`) raise an error — extending `value_to_literal_expr` +/// to cover them is a follow-up. Effects are declared as +/// `! [target]` so the verify pass sees the user fn in the +/// surface and module-level `effects [...]` lists. +fn build_synth_entry_fn( + target_fn: &str, + args: &[crate::value::Value], + return_type: &str, +) -> Result { + use crate::ast::{Expr, FnBody, FnDef, Spanned, Stmt, TopLevel}; + let arg_exprs: Vec> = args + .iter() + .map(value_to_literal_expr) + .collect::>()?; + let callee = Spanned::bare(Expr::Ident(target_fn.to_string())); + let call = Spanned::bare(Expr::FnCall(Box::new(callee), arg_exprs)); + let body = FnBody::Block(vec![Stmt::Expr(call)]); + Ok(TopLevel::FnDef(FnDef { + name: "__entry__".to_string(), + line: 0, + params: vec![], + return_type: return_type.to_string(), + effects: vec![Spanned::bare(target_fn.to_string())], + desc: None, + body: std::sync::Arc::new(body), + resolution: None, + })) +} + +/// Convert a `Value` literal back into its AST shape so the +/// synthetic entry body type-checks under the same path as a +/// hand-written call site. Supported: Int / Float / Bool / Str / +/// Unit. Compound shapes (lists, tuples, variants, records) raise +/// an error — extending the mapper to cover them is a follow-up. +fn value_to_literal_expr( + v: &crate::value::Value, +) -> Result, String> { + use crate::ast::{Expr, Literal, Spanned}; + let lit = match v { + crate::value::Value::Int(n) => Literal::Int(*n), + crate::value::Value::Float(f) => Literal::Float(*f), + crate::value::Value::Str(s) => Literal::Str(s.clone()), + crate::value::Value::Bool(b) => Literal::Bool(*b), + crate::value::Value::Unit => Literal::Unit, + other => { + return Err(format!( + "synthetic `__entry__` only supports Int/Float/Bool/String/Unit args today; got {:?}", + other + )); + } + }; + Ok(Spanned::bare(Expr::Literal(lit))) +} + +/// Re-exported for the wasm-bindgen `aver_parse_entry_target` arm. +/// Gated on the `playground` feature so the symbol mirrors the +/// `bindgen` module's visibility — non-playground builds drop both. +#[cfg(feature = "playground")] +fn crate_parse_entry_call(expr: &str) -> Result<(String, Vec), String> { + crate::replay::parse_entry_call(expr) +} + // ── Proof export & Rust compile entry points ──────────────────────── // // Single-file source → backend project files (path → content map). @@ -569,301 +713,6 @@ pub fn format_source(source: &str) -> String { .unwrap_or_else(|_| source.to_string()) } -// ── Record / replay ──────────────────────────────────────────────── -// Runs `fn main` through the in-browser VM, captures every effect -// call as a SessionRecording, and returns the recording as JSON. -// Replay loads such a recording back and checks that the program -// reproduces the same effect trace — byte-for-byte parity with the -// CLI's `aver run --record` / `aver replay`. - -#[cfg(feature = "runtime")] -#[derive(serde::Serialize)] -struct RunRecordResult { - ok: bool, - recording: Option, - error: Option, - effect_count: u32, - runtime_error: Option, -} - -#[cfg(feature = "runtime")] -pub fn run_record_project(files: &HashMap, entry: &str) -> String { - run_record_project_with_entry(files, entry, None) -} - -#[cfg(feature = "runtime")] -pub fn run_record_project_with_entry( - files: &HashMap, - entry: &str, - entry_expr: Option<&str>, -) -> String { - let Some(entry_source) = files.get(entry).cloned() else { - return err_json(format!("Entry '{}' not in virtual fs", entry)); - }; - match run_record_inner(&entry_source, files, entry, entry_expr) { - Ok(res) => serde_json::to_string(&res).unwrap_or_else(|_| "{}".to_string()), - Err(e) => err_json(e), - } -} - -#[cfg(feature = "runtime")] -fn run_record_inner( - entry_source: &str, - files: &HashMap, - entry: &str, - entry_expr: Option<&str>, -) -> Result { - use crate::replay::json::JsonValue; - use crate::replay::session::{RecordedOutcome, SessionRecording}; - use crate::replay::{ - encode_entry_args, parse_entry_call, session_recording_to_string_pretty, - value_to_json_lossy, - }; - - let (mut items, loaded) = parse_and_load(entry_source, files, entry)?; - - // Full pipeline. Recordings store effects, not IR shape — running - // interp_lower + buffer_build during record produces the same - // effect sequence as the un-lowered run (matches replay_cmd.rs fix). - let pipeline_result = crate::ir::pipeline::run( - &mut items, - PipelineConfig { - typecheck: Some(TypecheckMode::WithLoaded(&loaded)), - ..Default::default() - }, - ); - let tc_result = pipeline_result.typecheck.expect("typecheck was requested"); - if !tc_result.errors.is_empty() { - return Err(format_tc_errors(&tc_result.errors)); - } - - let mut arena = Arena::new(); - vm::register_service_types(&mut arena); - let (code, globals) = vm::compile_program_with_loaded_modules( - &items, - &mut arena, - loaded, - "", - pipeline_result.analysis.as_ref(), - ) - .map_err(|e| format!("Compile error: {}", e.msg))?; - - let mut machine = vm::VM::new(code, globals, arena); - machine.set_silent_console(true); - // Safety net — a game with no quit path (Terminal.readKey always - // returning None under the stubs) would otherwise loop forever on - // the wasm main thread. 10k effects is way above any sensible - // real program and short-circuits to a clear error fast. - machine.set_record_cap(Some(10_000)); - machine.start_recording(); - - // Resolve the entry: either a user-supplied call expression or `main`. - let entry_info: Option<(String, Vec)> = match entry_expr { - Some(src) => Some(parse_entry_call(src)?), - None => None, - }; - - let mut runtime_error: Option = None; - let run_result = if let Some((fn_name, args)) = &entry_info { - machine.run_top_level().and_then(|_| { - use crate::nan_value::{NanValue, NanValueConvert}; - let nv_args: Vec = args - .iter() - .map(|v| NanValue::from_value(v, &mut machine.arena)) - .collect(); - machine.run_named_function(fn_name, &nv_args) - }) - } else { - machine.run() - }; - - let output = match run_result { - Ok(val) => { - // Recorded outcome is only used by replay-validation; the - // browser usually drives Unit out of main, so a lossy - // representation is fine. If a program returns a rich - // value we'll fall back to its `aver_repr`. - use crate::nan_value::NanValueConvert; - let value = val.to_value(&machine.arena); - RecordedOutcome::Value(value_to_json_lossy(&value)) - } - Err(e) => { - let msg = e.to_string(); - runtime_error = Some(msg.clone()); - RecordedOutcome::RuntimeError(msg) - } - }; - - let (entry_fn, input_json) = match &entry_info { - Some((name, args)) => ( - name.clone(), - encode_entry_args(args).unwrap_or(JsonValue::Null), - ), - None => ("main".to_string(), JsonValue::Null), - }; - - let recording = SessionRecording { - schema_version: 1, - request_id: "playground".to_string(), - timestamp: String::new(), - program_file: entry.to_string(), - module_root: "".to_string(), - entry_fn, - input: input_json, - effects: machine.recorded_effects().to_vec(), - output, - }; - - Ok(RunRecordResult { - ok: true, - effect_count: recording.effects.len() as u32, - recording: Some(session_recording_to_string_pretty(&recording)), - error: None, - runtime_error, - }) -} - -#[cfg(feature = "runtime")] -#[derive(serde::Serialize)] -struct ReplayResult { - ok: bool, - matched: bool, - replayed: u32, - total: u32, - error: Option, -} - -#[cfg(feature = "runtime")] -pub fn replay_run_project( - files: &HashMap, - entry: &str, - recording_json: &str, -) -> String { - match replay_run_inner(files, entry, recording_json) { - Ok(r) => serde_json::to_string(&r).unwrap_or_else(|_| "{}".to_string()), - Err(e) => serde_json::to_string(&ReplayResult { - ok: false, - matched: false, - replayed: 0, - total: 0, - error: Some(e), - }) - .unwrap_or_else(|_| "{}".to_string()), - } -} - -#[cfg(feature = "runtime")] -fn replay_run_inner( - files: &HashMap, - entry: &str, - recording_json: &str, -) -> Result { - use crate::nan_value::{NanValue, NanValueConvert}; - use crate::replay::json::JsonValue; - use crate::replay::json_to_value; - use crate::replay::session::parse_session_recording; - use crate::value::{Value, list_to_vec}; - - let Some(entry_source) = files.get(entry).cloned() else { - return Err(format!("Entry '{}' not in virtual fs", entry)); - }; - let recording = parse_session_recording(recording_json)?; - let total = recording.effects.len() as u32; - - let (mut items, loaded) = parse_and_load(&entry_source, files, entry)?; - - // Full pipeline — recordings store effects, not IR shape (mirrors - // replay_cmd.rs and the playground record path). - let pipeline_result = crate::ir::pipeline::run( - &mut items, - PipelineConfig { - typecheck: Some(TypecheckMode::WithLoaded(&loaded)), - ..Default::default() - }, - ); - let tc_result = pipeline_result.typecheck.expect("typecheck was requested"); - if !tc_result.errors.is_empty() { - return Err(format_tc_errors(&tc_result.errors)); - } - - let mut arena = Arena::new(); - vm::register_service_types(&mut arena); - let (code, globals) = vm::compile_program_with_loaded_modules( - &items, - &mut arena, - loaded, - "", - pipeline_result.analysis.as_ref(), - ) - .map_err(|e| format!("Compile error: {}", e.msg))?; - - let mut machine = vm::VM::new(code, globals, arena); - machine.set_silent_console(true); - machine.start_replay(recording.effects, true); - - // Replay respects the recording's own entry_fn and input, not just main — - // otherwise playground replay would miscompare recordings made with a - // custom entry point. - let run_err = if recording.entry_fn == "main" && matches!(recording.input, JsonValue::Null) { - machine.run().err().map(|e| e.to_string()) - } else { - let top_err = machine.run_top_level().err().map(|e| e.to_string()); - if let Some(err) = top_err { - Some(err) - } else { - let args: Vec = match json_to_value(&recording.input) { - Ok(Value::Unit) => vec![], - Ok(v) => list_to_vec(&v).unwrap_or_else(|| vec![v]), - Err(e) => return Err(e), - }; - let nv_args: Vec = args - .iter() - .map(|v| NanValue::from_value(v, &mut machine.arena)) - .collect(); - machine - .run_named_function(&recording.entry_fn, &nv_args) - .err() - .map(|e| e.to_string()) - } - }; - let consumed = machine.ensure_replay_consumed(); - let (replayed, _remaining) = machine.replay_progress(); - - let error = run_err.or_else(|| consumed.err().map(|e| e.to_string())); - - Ok(ReplayResult { - ok: true, - matched: error.is_none() && replayed as u32 == total, - replayed: replayed as u32, - total, - error, - }) -} - -#[cfg(feature = "runtime")] -fn parse_and_load( - entry_source: &str, - files: &HashMap, - _entry: &str, -) -> Result<(Vec, Vec), String> { - let items = parse_source(entry_source)?; - let depends = module_depends(&items); - let loaded = load_module_tree_from_map(&depends, files)?; - Ok((items, loaded)) -} - -#[cfg(feature = "runtime")] -fn err_json(msg: String) -> String { - serde_json::to_string(&RunRecordResult { - ok: false, - recording: None, - error: Some(msg), - effect_count: 0, - runtime_error: None, - }) - .unwrap_or_else(|_| "{}".to_string()) -} - #[cfg(feature = "playground")] mod bindgen { use wasm_bindgen::prelude::*; @@ -894,14 +743,6 @@ mod bindgen { super::compile_to_wasm(source).map_err(|e| JsError::new(&e)) } - /// Bytes of the standalone aver_runtime wasm module. Worker-side - /// instantiates this once and feeds its exports as the - /// `aver_runtime` import of every compiled user.wasm. - #[wasm_bindgen] - pub fn aver_runtime_wasm() -> Result, JsError> { - super::build_aver_runtime_wasm().map_err(|e| JsError::new(&e)) - } - /// Compile a multi-file project. `files_json` is a JSON object /// mapping path -> source (e.g. `{"types.av": "...", "main.av": /// "..."}`). `entry` is the key of the entry file. @@ -912,6 +753,33 @@ mod bindgen { super::compile_project_to_wasm(&files, entry).map_err(|e| JsError::new(&e)) } + /// Compile a project that targets `expr` (e.g. `add(7, 35)`) + /// instead of `main`. Wraps the call in a synthetic `__entry__` + /// fn the codegen wires `_start` through, so the playground + /// worker can run user expressions on the native wasm-gc path + /// without any JS-side argument encoder. + #[wasm_bindgen] + pub fn aver_compile_project_with_entry( + files_json: &str, + entry: &str, + expr: &str, + ) -> Result, JsError> { + let files: std::collections::HashMap = + serde_json::from_str(files_json).map_err(|e| JsError::new(&e.to_string()))?; + let (bytes, _target_fn) = super::compile_project_to_wasm_with_entry(&files, entry, expr) + .map_err(|e| JsError::new(&e))?; + Ok(bytes) + } + + /// Resolve the user-facing fn name that `expr` calls. Returns + /// just the name half of `parse_entry_call(expr)` so the JS host + /// can label recordings without re-parsing the call expression. + #[wasm_bindgen] + pub fn aver_parse_entry_target(expr: &str) -> Result { + let (name, _args) = super::crate_parse_entry_call(expr).map_err(|e| JsError::new(&e))?; + Ok(name) + } + #[wasm_bindgen] pub fn aver_check(source: &str) -> String { super::check_source(source) @@ -1071,44 +939,8 @@ mod bindgen { Ok(super::audit_project_hostile(&files, entry)) } - // ── Record / replay bindings ─────────────────────────────────── - // Project-shaped on purpose: single-file callers pass - // `{"playground.av": source}` with entry "playground.av". - - #[wasm_bindgen] - pub fn aver_run_record(files_json: &str, entry: &str) -> Result { - let files = parse_files(files_json)?; - Ok(super::run_record_project(&files, entry)) - } - - /// Record a run starting from an arbitrary call expression instead of - /// `main`. `entry_expr` must be a function call with literal arguments - /// (String / Int / Float / Bool / Unit) — same constraints as `aver run - /// --expr` on the CLI. The resulting recording has `entry_fn` and - /// `input` populated accordingly and can be replayed unchanged. - #[wasm_bindgen] - pub fn aver_run_record_entry( - files_json: &str, - entry: &str, - entry_expr: &str, - ) -> Result { - let files = parse_files(files_json)?; - Ok(super::run_record_project_with_entry( - &files, - entry, - Some(entry_expr), - )) - } - - #[wasm_bindgen] - pub fn aver_replay_run( - files_json: &str, - entry: &str, - recording_json: &str, - ) -> Result { - let files = parse_files(files_json)?; - Ok(super::replay_run_project(&files, entry, recording_json)) - } + // Record / replay run on the JS side via `aver_compile_project*` + // + WebWorker wasm-gc, no Rust-hosted bindings needed. } #[cfg(test)] @@ -1273,92 +1105,6 @@ fn main() -> Int ); } - // Only meaningful when `terminal` feature is off — otherwise the - // real crossterm impl drives the effects and can fail outside a - // TTY (common in CI). The playground (wasm32-unknown-unknown) - // always ships without `terminal`, which is exactly this path. - #[cfg(not(feature = "terminal"))] - #[test] - fn records_terminal_effects_in_playground_build() { - // Snake uses Terminal.* extensively. In the playground build - // (no `terminal` feature → crossterm unavailable) the stubs in - // vm/builtin.rs should let the VM record each call with a Unit - // outcome instead of surfacing "not available in this build". - let mut files = HashMap::new(); - files.insert( - "playground.av".to_string(), - [ - "module Main", - " intent = \"terminal smoke\"", - "", - "fn main() -> Unit", - " ! [Terminal.enableRawMode, Terminal.clear, Terminal.disableRawMode]", - " Terminal.enableRawMode()", - " Terminal.clear()", - " Terminal.disableRawMode()", - "", - ] - .join("\n"), - ); - let record: serde_json::Value = - serde_json::from_str(&run_record_project(&files, "playground.av")).unwrap(); - assert_eq!( - record["ok"], true, - "should record terminal stubs: {}", - record - ); - assert_eq!(record["effect_count"], 3, "three terminal calls"); - assert!( - record["runtime_error"].is_null(), - "terminal stubs shouldn't raise: {}", - record["runtime_error"] - ); - - let replay: serde_json::Value = serde_json::from_str(&replay_run_project( - &files, - "playground.av", - record["recording"].as_str().unwrap(), - )) - .unwrap(); - assert_eq!(replay["matched"], true, "replay should match: {}", replay); - } - - #[test] - fn run_record_captures_effects_then_replays_clean() { - let mut files = HashMap::new(); - files.insert( - "playground.av".to_string(), - [ - "module Main", - " intent = \"record/replay smoke\"", - "", - "fn main() -> Unit", - " ! [Console.print]", - " Console.print(\"hello\")", - " Console.print(\"world\")", - "", - ] - .join("\n"), - ); - - let record_json = run_record_project(&files, "playground.av"); - let record: serde_json::Value = serde_json::from_str(&record_json).unwrap(); - assert_eq!(record["ok"], true, "record should succeed: {}", record_json); - assert_eq!(record["effect_count"], 2, "two Console.print calls"); - let recording_str = record["recording"].as_str().expect("recording string"); - - let replay_json = replay_run_project(&files, "playground.av", recording_str); - let replay: serde_json::Value = serde_json::from_str(&replay_json).unwrap(); - assert_eq!(replay["ok"], true); - assert_eq!( - replay["matched"], true, - "replay should match captured effects: {}", - replay_json - ); - assert_eq!(replay["replayed"], 2); - assert_eq!(replay["total"], 2); - } - #[test] fn multi_file_check_has_no_unknown_ident_noise() { let files = load_rogue_files(); diff --git a/tools/website/index.html b/tools/website/index.html index 1f7dddea..97674154 100644 --- a/tools/website/index.html +++ b/tools/website/index.html @@ -220,42 +220,42 @@

What Aver deliberately omits

Native WASM, shared runtime

-

Seven games compiled directly from Aver to WebAssembly GC. Engine handles GC and tail calls — no NaN-boxing, no custom heap. Snake ships at 4.0 KiB; a full roguelike with procedural generation is 25.0 KiB. Modern browsers only (Chrome 119+ / Firefox 120+ / Safari 18.2+).

+

Seven games compiled directly from Aver to WebAssembly GC. Engine handles GC and tail calls — no NaN-boxing, no custom heap. Snake ships at 4.8 KiB; a full roguelike with procedural generation is 31.1 KiB. Modern browsers only (Chrome 119+ / Firefox 120+ / Safari 18.2+).

diff --git a/tools/website/playground/app.js b/tools/website/playground/app.js index 9fc0da4f..89536609 100644 --- a/tools/website/playground/app.js +++ b/tools/website/playground/app.js @@ -242,6 +242,76 @@ function handleWorkerMessage(event) { setStatus("Game ended.", "idle"); } break; + case "trace-effect": + // Per-effect stream from the worker. Mirror it into a + // main-thread buffer so a Stop click mid-game (which + // forcibly terminate()s the worker) still has every + // effect captured up to that point. + if (state.recordingBuffer && message.effect) { + state.recordingBuffer.push(message.effect); + // Coarse status update so we don't repaint the line on + // every effect for high-throughput traces (rogue can + // emit thousands per second). + if (state.recordingMeta && state.recordingBuffer.length % 25 === 0) { + setStatus( + `Recording: ${state.recordingBuffer.length} effect(s)… (click Stop to finish)`, + "info" + ); + } + } + break; + case "trace-cap": + state.recordingCapped = true; + setStatus( + `Trace cap reached at ${message.count} effect(s); program keeps running but new effects aren't recorded. Click Stop to finalise.`, + "info" + ); + break; + case "record-finished": + if (state.worker) { + state.worker.terminate(); + state.worker = null; + } + setRawMode(false); + dom.runButton.disabled = false; + dom.stopButton.disabled = true; + dom.stopButton.hidden = true; + if (!message.ok) { + setStatus("Record failed", "error"); + appendConsole("stderr", message.error || "unknown error"); + if (state.recordResolve) { + state.recordResolve({ ok: false, error: message.error }); + state.recordResolve = null; + } + } else if (state.recordResolve) { + state.recordResolve({ + ok: true, + recording: message.recording, + effect_count: message.effect_count, + }); + state.recordResolve = null; + } + state.recordingBuffer = null; + state.recordingMeta = null; + break; + case "replay-finished": + if (state.worker) { + state.worker.terminate(); + state.worker = null; + } + setRawMode(false); + if (state.replayResolve) { + state.replayResolve({ + ok: !!message.ok, + matched: !!message.matched, + replayed: message.replayed, + total: message.total, + args_diffs: message.args_diffs, + error: message.error || null, + }); + state.replayResolve = null; + } + break; default: break; } @@ -351,6 +421,14 @@ async function runSelectedModule(fixedSize) { } function stopRun() { + // Mid-recording stop: finalise the trace from the main-thread + // mirror buffer before tearing down the worker. Lets the user + // play through an interactive program (snake, checkers, …), + // click Stop when they're satisfied with the trace, and walk + // away with every effect captured up to that moment. + const recordingBuffer = state.recordingBuffer; + const recordingMeta = state.recordingMeta; + const recordResolve = state.recordResolve; if (state.worker) { state.worker.terminate(); state.worker = null; @@ -360,6 +438,32 @@ function stopRun() { dom.stopButton.disabled = true; dom.stopButton.hidden = true; setRawMode(false); + if (recordingBuffer && recordResolve) { + const recording = { + schema_version: 1, + request_id: `rec-${Date.now()}`, + timestamp: `unix-${Math.floor(Date.now() / 1000)}`, + program_file: recordingMeta?.program_file ?? "playground.av", + module_root: recordingMeta?.module_root ?? ".", + entry_fn: recordingMeta?.entry_fn ?? "main", + input: null, + effects: recordingBuffer, + output: { kind: "value", value: null }, + }; + state.recordingBuffer = null; + state.recordingMeta = null; + state.recordResolve = null; + recordResolve({ + ok: true, + recording, + effect_count: recordingBuffer.length, + }); + setStatus( + `Stopped recording at ${recordingBuffer.length} effect(s).`, + "success" + ); + return; + } setStatus("Run stopped.", "idle"); } @@ -2747,43 +2851,108 @@ async function doRecord(skipDownload = true, entryExpr = null) { setStatus("Nothing to record.", "error"); return; } - // Remember the entry expression so ↻ Re-record runs the same call instead - // of silently falling back to main(). Cleared when Record (main) is used. + // Per-fn entry recording (`-e 'fn(args)'`) — compile a synthetic + // `__entry__()` wrapper that calls the user expression with its + // literal args, then drive the same WebWorker record session + // `main` recordings use. The wasm-gc `_start` synthesis prefers + // `__entry__` when present, so the host invocation path stays + // unchanged; only the trace's `entry_fn` label flips to the + // user-facing name (`add` instead of `__entry__` / `main`). state.lastEntryExpr = entryExpr || null; try { const comp = await loadCompiler(); setStatus(entryExpr ? `Recording ${entryExpr}…` : "Recording…", "info"); - const json = entryExpr - ? comp.aver_run_record_entry(JSON.stringify(filesObj), entry, entryExpr) - : comp.aver_run_record(JSON.stringify(filesObj), entry); - const res = JSON.parse(json); - if (!res.ok) { - setStatus("Record failed", "error"); - appendConsole("stderr", res.error || "unknown error"); - return; - } - let parsed; + // Compile the source to wasm-gc bytes via `aver_compile_project` + // (or the synthetic-`__entry__` variant when the user supplied + // `-e 'fn(args)'`), then drive a record session on the + // WebWorker so the program runs natively under V8 wasm-gc + // instead of the VM-in-wasm32 bridge. Effect outcomes flow + // through the worker's `AverBrowserHost.recordOrDispatch`, + // which appends to the trace as the wasm-gc CLI host does. + let wasmBytes; + let entryLabel = "main"; try { - parsed = JSON.parse(res.recording); + if (entryExpr) { + wasmBytes = comp.aver_compile_project_with_entry( + JSON.stringify(filesObj), + entry, + entryExpr, + ); + entryLabel = comp.aver_parse_entry_target(entryExpr); + } else { + wasmBytes = comp.aver_compile_project(JSON.stringify(filesObj), entry); + } } catch (e) { - setStatus("Recording parse failed", "error"); - appendConsole("stderr", e.message); + appendConsole("stderr", e.message || String(e)); + setStatus("Compile failed", "error"); return; } - setRecording(parsed); - // Cap-hit is a soft stop: we still keep everything recorded - // before the cap. Surface it as info instead of error so the - // user reads it as "you can still work with this prefix". - const capped = /record cap reached/i.test(res.runtime_error || ""); - if (capped) { - setStatus( - `Recorded ${res.effect_count} effects (capped — program was still running, trace is a prefix)`, - "info" + // Live-trace mirror in main thread. Recordings used to live + // only inside the worker, so a Stop click (which terminates + // the worker) lost the in-flight trace. Now every + // `trace-effect` message lands here, and `stopRun()` finalises + // a recording from this buffer instead of waiting for + // record-finished to ship the full trace. + state.recordingBuffer = []; + state.recordingCapped = false; + state.recordingMeta = { + program_file: entry, + module_root: ".", + entry_fn: entryLabel, + }; + // Surface the same Run-style UI affordances: clear the + // previous output, switch the output pane to terminal vs + // console (so TUI games actually render — without + // `setOutputMode` the terminal element is hidden and snake + // looks like nothing's happening), enable Stop, focus the + // terminal so keypresses route into the worker. + clearOutput(); + setRawMode(false); + const usesTerminal = Array.from(Object.values(filesObj)).some((s) => + s.includes("Terminal."), + ); + setOutputMode(usesTerminal ? "terminal" : "console"); + const readlineBar = document.querySelector("[data-readline-bar]"); + if (readlineBar) { + readlineBar.style.display = usesTerminal ? "none" : "flex"; + } + dom.runButton.disabled = true; + dom.stopButton.disabled = false; + dom.stopButton.hidden = false; + dom.terminal.dataset.empty = "false"; + dom.terminal.focus({ preventScroll: true }); + // Reuse `spawnWorker` so the worker gets the same init + // sequence Run uses — `init-input` ships the SharedArrayBuffer + // key + line queues, `resize` reports current terminal cols + // /rows. Without those games never see keypresses and the + // terminal stays at the 80×35 default no matter what the + // playground UI shows. + const worker = spawnWorker(); + const result = await new Promise((resolve) => { + state.recordResolve = resolve; + // `aver_compile_project` returns a Uint8Array view; the + // underlying ArrayBuffer is what `postMessage` accepts as + // a transferable. Passing the Uint8Array itself as a + // transferable item raises `invalid transferable array + // for structured clone`. + worker.postMessage( + { + type: "record", + wasmBytes, + programArgs: state.programArgs ?? [], + programFile: entry, + moduleRoot: ".", + entryLabel, + }, + [wasmBytes.buffer], ); - } else { - const note = res.runtime_error ? ` (main threw: ${res.runtime_error})` : ""; - setStatus(`Recorded ${res.effect_count} effect(s)${note}`, "success"); + }); + if (!result.ok) { + setStatus("Record failed", "error"); + return; } + setRecording(result.recording); + setStatus(`Recorded ${result.effect_count} effect(s)`, "success"); } catch (e) { appendConsole("stderr", e.message || String(e)); setStatus("Record failed", "error"); @@ -2804,36 +2973,78 @@ async function doReplay() { try { const comp = await loadCompiler(); setStatus("Replaying…", "info"); - const json = comp.aver_replay_run( - JSON.stringify(filesObj), - entry, - state.lastRecording - ); - const res = JSON.parse(json); - if (!res.ok) { - setStatus("Replay failed", "error"); - appendConsole("stderr", res.error || "unknown error"); + // Compile to wasm-gc bytes, then drive a replay session on + // the WebWorker. Native wasm-gc + AverBrowserHost.recordOrDispatch + // pulls every effect outcome from the trace via the worker's + // primed EffectReplayState — same contract `aver replay + // --wasm-gc` uses on the CLI. + let wasmBytes; + try { + wasmBytes = comp.aver_compile_project(JSON.stringify(filesObj), entry); + } catch (e) { + appendConsole("stderr", e.message || String(e)); + setStatus("Compile failed", "error"); return; } - const exhausted = - /Replay exhausted/i.test(res.error || "") && res.replayed === res.total; + const recordingObj = + typeof state.lastRecording === "string" + ? JSON.parse(state.lastRecording) + : state.lastRecording; + // Same init sequence Run uses (see the comment in `doRecord`) + // — without `init-input` + `resize` the worker has no key + // buffer or terminal dimensions and replays of interactive + // programs come up blank. + clearOutput(); + setRawMode(false); + const usesTerminalReplay = Array.from(Object.values(filesObj)).some((s) => + s.includes("Terminal."), + ); + setOutputMode(usesTerminalReplay ? "terminal" : "console"); + dom.terminal.dataset.empty = "false"; + dom.terminal.focus({ preventScroll: true }); + const worker = spawnWorker(); + const result = await new Promise((resolve) => { + state.replayResolve = resolve; + // Transfer the ArrayBuffer, not the Uint8Array view — + // see the matching note in `doRecord`. + worker.postMessage( + { + type: "replay", + wasmBytes, + recording: recordingObj, + checkArgs: false, + programArgs: state.programArgs ?? [], + }, + [wasmBytes.buffer], + ); + }); let kind, summary; - if (res.matched) { + if (result.matched) { kind = "match"; - summary = `Replay matched · ${res.replayed}/${res.total} effects`; - } else if (exhausted) { + summary = `Replay matched · ${result.replayed}/${result.total} effects`; + if (result.args_diffs > 0) { + summary += ` · ${result.args_diffs} arg-diff warning(s)`; + } + } else if ( + result.error && + /Replay exhausted/i.test(result.error) && + result.replayed === result.total + ) { kind = "prefix"; - summary = `Prefix replayed · ${res.replayed}/${res.total} · program continued past the recorded trace`; + summary = `Prefix replayed · ${result.replayed}/${result.total} · program continued past the recorded trace`; } else { kind = "diverge"; - const short = (res.error || "divergence").replace( + const short = (result.error || "divergence").replace( /^Runtime error \[line \d+\]:\s*/i, - "" + "", ); - summary = `Replay diverged at ${res.replayed}/${res.total} · ${short}`; + summary = `Replay diverged at ${result.replayed}/${result.total} · ${short}`; } state.lastReplayResult = { kind, summary }; - setStatus(summary, kind === "diverge" ? "error" : kind === "prefix" ? "info" : "success"); + setStatus( + summary, + kind === "diverge" ? "error" : kind === "prefix" ? "info" : "success", + ); renderRecordingPanel(); } catch (e) { appendConsole("stderr", e.message || String(e)); diff --git a/tools/website/playground/checkers.wasm b/tools/website/playground/checkers.wasm index e554878e..48969772 100644 Binary files a/tools/website/playground/checkers.wasm and b/tools/website/playground/checkers.wasm differ diff --git a/tools/website/playground/doom.wasm b/tools/website/playground/doom.wasm index 1fa5582d..b6bc2bc2 100644 Binary files a/tools/website/playground/doom.wasm and b/tools/website/playground/doom.wasm differ diff --git a/tools/website/playground/index.html b/tools/website/playground/index.html index bf7fa9f6..5bd0375b 100644 --- a/tools/website/playground/index.html +++ b/tools/website/playground/index.html @@ -34,13 +34,13 @@ What is Aver? About WASM GitHub @@ -225,7 +225,7 @@

Native WASM

Tiny binaries - Snake ships at 4.0 KiB. Tetris is 7.8 KiB. A full roguelike with procedural generation is 25.0 KiB. Built with --target wasm-gc --optimize size — engine GC + native tail-calls; per-program binary, no shared runtime to fetch. + Snake ships at 4.8 KiB. Tetris is 9.8 KiB. A full roguelike with procedural generation is 31.1 KiB. Built with --target wasm-gc --optimize size — engine GC + native tail-calls; per-program binary, no shared runtime to fetch.
Capability-based imports diff --git a/tools/website/playground/life.wasm b/tools/website/playground/life.wasm index c1a55e18..8f169d52 100644 Binary files a/tools/website/playground/life.wasm and b/tools/website/playground/life.wasm differ diff --git a/tools/website/playground/replay_state.js b/tools/website/playground/replay_state.js new file mode 100644 index 00000000..a050d8cc --- /dev/null +++ b/tools/website/playground/replay_state.js @@ -0,0 +1,256 @@ +// Browser-side mirror of `aver::replay::EffectReplayState`. Drives +// `--record` / `--replay` semantics for the native wasm-gc playground +// path: the host (`AverBrowserHost`) routes every effect call through +// `record_or_dispatch`, which appends to the trace in Recording mode +// or pulls the next outcome from the trace in Replay mode. +// +// Trace JSON is byte-compatible with the VM / self-host / native +// wasm-gc CLI recorders: same `(group_id, branch_path, +// effect_occurrence)` tuple, same `$ok` / `$err` / `$some` / `$none` +// / `$record` markers, same `RecordedOutcome::Value` / +// `RuntimeError` discriminator. A trace dropped onto the playground +// from `aver run --record` (any backend) replays through this +// machinery; a trace recorded here downloads as a plain `.replay.json` +// the CLI replayer accepts. + +export const REPLAY_MODE = Object.freeze({ + NORMAL: "normal", + RECORDING: "recording", + REPLAYING: "replaying", +}); + +/// Soft ceiling on recorded effects per session. Open-loop programs +/// (TUI games, REPLs) easily generate hundreds of thousands of +/// effects per minute; once we hit the cap we stop appending new +/// entries (and stop streaming `trace-effect` postMessages) so +/// neither the worker's heap nor the main thread's per-effect +/// rerender tank the tab. The user can still click Stop at any +/// moment to finalise whatever the recorder accumulated up to that +/// point. +export const RECORDING_CAP = 10_000; + +export class EffectReplayState { + constructor() { + this.mode = REPLAY_MODE.NORMAL; + this.recordedEffects = []; + this.recordingCapped = false; + // Replay-only state. + this.replayEffects = []; + this.replayPos = 0; + this.checkArgs = false; + this.argsDiffCount = 0; + // Structural-scope tracking — independent products (`?!` / `!`). + this.nextGroupId = 0; + this.groupStack = []; + this.branchStack = []; + this.effectCountStack = []; + } + + setNormal() { + this.mode = REPLAY_MODE.NORMAL; + this.replayEffects = []; + this.replayPos = 0; + this.argsDiffCount = 0; + } + + startRecording() { + this.mode = REPLAY_MODE.RECORDING; + this.recordedEffects = []; + this.recordingCapped = false; + this.replayEffects = []; + this.replayPos = 0; + this.argsDiffCount = 0; + this.resetScope(); + } + + startReplay(effects, validateArgs) { + this.mode = REPLAY_MODE.REPLAYING; + this.replayEffects = Array.isArray(effects) ? effects.slice() : []; + this.replayPos = 0; + this.argsDiffCount = 0; + this.checkArgs = !!validateArgs; + this.resetScope(); + } + + resetScope() { + this.nextGroupId = 0; + this.groupStack = []; + this.branchStack = []; + this.effectCountStack = []; + } + + enterGroup() { + this.nextGroupId += 1; + this.groupStack.push(this.nextGroupId); + this.branchStack.push(0); + this.effectCountStack.push(0); + return this.nextGroupId; + } + + setBranch(index) { + if (this.branchStack.length === 0) return; + this.branchStack[this.branchStack.length - 1] = index | 0; + // New branch resets the per-branch effect occurrence counter. + this.effectCountStack[this.effectCountStack.length - 1] = 0; + } + + exitGroup() { + if (this.groupStack.length === 0) return; + this.groupStack.pop(); + this.branchStack.pop(); + this.effectCountStack.pop(); + } + + currentGroupId() { + return this.groupStack.length > 0 + ? this.groupStack[this.groupStack.length - 1] + : null; + } + + currentBranchPath() { + if (this.branchStack.length === 0) return null; + return this.branchStack.map((b) => String(b)).join("."); + } + + bumpEffectOccurrence() { + if (this.effectCountStack.length === 0) return null; + const idx = this.effectCountStack.length - 1; + const v = this.effectCountStack[idx]; + this.effectCountStack[idx] = v + 1; + return v; + } + + /// Append an effect to the trace if we're in Recording mode. + /// `outcome` is `{ kind: "value", value: }` or + /// `{ kind: "runtime_error", message: }`. + /// Returns the effect record for the worker to stream live — + /// the playground UX hands it to `postMessage` so the main + /// thread keeps a per-effect mirror, which lets the user click + /// Stop mid-game without losing the trace (worker.terminate() + /// would otherwise evict everything held inside the worker). + recordEffect(effectType, args, outcome, callerFn = "main", sourceLine = 0) { + if (this.mode !== REPLAY_MODE.RECORDING) return null; + if (this.recordedEffects.length >= RECORDING_CAP) { + // Cap reached. Subsequent effects fall through unrecorded — + // the program continues to run normally, but the recorder + // turns into a no-op so the trace stays bounded. Caller + // (`recordOrDispatch`) detects the null return and skips + // the per-effect `trace-effect` postMessage, which is what + // actually drove the perf cliff (each post copies the + // payload across the worker boundary). + this.recordingCapped = true; + return null; + } + const groupId = this.currentGroupId(); + const branchPath = this.currentBranchPath(); + const effectOccurrence = + groupId !== null ? this.bumpEffectOccurrence() : null; + const record = { + seq: this.recordedEffects.length + 1, + type: effectType, + args: args ?? [], + outcome: outcome ?? { kind: "value", value: null }, + caller_fn: callerFn, + source_line: sourceLine, + ...(groupId !== null ? { group_id: groupId } : {}), + ...(branchPath !== null ? { branch_path: branchPath } : {}), + ...(effectOccurrence !== null + ? { effect_occurrence: effectOccurrence } + : {}), + }; + this.recordedEffects.push(record); + return record; + } + + /// Pull the next outcome from the trace if we're in Replay mode. + /// Returns `{ outcome: }` on a hit, `{ skip: true }` + /// when we're not in Replay mode (caller falls back to the real + /// effect), or throws on sequence / args mismatch (under + /// `--check-args`) / exhausted trace. + replayEffect(effectType, args) { + if (this.mode !== REPLAY_MODE.REPLAYING) { + return { skip: true }; + } + if (this.replayPos >= this.replayEffects.length) { + throw new Error( + `Replay exhausted at effect '${effectType}': trace had ${this.replayEffects.length} entries` + ); + } + const entry = this.replayEffects[this.replayPos]; + if (entry.type !== effectType) { + throw new Error( + `Replay mismatch at #${this.replayPos + 1}: expected '${entry.type}', got '${effectType}'` + ); + } + if (!argsEqual(entry.args ?? [], args ?? [])) { + if (this.checkArgs) { + throw new Error( + `Replay args mismatch at #${this.replayPos + 1} for '${effectType}'` + ); + } + this.argsDiffCount += 1; + } + this.replayPos += 1; + return { outcome: entry.outcome }; + } + + takeRecordedEffects() { + const out = this.recordedEffects; + this.recordedEffects = []; + return out; + } + + replayProgress() { + return [this.replayPos, this.replayEffects.length]; + } + + ensureReplayConsumed() { + if (this.mode !== REPLAY_MODE.REPLAYING) return; + if (this.replayPos < this.replayEffects.length) { + const remaining = this.replayEffects.length - this.replayPos; + throw new Error( + `Replay incomplete: ${remaining} unconsumed effect(s) past program's last replay_effect call` + ); + } + } +} + +/// Structural equality on the JSON shape the recorder produces. Match +/// what `aver::replay::JsonValue` Eq does in Rust — recurse through +/// arrays / objects, strict equality on primitives. +function argsEqual(a, b) { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!argsEqual(a[i], b[i])) return false; + } + return true; + } + if ( + a !== null && + b !== null && + typeof a === "object" && + typeof b === "object" + ) { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length) return false; + for (let i = 0; i < ka.length; i += 1) { + if (ka[i] !== kb[i]) return false; + if (!argsEqual(a[ka[i]], b[ka[i]])) return false; + } + return true; + } + if (typeof a === "bigint" || typeof b === "bigint") { + // BigInt vs Number: coerce both to BigInt when compatible so a + // recorded `5n` matches a live `5` (the JSON parser doesn't + // produce BigInt by default — wasm-gc args may). + try { + return BigInt(a) === BigInt(b); + } catch { + return false; + } + } + return false; +} diff --git a/tools/website/playground/rogue.wasm b/tools/website/playground/rogue.wasm index 798c1ea4..48c265c0 100644 Binary files a/tools/website/playground/rogue.wasm and b/tools/website/playground/rogue.wasm differ diff --git a/tools/website/playground/snake.wasm b/tools/website/playground/snake.wasm index 68fd0f57..d3eb0c82 100644 Binary files a/tools/website/playground/snake.wasm and b/tools/website/playground/snake.wasm differ diff --git a/tools/website/playground/tetris.wasm b/tools/website/playground/tetris.wasm index cf736bc8..22b563b0 100644 Binary files a/tools/website/playground/tetris.wasm and b/tools/website/playground/tetris.wasm differ diff --git a/tools/website/playground/wasm/aver.js b/tools/website/playground/wasm/aver.js index a528d6d1..32c65f4d 100644 --- a/tools/website/playground/wasm/aver.js +++ b/tools/website/playground/wasm/aver.js @@ -179,6 +179,33 @@ export function aver_compile_project(files_json, entry) { return v3; } +/** + * Compile a project that targets `expr` (e.g. `add(7, 35)`) + * instead of `main`. Wraps the call in a synthetic `__entry__` + * fn the codegen wires `_start` through, so the playground + * worker can run user expressions on the native wasm-gc path + * without any JS-side argument encoder. + * @param {string} files_json + * @param {string} entry + * @param {string} expr + * @returns {Uint8Array} + */ +export function aver_compile_project_with_entry(files_json, entry, expr) { + const ptr0 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passStringToWasm0(expr, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len2 = WASM_VECTOR_LEN; + const ret = wasm.aver_compile_project_with_entry(ptr0, len0, ptr1, len1, ptr2, len2); + if (ret[3]) { + throw takeFromExternrefTable0(ret[2]); + } + var v4 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + return v4; +} + /** * Aver source → Rust/Cargo project files (JSON `{path: content}`). * Maps to `aver compile --target rust` on the CLI. @@ -348,6 +375,34 @@ export function aver_format(source) { } } +/** + * Resolve the user-facing fn name that `expr` calls. Returns + * just the name half of `parse_entry_call(expr)` so the JS host + * can label recordings without re-parsing the call expression. + * @param {string} expr + * @returns {string} + */ +export function aver_parse_entry_target(expr) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passStringToWasm0(expr, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.aver_parse_entry_target(ptr0, len0); + var ptr2 = ret[0]; + var len2 = ret[1]; + if (ret[3]) { + ptr2 = 0; len2 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } +} + /** * Aver source → Dafny project files (JSON `{path: content}`). * Maps to `aver proof --backend dafny` on the CLI. @@ -461,117 +516,6 @@ export function aver_proof_lean_project(files_json, entry) { } } -/** - * @param {string} files_json - * @param {string} entry - * @param {string} recording_json - * @returns {string} - */ -export function aver_replay_run(files_json, entry, recording_json) { - let deferred5_0; - let deferred5_1; - try { - const ptr0 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - const ptr2 = passStringToWasm0(recording_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len2 = WASM_VECTOR_LEN; - const ret = wasm.aver_replay_run(ptr0, len0, ptr1, len1, ptr2, len2); - var ptr4 = ret[0]; - var len4 = ret[1]; - if (ret[3]) { - ptr4 = 0; len4 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred5_0 = ptr4; - deferred5_1 = len4; - return getStringFromWasm0(ptr4, len4); - } finally { - wasm.__wbindgen_free(deferred5_0, deferred5_1, 1); - } -} - -/** - * @param {string} files_json - * @param {string} entry - * @returns {string} - */ -export function aver_run_record(files_json, entry) { - let deferred4_0; - let deferred4_1; - try { - const ptr0 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - const ret = wasm.aver_run_record(ptr0, len0, ptr1, len1); - var ptr3 = ret[0]; - var len3 = ret[1]; - if (ret[3]) { - ptr3 = 0; len3 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred4_0 = ptr3; - deferred4_1 = len3; - return getStringFromWasm0(ptr3, len3); - } finally { - wasm.__wbindgen_free(deferred4_0, deferred4_1, 1); - } -} - -/** - * Record a run starting from an arbitrary call expression instead of - * `main`. `entry_expr` must be a function call with literal arguments - * (String / Int / Float / Bool / Unit) — same constraints as `aver run - * --expr` on the CLI. The resulting recording has `entry_fn` and - * `input` populated accordingly and can be replayed unchanged. - * @param {string} files_json - * @param {string} entry - * @param {string} entry_expr - * @returns {string} - */ -export function aver_run_record_entry(files_json, entry, entry_expr) { - let deferred5_0; - let deferred5_1; - try { - const ptr0 = passStringToWasm0(files_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len0 = WASM_VECTOR_LEN; - const ptr1 = passStringToWasm0(entry, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - const ptr2 = passStringToWasm0(entry_expr, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len2 = WASM_VECTOR_LEN; - const ret = wasm.aver_run_record_entry(ptr0, len0, ptr1, len1, ptr2, len2); - var ptr4 = ret[0]; - var len4 = ret[1]; - if (ret[3]) { - ptr4 = 0; len4 = 0; - throw takeFromExternrefTable0(ret[2]); - } - deferred5_0 = ptr4; - deferred5_1 = len4; - return getStringFromWasm0(ptr4, len4); - } finally { - wasm.__wbindgen_free(deferred5_0, deferred5_1, 1); - } -} - -/** - * Bytes of the standalone aver_runtime wasm module. Worker-side - * instantiates this once and feeds its exports as the - * `aver_runtime` import of every compiled user.wasm. - * @returns {Uint8Array} - */ -export function aver_runtime_wasm() { - const ret = wasm.aver_runtime_wasm(); - if (ret[3]) { - throw takeFromExternrefTable0(ret[2]); - } - var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); - wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); - return v1; -} - /** * @param {string} source * @returns {string} @@ -731,7 +675,7 @@ function __wbg_get_imports() { __wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }, - __wbg_error_d6e4671dea5cd845: function(arg0, arg1) { + __wbg_error_d8ad12e60840c9e8: function(arg0, arg1) { console.error(getStringFromWasm0(arg0, arg1)); }, __wbg_getRandomValues_3f44b700395062e5: function() { return handleError(function (arg0, arg1) { diff --git a/tools/website/playground/wasm/aver_bg.wasm b/tools/website/playground/wasm/aver_bg.wasm index c99636f3..31d78386 100644 Binary files a/tools/website/playground/wasm/aver_bg.wasm and b/tools/website/playground/wasm/aver_bg.wasm differ diff --git a/tools/website/playground/wasm_host.js b/tools/website/playground/wasm_host.js index fbc305ab..377a8820 100644 --- a/tools/website/playground/wasm_host.js +++ b/tools/website/playground/wasm_host.js @@ -1,4 +1,5 @@ import { TerminalBuffer } from "./browser_terminal.js"; +import { EffectReplayState, REPLAY_MODE } from "./replay_state.js"; const COLOR_NAMES = new Set([ "default", @@ -94,6 +95,10 @@ export class AverBrowserHost { this.encoder = new TextEncoder(); this.decoder = new TextDecoder(); this.terminal = new TerminalBuffer(80, 35); + // Recording / replay state — drives the Step 4 native wasm-gc + // record/replay path so the playground can record under V8 + // wasm-gc directly (instead of bouncing through VM-in-wasm32). + this.recorder = new EffectReplayState(); this.keyQueue = []; this.keyQueueView = null; this.lineQueue = []; @@ -220,76 +225,400 @@ export class AverBrowserHost { }, [snapshot.chars.buffer, snapshot.colors.buffer]); } + /// Run `realCall()` if the recorder is in Normal or Recording + /// mode, returning its native result. In Replay mode the cached + /// outcome is decoded via `decodeOutcome(json)` instead and the + /// real call is skipped — same shape the wasm-gc executor's + /// `try_replay` enforces. Recording mode appends the live result + /// (translated through `encodeOutcome(value)`) to the trace + /// before returning. Effects that don't carry a return value + /// pass null encoders / decoders. + recordOrDispatch(effectType, args, realCall, decodeOutcome, encodeOutcome, callerFn) { + const r = this.recorder; + if (r.mode === REPLAY_MODE.REPLAYING) { + const replayResult = r.replayEffect(effectType, args); + if (!replayResult.skip) { + const outcome = replayResult.outcome ?? { kind: "value", value: null }; + if (outcome.kind === "runtime_error") { + throw new Error(outcome.message ?? `replay runtime error in ${effectType}`); + } + return decodeOutcome + ? decodeOutcome(outcome.value ?? null) + : undefined; + } + } + const live = realCall(); + if (r.mode === REPLAY_MODE.RECORDING) { + const outcomeJson = encodeOutcome + ? encodeOutcome(live) + : null; + const record = r.recordEffect( + effectType, + args, + { kind: "value", value: outcomeJson }, + callerFn || "main", + ); + // Stream the freshly-recorded effect to the main thread + // so it can mirror the trace incrementally. Lets the + // user click Stop mid-game and still walk away with + // every effect captured before the worker terminate'd. + // No-op when the recorder rejected the entry (mode race). + if (record !== null) { + this.post({ type: "trace-effect", effect: record }); + } + } + return live; + } + createImports() { + // Every effect import declares a trailing `caller_fn: + // any_ref` param now (see `effects.rs::params`). Each + // callback below picks it up as `callerRef`, decodes via + // LM transport, and pipes the resulting JS string through + // `recordOrDispatch` as the recorder's caller_fn stamp. + // Pure imports (`float_*`) and the group markers ignore + // the trailing arg — JS just lets the extra value drop on + // the floor. + const dec = (callerRef) => this.averToJs(callerRef); return { aver: { - args_len: () => BigInt(this.programArgs.length), - args_get: (index) => { + args_len: (callerRef) => + this.recordOrDispatch( + "Args.len", + [], + () => BigInt(this.programArgs.length), + (json) => BigInt(json ?? 0), + (v) => Number(v), + dec(callerRef), + ), + args_get: (index, callerRef) => { const idx = Number(index); - const value = - idx >= 0 && idx < this.programArgs.length ? this.programArgs[idx] : ""; - return this.jsToAver(value); + return this.recordOrDispatch( + "Args.get", + [idx], + () => { + const value = + idx >= 0 && idx < this.programArgs.length + ? this.programArgs[idx] + : ""; + return this.jsToAver(value); + }, + (json) => this.jsToAver(json ?? ""), + () => + idx >= 0 && idx < this.programArgs.length + ? this.programArgs[idx] + : "", + dec(callerRef), + ); }, - console_print: (sref) => { - this.postConsole("stdout", this.averToJs(sref)); + console_print: (sref, callerRef) => { + const text = this.averToJs(sref); + this.recordOrDispatch( + "Console.print", + [text], + () => this.postConsole("stdout", text), + () => undefined, + () => null, + dec(callerRef), + ); }, - console_error: (sref) => { - this.postConsole("stderr", this.averToJs(sref)); + console_error: (sref, callerRef) => { + const text = this.averToJs(sref); + this.recordOrDispatch( + "Console.error", + [text], + () => this.postConsole("stderr", text), + () => undefined, + () => null, + dec(callerRef), + ); }, - console_warn: (sref) => { - this.postConsole("stderr", this.averToJs(sref)); + console_warn: (sref, callerRef) => { + const text = this.averToJs(sref); + this.recordOrDispatch( + "Console.warn", + [text], + () => this.postConsole("stderr", text), + () => undefined, + () => null, + dec(callerRef), + ); }, - console_read_line: () => { + console_read_line: (callerRef) => { const exports = this.instance.exports; - try { - const line = this.blockingReadLine(); - return exports.__rt_result_string_string_ok(this.jsToAver(line)); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return exports.__rt_result_string_string_err(this.jsToAver(msg)); - } + return this.recordOrDispatch( + "Console.readLine", + [], + () => { + try { + const line = this.blockingReadLine(); + return exports.__rt_result_string_string_ok( + this.jsToAver(line), + ); + } catch (err) { + const msg = + err instanceof Error ? err.message : String(err); + return exports.__rt_result_string_string_err( + this.jsToAver(msg), + ); + } + }, + (json) => this.decodeResultStringMarker(json), + (_ref) => { + const peek = this.lineQueue.length + ? this.lineQueue[0] + : ""; + return { $ok: peek }; + }, + dec(callerRef), + ); }, - random_int: (min, max) => chooseRandomInt(min, max), - random_float: () => Math.random(), - time_unix_ms: () => BigInt(Date.now()), - time_now: () => this.jsToAver(new Date().toISOString()), - time_sleep: (millis) => sleepMillis(millis), - float_sin: (x) => Math.sin(x), - float_cos: (x) => Math.cos(x), - float_atan2: (y, x) => Math.atan2(y, x), - float_pow: (b, e) => Math.pow(b, e), - terminal_enable_raw_mode: () => { - this.rawMode = true; - this.post({ type: "raw-mode", enabled: true }); + random_int: (min, max, callerRef) => + this.recordOrDispatch( + "Random.int", + [Number(min), Number(max)], + () => chooseRandomInt(min, max), + (json) => BigInt(json ?? 0), + (v) => Number(v), + dec(callerRef), + ), + random_float: (callerRef) => + this.recordOrDispatch( + "Random.float", + [], + () => Math.random(), + (json) => Number(json ?? 0), + (v) => Number(v), + dec(callerRef), + ), + time_unix_ms: (callerRef) => + this.recordOrDispatch( + "Time.unixMs", + [], + () => BigInt(Date.now()), + (json) => BigInt(json ?? 0), + (v) => Number(v), + dec(callerRef), + ), + time_now: (callerRef) => + this.recordOrDispatch( + "Time.now", + [], + () => this.jsToAver(new Date().toISOString()), + (json) => this.jsToAver(json ?? ""), + () => new Date().toISOString(), + dec(callerRef), + ), + time_sleep: (millis, callerRef) => + this.recordOrDispatch( + "Time.sleep", + [Number(millis)], + () => sleepMillis(millis), + () => undefined, + () => null, + dec(callerRef), + ), + // Float math is pure — no recording, no replay. The + // wasm-gc imports list these because the engine + // doesn't expose `f64.sin` directly. The trailing + // `callerRef` arg is ignored. + float_sin: (x, _callerRef) => Math.sin(x), + float_cos: (x, _callerRef) => Math.cos(x), + float_atan2: (y, x, _callerRef) => Math.atan2(y, x), + float_pow: (b, e, _callerRef) => Math.pow(b, e), + terminal_enable_raw_mode: (callerRef) => + this.recordOrDispatch( + "Terminal.enableRawMode", + [], + () => { + this.rawMode = true; + this.post({ type: "raw-mode", enabled: true }); + }, + () => undefined, + () => null, + dec(callerRef), + ), + terminal_disable_raw_mode: (callerRef) => + this.recordOrDispatch( + "Terminal.disableRawMode", + [], + () => { + this.rawMode = false; + this.post({ type: "raw-mode", enabled: false }); + }, + () => undefined, + () => null, + dec(callerRef), + ), + terminal_clear: (callerRef) => + this.recordOrDispatch( + "Terminal.clear", + [], + () => this.terminal.clear(), + () => undefined, + () => null, + dec(callerRef), + ), + terminal_move_to: (x, y, callerRef) => { + const xi = Number(x); + const yi = Number(y); + this.recordOrDispatch( + "Terminal.moveTo", + [xi, yi], + () => this.terminal.moveTo(xi, yi), + () => undefined, + () => null, + dec(callerRef), + ); }, - terminal_disable_raw_mode: () => { - this.rawMode = false; - this.post({ type: "raw-mode", enabled: false }); + terminal_print: (sref, callerRef) => { + const text = this.averToJs(sref); + this.recordOrDispatch( + "Terminal.print", + [text], + () => this.terminal.print(text), + () => undefined, + () => null, + dec(callerRef), + ); }, - terminal_clear: () => this.terminal.clear(), - terminal_move_to: (x, y) => this.terminal.moveTo(Number(x), Number(y)), - terminal_print: (sref) => this.terminal.print(this.averToJs(sref)), - terminal_set_color: (sref) => { + terminal_set_color: (sref, callerRef) => { const color = this.averToJs(sref); - this.terminal.setColor(COLOR_NAMES.has(color) ? color : "default"); + this.recordOrDispatch( + "Terminal.setColor", + [color], + () => + this.terminal.setColor( + COLOR_NAMES.has(color) ? color : "default", + ), + () => undefined, + () => null, + dec(callerRef), + ); }, - terminal_reset_color: () => this.terminal.resetColor(), - terminal_read_key: () => { - const key = this.dequeueKey(); + terminal_reset_color: (callerRef) => + this.recordOrDispatch( + "Terminal.resetColor", + [], + () => this.terminal.resetColor(), + () => undefined, + () => null, + dec(callerRef), + ), + terminal_read_key: (callerRef) => { const exports = this.instance.exports; - if (!key) return exports.__rt_option_string_none(); - return exports.__rt_option_string_some(this.jsToAver(key)); + return this.recordOrDispatch( + "Terminal.readKey", + [], + () => { + const key = this.dequeueKey(); + if (!key) return exports.__rt_option_string_none(); + return exports.__rt_option_string_some(this.jsToAver(key)); + }, + (json) => { + if (json && typeof json === "object" && "$some" in json) { + return exports.__rt_option_string_some( + this.jsToAver(json.$some ?? ""), + ); + } + return exports.__rt_option_string_none(); + }, + (_ref) => { + const head = this.keyQueue[0]; + return head + ? { $some: head } + : { $none: true }; + }, + dec(callerRef), + ); }, - terminal_size: () => { - return this.instance.exports.__rt_record_terminal_size_make( - BigInt(this.terminal.cols), - BigInt(this.terminal.rows), + terminal_size: (callerRef) => { + const exports = this.instance.exports; + const cols = this.terminal.cols; + const rows = this.terminal.rows; + return this.recordOrDispatch( + "Terminal.size", + [], + () => + exports.__rt_record_terminal_size_make( + BigInt(cols), + BigInt(rows), + ), + (json) => { + const fields = json?.$record?.fields ?? {}; + return exports.__rt_record_terminal_size_make( + BigInt(fields.width ?? cols), + BigInt(fields.height ?? rows), + ); + }, + () => ({ + $record: { + type: "Terminal.Size", + fields: { width: cols, height: rows }, + }, + }), + dec(callerRef), ); }, - terminal_hide_cursor: () => this.terminal.hideCursor(), - terminal_show_cursor: () => this.terminal.showCursor(), - terminal_flush: () => this.postTerminalSnapshot(), + terminal_hide_cursor: (callerRef) => + this.recordOrDispatch( + "Terminal.hideCursor", + [], + () => this.terminal.hideCursor(), + () => undefined, + () => null, + dec(callerRef), + ), + terminal_show_cursor: (callerRef) => + this.recordOrDispatch( + "Terminal.showCursor", + [], + () => this.terminal.showCursor(), + () => undefined, + () => null, + dec(callerRef), + ), + terminal_flush: (callerRef) => + this.recordOrDispatch( + "Terminal.flush", + [], + () => this.postTerminalSnapshot(), + () => undefined, + () => null, + dec(callerRef), + ), + // Independent-product structural-scope markers — same + // contract the wasm-gc CLI host enforces. Trailing + // `callerRef` ignored (group state lives in the + // recorder, not in trace records). + record_enter_group: (_callerRef) => { + this.recorder.enterGroup(); + }, + record_set_branch: (i, _callerRef) => { + this.recorder.setBranch(Number(i)); + }, + record_exit_group: (_callerRef) => { + this.recorder.exitGroup(); + }, }, }; } + + /// Decode a `Result` marker JSON into the wasm-gc + /// engine value via the module's factory exports. Mirrors the + /// Rust-side `decode_result_string` helper. + decodeResultStringMarker(json) { + const exports = this.instance.exports; + if (json && typeof json === "object" && "$ok" in json) { + return exports.__rt_result_string_string_ok(this.jsToAver(json.$ok ?? "")); + } + if (json && typeof json === "object" && "$err" in json) { + return exports.__rt_result_string_string_err( + this.jsToAver(json.$err ?? ""), + ); + } + // Fallback: empty Ok (defensively, when the trace is + // malformed at this position). + return exports.__rt_result_string_string_ok(this.jsToAver("")); + } } diff --git a/tools/website/playground/worker.js b/tools/website/playground/worker.js index ffb3d3af..e0e38fbb 100644 --- a/tools/website/playground/worker.js +++ b/tools/website/playground/worker.js @@ -2,24 +2,26 @@ import { AverBrowserHost } from "./wasm_host.js"; const host = new AverBrowserHost((message, transfer) => self.postMessage(message, transfer ?? [])); +async function instantiateAndCallEntry(wasmBytes) { + const userImports = host.createImports(); + const { instance } = await WebAssembly.instantiate(wasmBytes, userImports); + host.setInstance(instance); + if (typeof instance.exports._start === "function") { + instance.exports._start(); + } else if (typeof instance.exports.main === "function") { + instance.exports.main(); + } else { + throw new Error("Module exports neither `_start` nor `main`."); + } +} + async function runModule(wasmBytes) { try { + host.recorder.setNormal(); host.post({ type: "status", level: "info", text: "Instantiating module…" }); - - const userImports = host.createImports(); - const { instance } = await WebAssembly.instantiate(wasmBytes, userImports); - host.setInstance(instance); host.postTerminalSnapshot(); host.post({ type: "status", level: "success", text: "Running…" }); - - if (typeof instance.exports._start === "function") { - instance.exports._start(); - } else if (typeof instance.exports.main === "function") { - instance.exports.main(); - } else { - throw new Error("Module exports neither `_start` nor `main`."); - } - + await instantiateAndCallEntry(wasmBytes); host.postTerminalSnapshot(); host.post({ type: "status", level: "success", text: "Finished." }); host.post({ type: "finished", ok: true }); @@ -31,6 +33,94 @@ async function runModule(wasmBytes) { } } +/// Drive a `--record` session natively under V8 wasm-gc instead of +/// bouncing through VM-in-wasm32. Returns the same recording JSON +/// shape the CLI produces, so a downloaded `.replay.json` from the +/// playground replays under `aver replay --wasm-gc` (and vice versa). +async function recordModule(wasmBytes, programArgs, programFile, moduleRoot, entryLabel) { + try { + host.recorder.startRecording(); + host.capNotified = false; + host.post({ type: "status", level: "info", text: "Recording…" }); + host.postTerminalSnapshot(); + await instantiateAndCallEntry(wasmBytes); + host.postTerminalSnapshot(); + const effects = host.recorder.takeRecordedEffects(); + const recording = { + schema_version: 1, + request_id: `rec-${Date.now()}`, + timestamp: `unix-${Math.floor(Date.now() / 1000)}`, + program_file: programFile ?? "playground.av", + module_root: moduleRoot ?? ".", + entry_fn: entryLabel ?? "main", + input: null, + effects, + // Output value comparison for the playground's native + // wasm-gc path is deferred: ref-typed main returns need a + // compiler-injected `__rt_main_to_lm_json` per main return + // type, which is its own compiler change. Until then, + // recording.output stays null and `MATCH` is determined + // by the effect-sequence + outcomes. + output: { kind: "value", value: null }, + }; + host.recorder.setNormal(); + host.post({ + type: "record-finished", + ok: true, + recording, + effect_count: effects.length, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + host.recorder.setNormal(); + host.post({ type: "record-finished", ok: false, error: message }); + } +} + +/// Drive a `--replay` session natively. The recorder is primed with +/// `recording.effects` and the module re-runs end-to-end; every +/// host import pulls its outcome from the trace via +/// `recordOrDispatch` instead of touching the real I/O. After the +/// program returns, `ensureReplayConsumed` raises if the program was +/// a strict prefix of the trace (mirrors the CLI contract). +async function replayModule(wasmBytes, recording, checkArgs) { + try { + const effects = Array.isArray(recording?.effects) + ? recording.effects + : []; + host.recorder.startReplay(effects, !!checkArgs); + host.post({ type: "status", level: "info", text: "Replaying…" }); + host.postTerminalSnapshot(); + await instantiateAndCallEntry(wasmBytes); + host.postTerminalSnapshot(); + host.recorder.ensureReplayConsumed(); + const [consumed, total] = host.recorder.replayProgress(); + const argsDiffs = host.recorder.argsDiffCount; + host.recorder.setNormal(); + host.post({ + type: "replay-finished", + ok: true, + matched: true, + replayed: consumed, + total, + args_diffs: argsDiffs, + }); + } catch (error) { + const [consumed, total] = host.recorder.replayProgress(); + const argsDiffs = host.recorder.argsDiffCount; + host.recorder.setNormal(); + host.post({ + type: "replay-finished", + ok: false, + matched: false, + replayed: consumed, + total, + args_diffs: argsDiffs, + error: error instanceof Error ? error.message : String(error), + }); + } +} + self.onmessage = (event) => { const { type } = event.data ?? {}; @@ -46,6 +136,28 @@ self.onmessage = (event) => { return; } + if (type === "record") { + host.setProgramArgs(event.data.programArgs ?? []); + recordModule( + event.data.wasmBytes, + event.data.programArgs ?? [], + event.data.programFile, + event.data.moduleRoot, + event.data.entryLabel, + ); + return; + } + + if (type === "replay") { + host.setProgramArgs(event.data.programArgs ?? []); + replayModule( + event.data.wasmBytes, + event.data.recording, + event.data.checkArgs, + ); + return; + } + if (type === "resize") { host.setTerminalSize(event.data.cols, event.data.rows); return; diff --git a/tools/website/playground/wumpus.wasm b/tools/website/playground/wumpus.wasm index 3c1db26f..aeba3498 100644 Binary files a/tools/website/playground/wumpus.wasm and b/tools/website/playground/wumpus.wasm differ