Skip to content

engine: move wasm sim live-state ownership into the blob#630

Merged
bpowers merged 1 commit into
mainfrom
engine-wasm-blob-state
May 23, 2026
Merged

engine: move wasm sim live-state ownership into the blob#630
bpowers merged 1 commit into
mainfrom
engine-wasm-blob-state

Conversation

@bpowers
Copy link
Copy Markdown
Owner

@bpowers bpowers commented May 23, 2026

Summary

Follow-up to #628. The @simlin/engine wasm backend was reconstructing the bytecode VM's live-state semantics in TypeScript by poking the blob's curr chunk directly -- a host-side zero-fill on reset and an override mirror on setValue. Every nuance of the VM's live-state presentation had to be re-derived in the host, and each gap surfaced as a separate review finding (the "whack-a-mole" #628 kept hitting). Two of those even contradicted each other: reset's zero-fill clobbered the override that setValue had just mirrored into curr.

This makes the blob own the live curr chunk's presentation end-to-end, so the host needs no shadow writes and the DirectBackend wasm path calls the blob exports 1:1 -- structurally identical to the VM-via-libsimlin path.

What changed (the blob, wasmgen/module.rs)

  • set_value now mirrors the override into the live curr chunk (matching the VM's set_value_now) and marks the slot in a new const_override_set marker region.
  • reset now zeroes curr and reapplies the explicitly-overridden constants, matching libsimlin's recreate-and-reapply simlin_sim_reset. The marker region is required: a freshly-created VM leaves an unoverridden constant at 0 until initials run, so reapplying the mere compiled defaults would diverge (the validity region alone can't express "was explicitly set").
  • run_to breaks at the top of its loop when saved >= n_chunks, so a resume on an already-complete slab is a no-op.
  • clear_values also drops the override marks (a cleared override is no longer reapplied on the next reset).

The blob's export set and the WasmLayout wire format are unchanged (the marker region is internal). The DirectBackend wasm path drops the host-side zero-fill (simReset) and the host-side override mirror (simSetValue).

Fixes the two newest #628 review findings

  • P1 (out-of-bounds write): a resumed run_to past a full slab (a second runToEnd, or interactive scrubbing that stays at the end) re-entered the stepping loop and wrote one results row past the n_chunks-row region, silently corrupting the snapshot/GF regions immediately after it (each re-trigger added another row). Now it is a complete no-op.
  • P2 (reset clobbers override): after setValue + reset, getValue of the overridden constant returned 0 instead of the preserved override, because the host zero-fill cleared the mirrored value. Now reset reapplies the override into curr, so the read matches the VM.

Tests (TDD)

New regression tests, each watched fail first:

  • wasmgen/module.rs: run_to_on_full_slab_is_a_noop (linear memory is byte-identical after a resumed run_to on a full slab) and set_value_writes_curr_and_reset_reapplies_override (set_value writes curr; reset zeroes curr and reapplies the override; clear_values drops it).
  • src/engine/tests/wasm-backend.test.ts: runToEnd twice leaves the step count and series unchanged (matches VM) and reset preserves an override in the live curr state (getValue matches VM).

Full pre-commit gate passes: cargo test --workspace, pnpm -C src/engine test (412), libsimlin tests/wasm.rs resumable-ABI vs VM, clippy, tsc, pysimlin.

The @simlin/engine wasm backend was reconstructing the VM's live-state
semantics in TypeScript by poking the blob's curr chunk directly -- a
host-side zero-fill on reset and an override mirror on setValue. Each
divergence from the VM surfaced as a separate review finding, and the
zero-fill and mirror even contradicted each other (reset clobbered the
override it had just mirrored).

Make the blob own the live curr chunk's presentation end-to-end so the
host needs no shadow writes:

- set_value mirrors the override into curr (like the VM's set_value_now)
  and marks the slot in a new const_override_set region.
- reset zeroes curr and reapplies the explicitly-overridden constants
  (matching libsimlin's recreate-and-reapply reset). The marker region is
  needed because a freshly-created VM leaves an unoverridden constant at 0
  until initials run -- reapplying mere defaults would diverge.
- run_to breaks at the top of its loop when saved >= n_chunks, so a resume
  on an already-complete slab (a second runToEnd, or scrubbing that stays
  at the end) is a no-op rather than writing one results row past the
  n_chunks-row region and corrupting the snapshot/GF regions after it.

The DirectBackend wasm path drops the zero-fill and the mirror; it now
calls the blob exports 1:1, structurally matching the VM path.

Regression tests cover both review findings: the out-of-bounds write on a
full slab (run_to on a full slab is a memory no-op) and the
reset-clobbers-override divergence (setValue/reset present the override in
curr, matching the VM).
@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Review — no blocking issues found

I reviewed the diff (the const_override_set marker region, the rewritten set_value/reset/clear_values, the run_to full-slab guard, and the host-side removals). The change is correct and surgical.

Verification notes:

  • Memory layout: the new const_override_set region is zero-init (correct, since linear memory starts zeroed) and is folded into total_bytes via checked_add, so pages covers it (module.rs:604-607). Bases stay u32, so the MemArg offsets remain valid for wasm32.
  • reset reapply: the Select reads const_region[slot] (val1) vs 0.0 (val2) keyed on the marker byte (condition); operand order matches wasm select semantics, and the F64Store targets curr[slot] (base 0). A non-overridden constant correctly reads 0, matching a freshly-recreated VM (module.rs:1513-1531).
  • run_to guard: BrIf(1) sits directly in the loop at the same depth as the existing time guard, so it breaks out of the loop, not the inner if. A fresh run can't trip it because saved only reaches n_chunks via the post-save break, which exits before the top guard is re-checked (module.rs:1206-1209).
  • Host parity: simReset/simSetValue correctly drop the redundant shadow writes now that the blob owns curr; the only remaining memory.buffer use in direct-backend.ts is the strided read. run()'s reset; run_to(stop) delegation still re-materializes curr via run_initials, and libsimlin's tests/wasm.rs resets are followed by run, so neither is affected.

Both #628 findings (the OOB write on a resumed full slab, and reset clobbering a mirrored override) are addressed, each with a TDD regression test on both the Rust blob and the TS host.

Overall correctness: correct. No findings.

@bpowers bpowers merged commit d1b2492 into main May 23, 2026
12 of 13 checks passed
@bpowers bpowers deleted the engine-wasm-blob-state branch May 23, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant