Skip to content

feat(sandbox): prove playground and iOS corpus coverage with real trap-form emission#2673

Open
slepp wants to merge 15 commits into
mainfrom
feat/cap16-sandbox-replay
Open

feat(sandbox): prove playground and iOS corpus coverage with real trap-form emission#2673
slepp wants to merge 15 commits into
mainfrom
feat/cap16-sandbox-replay

Conversation

@slepp

@slepp slepp commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Why

The sandbox VM's parity story had two gaps. First, the consumer-driven coverage work from #2623 (playground manifest, iOS corpus, graduation ratchet, VM-dependent-test classification) went stale against main before it could land — this PR replaces and completes that stack; I'll close #2623 after this merges. Second, three expression forms that real playground/iOS programs use — [value; count] array repeat, numeric as casts, and ? on Result/Option — still trapped in the sandbox instead of executing, and two verified native↔sandbox divergences existed underneath them: f32 arithmetic ran at f64 precision, and isize/usize width was unpinned.

What

  • Coverage substrate (replayed from test(sandbox): prove playground and iOS corpus coverage #2623, rebased onto current main): scripts/check-sandbox-parity-coverage.py coverage guard with a 12-case self-test; whole-binary VM-dependent-test classification with nextest exclusion across default/ci/lane profiles (the sandbox-parity target still runs all four binaries); CI coverage-check step; 44-entry playground manifest parity; 40-source iOS subset corpus; 17-file graduation-corpus ratchet.
  • emit (sandbox bytecode): ArrayRepeat lowers to a counted loop with the element evaluated once before the count; numeric Cast emits numeric.cast with explicit source/target type names (saturating float→int with NaN→0, matching fptosi.sat/fptoui.sat; f32 via fround); PostfixTry on Result/Option extracts the success payload or early-returns the error/None, reusing the extracted emit_early_return path. MapLiteral stays fail-closed — the VM has no map semantics, so it refuses rather than approximating. Error-converting ? (mismatched error types) also stays fail-closed.
  • VM (f32 precision): f32 arithmetic now rounds through Math.fround at every operation, not just at literals/casts, with the divergent input pinned as a fixture.
  • Pointer width: isize/usize are locked to the parity suite's native-64 contract with a regression fixture, and the wasm32 divergence is documented in the public divergence catalog.
  • Ratchet: RUNNABLE_BASELINE advances 52→55; NotYetRunnable 4→1 (MapLiteral remains, asserted to trap).

Test

  • trap_residual.hew graduation fixture with an exact-stdout native contract, asserted equal native↔sandbox (covers saturating casts, sign/zero extension, ? success and early-return paths, array-repeat evaluation order).
  • hew-sandbox-vm/test/numeric-cast-parity.test.mjs: int/float/bool/char cast boundary cases against native as semantics.
  • f32 precision and native-64 pointer-width regression fixtures pinning the previously divergent inputs.
  • parity_ratchet behavioural gate: every promoted construct must run clean on the real VM; every NotYetRunnable construct must trap.
  • scripts/tests/test_check_sandbox_parity_coverage.py: 12 sabotage cases (bypass, sibling-profile borrow, Python 3.10 compatibility).
  • Ran green locally: make sandbox-parity, make fixtures-check, parity_ratchet, full workspace nextest.

Quality Checklist

  • PR title, body, and commit messages avoid internal-only orchestration/model vocabulary
  • No new .ok()? or unwrap_or_default() in codegen without // JUSTIFIED comment
  • New allocations have cleanup paths for sync, async, and actor shutdown contexts
  • Serialization changes include round-trip encode/decode tests
  • New runtime features have WASM implementation or // WASM-TODO(#NNN): marker, and new hew_* exports are classified in scripts/jit-symbol-classification.toml
  • No duplicated logic — checked for existing helpers before adding new ones

Out of scope

  • MapLiteral sandbox emission — deliberately fail-closed until the VM grows map semantics.
  • Array-repeat with heap/reference element types is unproven (only the scalar case has a fixture); a heap-element parity fixture is a follow-up.
  • int as char casts trap at runtime in the VM rather than failing closed at compile time; tightening that to compile-time refusal is a follow-up.

slepp added 15 commits July 15, 2026 15:27
Execute every runnable playground manifest entry through native Hew and the sandbox VM, pinning stdout to the checked-in expected output while preserving fail-closed assertions for unsupported entries.
Require every Hew source in examples/sandbox-graduation to remain represented in the native-to-sandbox execution parity suite.
Snapshot the iOS default template, bundled examples, and tutorial starters. Run valid supported programs at native-to-sandbox parity and assert typed fail-closed behavior for the stale template, indirect function calls, and closures.
Snapshot all quick-reference snippets, execute the 13 standalone programs using the app's wrapping rule, pin higher-order and native-import rejection, and enumerate six context-dependent fragments.
hew-sandbox-wasm's new playground.rs and ios_subset.rs tests spawn the
hew-sandbox-vm Node/npm toolchain directly and unconditionally, so they
ran under any generic 'cargo nextest run --workspace' invocation -
Linux/macOS/Windows/FreeBSD CI, release-gate.yml, and local 'make test'
- all of which fail on an unprovisioned runner with 'hew-sandbox-vm
dependencies are not installed'.

Exclude both test binaries from the nextest default-filter
(.config/nextest.toml, profile.default and profile.ci), matching the
existing binary(parity) convention used for parity.rs. Add
matching the existing convention on parity.rs and parity_ratchet.rs's
VM-dependent tests.

Split hew-sandbox-vm's npm-ci-with-hash-stamp logic out of the
sandbox-parity make target into a standalone sandbox-vm-deps target,
and extend sandbox-parity's cargo test invocation to run all four
VM-dependent binaries (parity, parity_ratchet, playground, ios_subset)
instead of just the first two, so the dedicated provisioned gate
actually exercises every native-vs-VM test that generic nextest runs
now skip.

Add scripts/check-sandbox-parity-coverage.py (wired into 'make lint'
and the CI lint job) as a regression guard: it scans
hew-sandbox-wasm/tests/*.rs for tests that call
ensure_parity_runner_built(), and asserts each one is both excluded
from the generic nextest default-filter and named in sandbox-parity's
cargo test invocation, so a future VM-dependent test can't land in
only one of those two states.
check-sandbox-parity-coverage.py only matched a literal
ensure_parity_runner_built() call inside a test's own body, missing
indirect VM invocation: parity_ratchet.rs's
live_gate_matches_declared_coverage reaches the npm parity:run spawn
only through assert_admitted_runs_clean/assert_admitted_traps/
assert_admitted_but_fails -> run_sandbox_inline, a second, independent
spawn path that never calls ensure_parity_runner_built. Today's
classification happened to be correct only because that test also
calls ensure_parity_runner_built() directly elsewhere in its body - a
future test reaching a VM spawn purely transitively would have been
missed entirely.

Rewrite the checker around a same-file call graph instead of a single
literal-string check on each test's own body:

- Parse every function (not just #[test] ones) in a test file.
- Mark a function as a VM 'spawn marker' if its body contains the
  literal hew-sandbox-vm path segment - the common substring every
  real Node-toolchain spawn path names, whether via
  ensure_parity_runner_built, run_sandbox, or run_sandbox_inline.
- Build a call graph from literal name( occurrences and compute, for
  every #[test], whether it transitively reaches a marker function.
- Conservative fallback: if a marker function is not transitively
  reached by any #[test] in its file via the static graph (e.g. it is
  only reachable through indirection this script cannot parse, such as
  a closure or function pointer), treat every test in that binary as
  VM-dependent and require whole-binary exclusion rather than trusting
  a narrower per-test exclusion for that file.

Coverage assertions are unchanged in spirit: every VM-dependent test
must be excluded from nextest's profile.default and profile.ci
default-filter, and its binary must be named in sandbox-parity's cargo
test invocation.

Add scripts/tests/test_check_sandbox_parity_coverage.py as a
regression proof, wired into a new test-sandbox-parity-coverage-check
make target that sandbox-parity-coverage-check now depends on (mirrors
verify-ffi/test-verify-ffi). Covers: a direct marker call, a two-hop
indirect call chain with no direct call in the test body (the exact
parity_ratchet.rs shape), a non-VM structural test staying unflagged,
an orphaned-marker case tripping the binary-level fallback, and an
end-to-end assertion against the real parity_ratchet.rs file - including
replaying the original regression by stripping the direct
ensure_parity_runner_built() call from live_gate_matches_declared_coverage
and confirming the transitive path through run_sandbox_inline alone
still marks it VM-dependent.
…-test

The call-graph attribution in check-sandbox-parity-coverage.py was
unsound: it excluded from generic nextest only the #[test] functions it
could statically prove reached a VM-spawning helper, and fell back to
whole-binary exclusion only when a marker function was unreachable from
ANY test in its file. That fallback condition is not the right trigger.
Proving test A reaches a spawning function via the call graph says
nothing about whether some OTHER test B in the same file also reaches
it through a path the graph does not model - a runtime dispatch table,
a closure passed through a combinator, a trait object. Since A already
'explains' the marker's reachability, the marker is not orphaned, the
fallback never fires, and B keeps running under generic (unprovisioned)
nextest despite spawning the VM at runtime through indirection the
graph cannot trace.

Fix: drop per-test/call-graph attribution entirely. A test file is
VM-dependent if it contains a spawn marker anywhere, full stop; the
whole binary is then excluded from nextest's default-filter via
binary(<name>) - never a narrower test(<name>) - and the whole binary
is run under the provisioned make sandbox-parity gate. This cannot be
evaded by any indirection because it no longer depends on proving which
test reaches what; it only depends on whether the file contains a
spawning function at all.

- .config/nextest.toml: replace parity_ratchet's
  test(live_gate_matches_declared_coverage) exclusion with
  binary(parity_ratchet) in profile.default and profile.ci, so its
  non-VM structural ratchet tests move to sandbox-parity alongside the
  VM test - an acceptable, correctness-driven consequence of whole-
  binary containment, not a coverage loss (sandbox-parity still runs
  them every time). Also add the same exclusion to profile.lane, which
  had no exclusion for parity_ratchet at all: verified this was a real,
  pre-existing failure (cargo nextest run --profile lane failed on
  live_gate_matches_declared_coverage without node_modules) rather than
  something introduced by this branch, but it is the identical defect
  in the identical file for the identical binary, so it is fixed
  alongside the profile.default/profile.ci change rather than left
  broken.
- scripts/check-sandbox-parity-coverage.py: rewritten around a plain
  whole-file substring search for the hew-sandbox-vm marker instead of
  a same-file call graph. Function extraction is kept only for
  --verbose diagnostics (naming which function(s) triggered the
  classification), not for the pass/fail decision.
- scripts/tests/test_check_sandbox_parity_coverage.py: rewritten to
  prove the new contract, including a synthetic case with two tests -
  one reaching a spawn marker via a call the graph could trace, one
  reaching the very same marker only through a runtime dispatch table
  - and asserting both land in the VM-dependent binary regardless,
  plus that excludes_binary() rejects a bare test(<name>) filter for a
  VM-dependent binary and only accepts binary(<name>).
- Makefile / ci.yml: comments updated to describe whole-binary
  containment; sandbox-parity's own comment now notes explicitly that
  parity_ratchet's structural tests run there too.

Verified: cargo nextest run -p hew-sandbox-wasm under profile.default,
profile.ci, and profile.lane all pass (51/51, 0 skipped) with
node_modules removed; make sandbox-parity passes all 10 tests across
the 4 binaries with node_modules restored; the coverage checker and
its self-test pass and were confirmed to fail correctly against the
pre-fix nextest.toml (stashed and reverted) with the exact two
FAIL lines expected.
…fault/ci

REQUIRED_PROFILES in check-sandbox-parity-coverage.py listed only
default and ci, but make test-lane, make test-lane-all, and make
test-fast all run generic 'cargo nextest run --profile lane' too -
another unprovisioned entry point a VM-dependent binary could leak
into unnoticed. Add lane to REQUIRED_PROFILES so the checker asserts
binary(<name>) whole-binary exclusion in profile.lane exactly as it
already does for profile.default and profile.ci.

.config/nextest.toml's profile.lane already carries
binary(parity_ratchet) (added when the previous fix in this lane's
history discovered and closed a real, verified pre-existing gap
there), so no nextest.toml change is needed here - only the checker
itself was under-scoped.

Add a sabotage self-test that removes binary(parity_ratchet) from
profile.lane's default-filter alone (profile.default and profile.ci
keep their real exclusions), runs the checker's main() in-process
against the mutated file, and asserts it fails with exactly one FAIL
line naming profile.lane and parity_ratchet - proving lane is actually
enforced, not just present in the tuple. Also asserts the checker
passes again once the real file is restored, and adds a one-line
regression pin asserting 'lane' is in REQUIRED_PROFILES.

Verified: cargo nextest run -p hew-sandbox-wasm under profile.default,
profile.ci, and profile.lane all pass (51/51, 0 skipped) with
node_modules removed; make sandbox-parity passes all 10 tests across
the 4 binaries with node_modules restored; the coverage checker now
reports 16/16 passing checks (4 binaries x 3 profiles + 4 Makefile
checks, up from 12); its self-test (10 cases, including the new
sabotage proof) passes 3x; cargo fmt, actionlint, and both
preflight-parity scripts are clean.
…able

`default_filter_line()` used a regex that scanned from a profile's
header to end-of-file looking for the next `default-filter = "..."`
line. If `[profile.default]`'s own filter line were deleted, the
regex would spill past the table boundary and match the *next*
occurrence later in the file -- `[profile.ci]`'s filter -- silently
reporting CI's value as if it belonged to default. That is a false
pass: the checker would report `[profile.default]` as excluding a
VM-dependent binary it does not actually exclude.

Rewrite `default_filter_line()` to parse `.config/nextest.toml` with
`tomllib` (already an established convention in
scripts/verify-ffi-symbols.py) and look up
`document["profile"][profile].get("default-filter", "")`. A table
lookup is inherently bounded to that table's own keys, so it cannot
spill into a sibling profile the way a naive regex scan can. A
missing profile table still raises (unchanged, more severe case,
out of scope here); a missing `default-filter` key now returns ""
and flows through the normal per-binary FAIL-reporting path instead
of borrowing a neighbor's value.

Add a regression test that deletes `[profile.default]`'s entire
`default-filter` line, confirms `profile.ci`/`profile.lane` are
untouched, and asserts the checker fails for all VM-dependent
binaries under profile.default only -- with no false FAIL under
ci/lane and no false pass under default. Also add a
`_profile_section_span()` test helper (bounding a profile's TOML
section between its own header and the next top-level header) and
use it to fix the same latent unbounded-scan pattern in the existing
`_remove_binary_exclusion_in_profile` test helper.

Verified:
- scripts/tests/test_check_sandbox_parity_coverage.py: 11/11 pass
  (flake gate 3x, all pass)
- make sandbox-parity-coverage-check: 16/16 checks pass against
  real repo state
- cargo nextest run -p hew-sandbox-wasm (default/ci/lane profiles,
  hew-sandbox-vm/node_modules removed): 51/51 passed, 0 skipped,
  each profile
- make sandbox-parity (node_modules restored): 10/10 tests pass
  across parity, parity_ratchet, playground, ios_subset
- cargo fmt --check: clean
- actionlint .github/workflows/ci.yml: clean
- scripts/check-preflight-ci-parity.sh: 16/16, 11/11, 5/5
- scripts/preflight-parity-selftest.sh: 4/4 PASS
The previous fix for the [profile.default]/[profile.ci] filter
boundary bug used the stdlib `tomllib` module. `tomllib` requires
Python 3.11+; this project's tooling baseline is Python 3.10, and
nothing provisions a newer interpreter for this script. That silently
raised the effective floor for anyone or any CI runner still on 3.10,
with no dependency or interpreter change declared to match.

Replace it with `_profile_table_span()`: a plain regex-based scan
that locates `[profile.<name>]`'s own header line and bounds the
lookup to the text between that header and the very next header line
of any kind (a sibling profile's `[profile.<other>]`, or that same
profile's own `[[profile.<name>.overrides]]` array-of-tables entries).
This mirrors real TOML table semantics without a parser: once any new
header line appears, keys that follow no longer belong to the
previously open table. Every profile in .config/nextest.toml declares
its `default-filter` before its own first override block, so bounding
at "the next header line of any kind" is correct and requires no
top-level/nested distinction. `default_filter_line()` now calls this
helper and searches only within the returned span, so a missing
`default-filter` key returns "" and can never read a sibling
profile's value -- the exact bug this already-existing regression
test proves.

No new dependency added (no third-party TOML library, no `tomllib`
import); the script runs on a bare Python 3.10 interpreter same as
before this whole exercise started.

Added `test_script_stays_python_3_10_compatible_with_no_new_dependency`,
asserting no `import tomllib`/`import toml`/`import tomli` statement is
present and that the section-bounded lookup still resolves
`binary(parity_ratchet)` correctly for profile.default/ci/lane against
the real repo file. Retained the existing
`test_deleting_profile_default_filter_line_entirely_fails_default_only`
sabotage proof (deletes profile.default's own default-filter line,
confirms ci/lane are untouched, confirms the checker fails for
profile.default only) and all prior VM-topology self-tests unchanged.

Verified:
- scripts/tests/test_check_sandbox_parity_coverage.py: 12/12 pass
  (flake gate 3x, all pass)
- make sandbox-parity-coverage-check: 16/16 checks pass against
  real repo state
- cargo nextest run -p hew-sandbox-wasm (default/ci/lane profiles,
  hew-sandbox-vm/node_modules removed): 51/51 passed, 0 skipped,
  each profile
- make sandbox-parity (node_modules restored): 10/10 tests pass
  across parity, parity_ratchet, playground, ios_subset
- cargo fmt --check: clean
- actionlint .github/workflows/ci.yml: clean
- scripts/check-preflight-ci-parity.sh: 16/16, 11/11, 5/5
- scripts/preflight-parity-selftest.sh: 4/4 PASS
Implement parity-correct array repeat, scalar cast, and Result/Option postfix-try emission while keeping map literals fail-closed until the VM has map semantics.
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