Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions src/codegen/wasm_gc/body/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
Expand Down Expand Up @@ -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
Expand All @@ -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<String> { head: s, tail: acc }
Expand Down
45 changes: 41 additions & 4 deletions src/codegen/wasm_gc/body/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1316,19 +1316,32 @@ pub(super) fn emit_independent_product_unwrap(
/// Emit `call $aver/<group_op>` 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; <caller_fn>; 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));
}
}
Expand Down Expand Up @@ -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_*`
Expand Down
16 changes: 16 additions & 0 deletions src/codegen/wasm_gc/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,23 @@ impl EffectName {
/// idx. The headers Map crossing uses the registered concrete
/// `Map<String, List<String>>` 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<Vec<ValType>, WasmGcError> {
let mut p = self.params_without_caller(registry)?;
p.push(any_ref_ty());
Ok(p)
}

fn params_without_caller(self, registry: &TypeRegistry) -> Result<Vec<ValType>, WasmGcError> {
match self {
Self::ConsolePrint | Self::ConsoleError | Self::ConsoleWarn => Ok(vec![any_ref_ty()]),
Self::TimeUnixMs => Ok(vec![]),
Expand Down
108 changes: 100 additions & 8 deletions src/codegen/wasm_gc/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = 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<usize> = fn_defs
.iter()
.position(|fd| fd.name == "__entry__")
.or_else(|| fn_defs.iter().position(|fd| fd.name == "main"));

let mut module = Module::new();

Expand Down Expand Up @@ -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<u32> = 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) ────────────────────────────
Expand All @@ -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_<idx>` 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 &registry.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
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -593,6 +658,33 @@ pub(super) fn emit_module(

factory_exports.emit_bodies(&mut codes, &registry)?;

// `__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 ───────────────────────────────────────────────
Expand Down
Loading
Loading