Skip to content

fix: close #695 and #705 (Map/Set heap-pointer GC reachability)#707

Merged
aallan merged 14 commits into
mainfrom
fix/695-705-host-store-gc-reachability
May 29, 2026
Merged

fix: close #695 and #705 (Map/Set heap-pointer GC reachability)#707
aallan merged 14 commits into
mainfrom
fix/695-705-host-store-gc-reachability

Conversation

@aallan

@aallan aallan commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #695 and #705 — silent use-after-free where heap-pointer values stored in Map<K, T_heap> and Set<T_heap> were invisible to the conservative GC scan. A $gc_collect between map / set construction and value access would reclaim the heap blocks (observed at VERA_EAGER_GC=1 returning 0 instead of the JArray's length).

Mirror approach: Every Map / Set wrapper now points (at body offset +8) to a WASM-resident bucket array that mirrors the store's (key, value) pairs as i32 slot words. The conservative scan reaches the values via shadow stack → wrapper → bucket → val_ptr.

Three bucket-population paths are covered:

  1. WAT-emitted attach_bucket_to_wrapper import — dispatches to host_attach_bucket (CLI) and the new browser-runtime JS implementation. Fires after every _emit_wrap_handle so map_new / map_insert / map_remove / set_add / set_remove returning wrappers all get their bucket attached.
  2. Host-side _alloc_map_wrapper — used by write_json's JObject branch and write_html's HtmlElement attrs. Populates the bucket inline with the wrapper allocation.
  3. Match-arm / let-binding shadow-rooting for heap-pointer bindings in vera/wasm/data.py and vera/wasm/context.py — the orthogonal Map<K, T_heap> values stored in _map_store are invisible to conservative GC scan — post-walk reachability of JObject children #695 root cause: parameter shadow-pushing already covered function calls, but match and let binding sites did not. Heap-pointer Vera locals are now shadow-pushed on creation.

Decimal is exempt: PyDecimal (CLI) and BigInt (browser) are value-typed, no heap pointers in the store entry — the silent-UAF window cannot apply. Clarifying comments confirm this in all three host-import sites.

Architectural follow-up tracked as #706 — "move" cleanup that deletes _map_store / _set_store entirely and makes the WASM bucket array the sole source of truth across CLI Map, CLI Set, and the browser runtime. An initial implementation hit O(N²) in pure-Python decode/encode for the 10000-element reclamation chain (~minutes vs the ~4-minute CPython baseline); the issue captures the perf challenge and the slot-layout grow that's needed.

Changes

Test plan

  • All three regression tests pass with VERA_EAGER_GC=1 (Map JSON parser + user-level Map<String, Json> + user-level Set).
  • Existing TestHostHandleReclamation573 reclamation tests still pass, including the 10 000-element array_fold(map_insert(...)) chain (test_map_chain_reclaims_transients).
  • Existing TestMapCollection (24), TestSetCollection (23) tests pass.
  • All 99 browser tests pass with the new JS attach_bucket_to_wrapper and updated wrapHandle.
  • 88 conformance programs pass.
  • 35 examples pass.
  • mypy clean.
  • All doc validators pass (spec, README, FAQ, SKILL, HTML, EXAMPLES, doc counts, walker coverage, limitations sync, license check, site assets, browser parity).
  • Version consistent across 6 files.

🤖 Generated with Claude Code

Closes #695
Closes #705
Closes #708

Summary by CodeRabbit

  • Bug Fixes

    • Fixed GC reachability for Map/Set storing heap-allocated values across CLI and browser runtimes to prevent use-after-free and silent corruption; browser runtime now preserves intermediate allocations during multi-step host calls.
  • Tests

    • Added regression and browser parity tests that force eager-GC to guard against prior Map/Set reachability failures.
  • Documentation

    • Bumped release to v0.0.159 and updated changelog, README, history, roadmap, testing metrics and known-issues to reflect the fix and added guidance for browser GC helpers.

Review Change Stack

aallan and others added 5 commits May 28, 2026 14:08
…lity

Empirically demonstrates the bug surfaced by CodeRabbit on PR #693 and
documented in #695: Map<K, T_heap> values stored as Python ints in
_map_store are invisible to the conservative GC scan.  A gc_collect
between map construction and value access reclaims the heap blocks,
producing silent use-after-free.

Reproducer pattern:
- json_parse builds Map<String, Json> with a JArray child block
- VERA_EAGER_GC=1 forces gc_collect on every alloc
- Option alloc inside json_get fires GC between parse and access
- json_array_length reads from freed block, returns 0 instead of 10

The xfail marker uses strict=True so the test will fail when the fix
lands and starts passing, that failure is the canonical handoff
signal to remove the marker.

Sibling regression test for Set<T_heap> (issue #705) will land
alongside the Set side of the fix.

Doc-counts updated: total 3947 -> 3948, test_codegen.py row tests
1129 -> 1130 lines 19293 -> 19367, ROADMAP overview 3947 -> 3948.

Refs #695, #705

Co-Authored-By: Claude <noreply@anthropic.invalid>
Phase A of the move-to-WASM migration for Map / Set host storage.
Introduces host-side helpers that allocate and operate on WASM-resident
bucket arrays.  These helpers are intentionally NOT called from any
existing code path yet — they are the foundation Phase B will use when
switching map_* host imports from Python-side _map_store dicts to WASM
linear memory.

Added (all in vera/codegen/api.py, module-factory level):

- _read_i32_at(caller, offset) / _read_bytes_at(caller, offset, length)
  module-level memory readers paralleling the existing _write_i32 /
  _write_bytes; previously only the writers existed at module-factory
  level, the readers were nested inside the decimal closures.

- _SLOT_SIZE = 12, bucket slot size (key_word_0, key_word_1, val_word).

- _alloc_bucket_array(caller, capacity), allocates a zero-init heap
  block of capacity * SLOT_SIZE bytes.  Empty-slot sentinel is
  key_word_0 == 0 for string-keyed slots (heap pointers always non-zero).

- _read_slot / _write_slot, slot accessors at slot_idx.

- _probe_string_key(caller, bucket_ptr, capacity, key_bytes), linear
  scan probe.  Returns (slot_idx, found).  Linear rather than hash
  probing for Phase A simplicity and PYTHONHASHSEED-determinism;
  Phase B can upgrade later.

A long comment block above the helpers documents the design (slot
layout, empty-slot sentinel, capacity/count living in the wrapper
ADT, to be added in Phase B).

No behaviour change.  Verified: vera.codegen.api imports cleanly,
mypy passes, all six TestHostWalkerGCRooting692 tests still pass,
and TestMapHostStoreGCReachability695 still xfails (the bug is
unchanged because nothing uses these helpers yet, Phase B is what
flips that test green).

Refs #695, #705

Co-Authored-By: Claude <noreply@anthropic.invalid>
The minimal fix for the Map<K, T_heap> GC-reachability bug.  Wrapper
ADT expanded from 8 to 12 bytes to add a bucket_ptr field at offset
+8.  _alloc_map_wrapper now populates a WASM-resident bucket array
that mirrors the (key, value) pairs from the input dict, and writes
the bucket pointer into the wrapper.  The conservative GC scan
reaches the val_ptrs naturally via shadow stack to wrapper to
bucket array.

Empirically validated:
- TestMapHostStoreGCReachability695::test_eager_gc_json_object_with_
  array_child_post_walk_uaf now PASSES (xfail marker removed; the
  test was returning 0 instead of 10 before the fix).
- All 1130 codegen tests pass.
- All 425 conformance tests pass.
- All 35 examples pass.

Critical detail discovered during implementation: the wrapper
itself needs shadow-stack rooting inside _alloc_map_wrapper before
any sub-alloc fires.  Pre-fix attempt without that rooting caused
the wrapper to be reclaimed by Phase 2c during my bucket-array
allocation (sub-allocs triggered GC, the wrapper was unrooted on
the operand stack, Phase 2c popped its _map_store entry, and
json_get returned None instead of the JArray).  Same #692-class
concern as the host-side tree walkers.  Documented inline.

Scope honesty: this is the MIRROR-not-MOVE first step of the full
fix.  The Python _map_store still exists and is still read by
map_get; the bucket array is currently used purely as a GC
reachability anchor.  The val_ptrs in _map_store now point to live
blocks because GC sees them via the wrapper, so map_get returns
valid pointers.  Bug closed.

Phase B.2 / B.3 follow-up will:
- Route map_get and friends through the bucket array
- Delete _map_store, _map_alloc, _alloc_map_wrapper Python paths
- Update _emit_unwrap_handle codegen sites accordingly
- Update TestExecutorHostStoreSizes573 introspection

Closes #695

Co-Authored-By: Claude <noreply@anthropic.invalid>
The architectural fix for the host-store GC reachability bug class,
covering both the Map sibling (#695, parser-built) and the Set
sibling (#705, user-level set_new/set_add).  Two complementary
mechanisms address the bug from both ends:

1. **Match-arm + let-binding shadow-rooting** (vera/wasm/data.py,
   vera/wasm/context.py).  Heap-pointer bindings (i32 slot type with
   non-scalar Vera type) are now shadow-pushed at the binding site.
   Without this, code like:

       let @set = set_add(set_new(), @Json.0)

   loses @JSON during set_new()'s wrap allocation because the
   match-arm binding for Ok(@JSON) had no shadow root and Vera
   function calls only shadow-root their parameters (not arbitrary
   locals).  Bool / Byte / Unit (inline i32 types) are excluded
   from rooting via a small _INLINE_I32_TYPES allowlist.

2. **Bucket-array attachment for WAT-allocated wrappers**
   (vera/wasm/calls_containers.py, vera/codegen/api.py,
   vera/codegen/assembly.py).  Wrapper ADT grew to 12 bytes (matching
   the api.py path landed in 8b59454) with a bucket_ptr field at +8.
   _emit_wrap_handle now calls a new host import attach_bucket_to_
   wrapper(wrapper_ptr, kind, handle) after the register + shadow_push
   steps, which mirrors the contents of _map_store[handle] /
   _set_store[handle] into a WASM-resident bucket array.  Heap-pointer
   values held only in the Python stores are now traceable through
   shadow stack → wrapper → bucket → element.

The bucket populator uses an ordering trick to avoid pre-pushing
every value onto the shadow stack (which would overflow at ~16K
entries — observed at the 10000-element reclamation test):
val_word is written to the bucket slot FIRST (no alloc), then the
key string is allocated (which may GC, but the value is now rooted
via wrapper → bucket).

Empirically validated:
- New test test_eager_gc_set_of_json_post_walk_uaf passes; was the
  empirical reproducer from #705's issue body (the helper-function
  pattern that exposed the bug).
- Existing test_eager_gc_json_object_with_array_child_post_walk_uaf
  (#695 regression) still passes via _alloc_map_wrapper's earlier
  fix.
- TestHostHandleReclamation573 (10 tests, exercises 10000-iter
  Map/Set chains) all pass — reclamation discipline preserved
  because bucket arrays die with their wrappers.
- TestHostWalkerGCRooting692 (6 tests) all pass — host-walker
  shadow rooting preserved.

Closes #705

Co-Authored-By: Claude <noreply@anthropic.invalid>
#705 fix

Completes the #695/#705 PR by:

1. Browser runtime parallel: replaces the no-op attach_bucket_to_wrapper
   stub in vera/browser/runtime.mjs with a JS implementation that
   allocates a WASM-side bucket array from mapStore[handle] /
   setStore[handle] and links it from the wrapper body (+8).  Also
   bumps wrapHandle to allocate the full 12-byte wrapper body
   (was 8, missed by the original #695 fix) and routes
   allocMapWrapper (used by writeJson JObject branch) through the
   bucket-attach path so JS-side Map construction parallels
   CLI _alloc_map_wrapper.  Both targets now have the same GC
   reachability semantics.

2. Scope test for user-level Map<String, T_heap> path:
   test_eager_gc_map_of_json_user_level_post_walk_uaf exercises the
   map_insert(map_new(), ...) path returning from a helper.  Closes
   the gap discussed on #705: the user-level wrapper path was
   covered for Set but not for Map.

3. Decimal exemption documented in code: PyDecimal and BigInt are
   value-typed (no heap pointers in the store entry), so the
   silent-UAF window cannot apply to Decimal.  Clarifying comments
   in vera/codegen/api.py, vera/wasm/calls_containers.py, and
   vera/browser/runtime.mjs confirm the exemption.

4. Documentation sweep: removed the stale #695 bug row from
   KNOWN_ISSUES.md (now closed), added the architectural follow-up
   #706 to the "Refactoring needed" section.  No example workarounds
   found to remove; spec / SKILL / FAQ / docs already clean.

5. Release prep for v0.0.159 (bump from 0.0.158): version sync across
   pyproject.toml, vera/__init__.py, uv.lock, README.md, docs/index.html;
   CHANGELOG entry; HISTORY entry in the current Stage 12 table;
   doc count refresh (TESTING.md, ROADMAP.md, README.md to reflect
   3,950 tests / 1,132 test_codegen tests / 19,461 lines).

Closes #695.
Closes #705.

Follow-up tracked as #706 (Phase D move: bucket-as-truth, deleting
_map_store / _set_store across CLI Map / CLI Set / browser runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR mirrors Python Map/Set host stores into WASM-resident bucket arrays, expands wrapper ADTs with a bucket_ptr, wires a host import to populate buckets, adds JS shadow-rooting helpers and conditional gc_shadow_push for non-inline i32 locals/extractions, and adds regression/parity tests; docs and package bumped to v0.0.159.

Changes

Map/Set Heap-Value GC Reachability

Layer / File(s) Summary
Wrapper ADT layout expansion and bucket_ptr field
vera/wasm/calls_containers.py, vera/codegen/api.py
Wrapper body increased from 8→12 bytes with bucket_ptr at offset +8; initialised to 0 and attach call added after registration.
Codegen API: bucket primitives and allocation
vera/codegen/api.py
Add WASM linear-memory read helpers and bucket-array primitives; rework map/set wrapper allocation to allocate and populate bucket arrays, write value slot words before key/string allocations, and use _ShadowGuard to root across sub-allocations; Decimal exempt.
Browser runtime attach and handle masking
vera/browser/runtime.mjs
Implement imports.vera.attach_bucket_to_wrapper to allocate/populate WASM buckets (value-first writes); update wrapHandle/allocMapWrapper to new wrapper layout, add gcShadowPush/gcShadowPop/gcGuard, shadow-root wrapper pointers across attach, and mask bit-31 tagged handles when reading Map/HTML attributes.
WAT module import generation
vera/codegen/assembly.py
When wrap-table is enabled, emit import vera.attach_bucket_to_wrapper (param i32 i32 i32) adjacent to other host imports.
Conditional GC shadow-rooting for i32 heap-pointer ADTs
vera/wasm/helpers.py, vera/wasm/context.py, vera/wasm/data.py
Centralise _INLINE_I32_TYPES = {"Bool","Byte","Unit"} and emit gc_shadow_push only for non-inline i32 locals, match-arm bindings, and constructor-field extractions to keep i32-encoded heap-pointer ADTs rooted.
Regression and browser-parity tests
tests/test_codegen.py, tests/test_browser.py, tests/conformance/manifest.json
Add regression tests forcing eager GC (VERA_EAGER_GC=1) validating Map/Set JSON heap-value reachability and browser parity Node tests; add conformance manifest entry ch09_host_store_gc_reachability.
Release notes, docs and versioning
CHANGELOG.md, HISTORY.md, KNOWN_ISSUES.md, README.md, ROADMAP.md, TESTING.md, pyproject.toml, vera/__init__.py, docs/*
Bump project/package to 0.0.159; add CHANGELOG/HISTORY entries describing the fix; update KNOWN_ISSUES (clear), README/ROADMAP/TESTING/AGENTS/CLAUDE/FAQ/SKILL metrics (conformance count → 89), and update GitHub compare links.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • aallan/vera#577: Prior wrapper/host-store mechanics this change builds on.
  • aallan/vera#673: Earlier wrapper-handle tagging/layout work extended here.
  • aallan/vera#363: Related HTML marshalling and browser runtime integration touched by this PR.

Suggested labels

compiler, tests, docs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title precisely identifies the main fix: resolving two critical use-after-free bugs (#695, #705) in Map/Set heap-pointer GC reachability. It maps directly to the PR's primary objective of preventing silent corruption when conservative GC reclaims heap blocks before access.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/695-705-host-store-gc-reachability

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 127 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.80%. Comparing base (ce9875c) to head (b81cde4).

Files with missing lines Patch % Lines
vera/browser/runtime.mjs 73.94% 99 Missing ⚠️
vera/codegen/api.py 73.58% 28 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #707      +/-   ##
==========================================
- Coverage   90.92%   90.80%   -0.13%     
==========================================
  Files          60       60              
  Lines       23606    24091     +485     
  Branches      259      292      +33     
==========================================
+ Hits        21464    21875     +411     
- Misses       2135     2209      +74     
  Partials        7        7              
Flag Coverage Δ
javascript 61.40% <73.94%> (+3.96%) ⬆️
python 94.55% <78.12%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…mpiler internals"

KNOWN_ISSUES.md "Refactoring needed" is for file decomposition (splitting
big files like test_codegen.py, test_checker.py, codegen/api.py).  The
mirror-to-bucket-as-truth move is an architectural cleanup, not a file
split, so it belongs in ROADMAP.md alongside other internal work.

Adds a new "Compiler internals" subsection under "Continuous: quality
and security hardening" — ROADMAP previously didn't have a place for
runtime-architecture follow-ups (only CI tooling, Verification depth,
Security, Testing gaps).

No code changes.

Skip-changelog: doc cross-reference cleanup, no shipped behaviour change

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
HISTORY.md (1)

306-347: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the HISTORY roll-up release total to 159.

After adding v0.0.159 at Line 306, the footer at Line 347 still says 158 tagged releases, so the release total is now inconsistent within the docs set.

Based on learnings: keep the tagged-release count in sync between HISTORY.md footer and README project-status release count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@HISTORY.md` around lines 306 - 347, The HISTORY.md footer still reads "158
tagged releases" after adding the new entry "v0.0.159"; update that footer text
to "159 tagged releases" and also update the README project-status release count
(the string "158 tagged releases" / release count) so both documents stay in
sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vera/browser/runtime.mjs`:
- Around line 1235-1262: In imports.vera.attach_bucket_to_wrapper, prevent UAF
by publishing and rooting the newly allocated wrapper/bucket before any nested
allocations: after computing capacity and calling alloc(...) assign and write
the bucketPtr into the wrapper (wrapperPtr + 8) immediately, and ensure the JS
host "roots" or otherwise holds a reference to wrapperPtr/bucketPtr before
calling allocString(); then zero-fill the entire bucket memory immediately after
alloc(...) (clear capacity * _SLOT_SIZE bytes) to avoid retained stale pointers
from free-list reuse, and only afterwards iterate mapStore entries and call
allocString()/writeI32 for keys/values; apply the same ordering and zeroing fix
to the other similar blocks referenced (the blocks around the other
attach_bucket_to_wrapper paths).
- Around line 1316-1319: wrapHandle stores handles with the high-bit set
(rawHandle | 0x80000000) but readJson() and readHtml() currently call
mapStore.get(readI32(wrapperPtr + 4)) directly, causing browser-side Map lookups
to miss; update both readJson and readHtml to untag the stored value before
lookup by masking off the high bit (e.g., readI32(wrapperPtr + 4) & ~0x80000000)
and pass that untagged integer into mapStore.get so keys match the original
rawHandle used as Map keys.

In `@vera/codegen/api.py`:
- Around line 1368-1384: Compute/root the entry's val_word before calling
_alloc_string to avoid a GC between allocation and mirroring the heap pointer
into the bucket: move the val_word assignment (val_word = v if isinstance(v,
int) else 0) to before the _alloc_string(caller, key_str) call and ensure it
remains live until _write_slot (e.g., keep it in the local variable and use it
when calling _write_slot); reference symbols: val_word, _alloc_string,
guard.push, _write_slot, and the loop over d.items().

In `@vera/wasm/data.py`:
- Around line 499-509: The match-binding path that calls
gc_shadow_push(local_idx) must also mark that the allocator is required: set
self.needs_alloc = True whenever you emit gc_shadow_push in the i32/non-inline
branch (the same place where you check wt == "i32" and type_name not in
_INLINE_I32_TYPES). Likewise, update the ast.BindingPattern handling (the branch
that copies heap-pointer i32 scrutinees into a local) to root those scrutinees
by calling gc_shadow_push(local_idx) and set self.needs_alloc = True there as
well so the allocator wiring/mixins are preserved.

---

Outside diff comments:
In `@HISTORY.md`:
- Around line 306-347: The HISTORY.md footer still reads "158 tagged releases"
after adding the new entry "v0.0.159"; update that footer text to "159 tagged
releases" and also update the README project-status release count (the string
"158 tagged releases" / release count) so both documents stay in sync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 795a663f-f2a3-415f-bec3-394a3c30b36a

📥 Commits

Reviewing files that changed from the base of the PR and between ce9875c and 71a318b.

⛔ Files ignored due to path filters (5)
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • uv.lock is excluded by !**/*.lock, !uv.lock
📒 Files selected for processing (15)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • pyproject.toml
  • tests/test_codegen.py
  • vera/__init__.py
  • vera/browser/runtime.mjs
  • vera/codegen/api.py
  • vera/codegen/assembly.py
  • vera/wasm/calls_containers.py
  • vera/wasm/context.py
  • vera/wasm/data.py

Comment thread vera/browser/runtime.mjs Outdated
Comment thread vera/browser/runtime.mjs
Comment thread vera/codegen/api.py Outdated
Comment thread vera/wasm/data.py
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

CodeRabbit round 1 on PR #707, all five findings verified valid and fixed:

1. vera/browser/runtime.mjs `attach_bucket_to_wrapper` ordering (lines 1235+):
   The mirror bucket was allocated via `alloc(...)` and only linked into
   `wrapperPtr + 8` AFTER the populating loop ran.  Between alloc and link,
   any `allocString` call could trigger `$gc_collect`, find the bucket
   unreachable from the shadow stack (the JS side has no shadow-stack API),
   and reclaim it.  Three-step fix in both Map and Set branches:
     (a) write `bucketPtr` into `wrapperPtr + 8` IMMEDIATELY after alloc
         so the wrapper — which is shadow-pushed by the WAT-side
         `_emit_wrap_handle` in `vera/wasm/calls_containers.py` — roots
         the bucket before any sub-alloc.
     (b) zero-fill the bucket region (`new Uint8Array(...).fill(0)`) so
         free-list reuse can't leave stale i32s that the conservative
         scan would mistake for live heap pointers (extending unrelated
         blocks' lifetimes).  $alloc has no zero-fill on reclaim.
     (c) only then iterate, val_word first (no alloc) to slot-root any
         heap-pointer value before the key allocString fires.

2. vera/browser/runtime.mjs `readJson` (line 1804) + `readHtml` (line 2063):
   Bug introduced by the `wrapHandle` change earlier in this PR.  After
   that change, the wrapper body's +4 field stores `rawHandle | 0x80000000`
   (matching the #578 bit-31-tag discipline on the CLI side) — but
   `readJson` and `readHtml` still read +4 raw, so `mapStore.get(handle)`
   always missed.  Mask with `& 0x7FFFFFFF` before the lookup (the CLI
   uses the same constant in `_emit_unwrap_handle`).

3. vera/codegen/api.py `_alloc_map_wrapper` val_word ordering (line 1368+):
   Same val-word-first pattern that `_attach_bucket_from_dict` uses.  The
   loop body computed `val_word` AFTER calling `_alloc_string(caller, key_str)`,
   so the heap-pointer value `v` was held only in a Python local across
   a potentially-GC-firing alloc.  Now writes `val_word` to `slot + 8`
   first (no alloc), then allocates the key string.  The conservative
   scan traces every i32 word in the heap as a potential pointer
   regardless of any occupied flag, so writing into the slot is enough
   to root via wrapper → bucket → slot[+8].

4. vera/wasm/data.py (line 408 + line 515):
   The match-arm constructor-field shadow-push added for #705 didn't set
   `self.needs_alloc = True`, so `$gc_sp` / `$gc_stack_limit` globals
   could be unexported for a function whose only allocation trigger is
   a heap-pointer match binding — WAT would then reference undefined
   globals.  Added the flag flip.  Plus: the sibling `ast.BindingPattern`
   path (`match @Json.0 { @JSON -> ... }`) was missed entirely by the
   original #705 fix; it now applies the same `gc_shadow_push` +
   `needs_alloc = True` discipline for non-inline i32 scrutinees.

5. HISTORY.md footer (line 347): the totals line still read
   "158 tagged releases" after the v0.0.159 entry was added.  Bumped to
   159 (README was already updated to 159).

Validation:
- mypy clean (59 files).
- Map/Set/GC reachability (50), browser (99), JSON+HTML collection (83),
  match/pattern (47), conformance (425), walker GC + reclamation (15)
  all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai thanks for the round 1 review — all five findings verified and addressed in commit b3eb760:

  1. Browser bucket population ordering (vera/browser/runtime.mjs attach_bucket_to_wrapper): valid. The mirror was unrooted between alloc(...) and the subsequent allocString(...) calls — a $gc_collect in that window could have reclaimed it. Three-step fix in both Map and Set branches: (a) write bucketPtr into wrapperPtr + 8 immediately after alloc so the WAT-shadow-pushed wrapper roots it, (b) new Uint8Array(...).fill(0) the whole bucket region so free-list reuse can't leave stale i32s the conservative scan would mistake for live heap pointers, (c) only then iterate val-word-first.

  2. readJson / readHtml mapStore untag: valid — a bug I introduced in the same PR. wrapHandle was bumped to store rawHandle | 0x80000000 at +4 (matching the Conservative GC scan can spuriously retain heap objects via wrapper handle field (#573 latent) #578 CLI discipline), but these two consumers still read +4 raw, so the lookup always missed. Masked with & 0x7FFFFFFF at both sites.

  3. _alloc_map_wrapper val_word ordering (vera/codegen/api.py ~line 1368): valid. Applied the same val-word-first pattern that _attach_bucket_from_dict already uses — write the slot's val_word at +8 before calling _alloc_string for the key, so heap-pointer values are slot-rooted (the conservative scan traces every i32 word regardless of occupancy) before any alloc can fire GC.

  4. vera/wasm/data.py needs_alloc + BindingPattern: both points valid. Added self.needs_alloc = True next to the gc_shadow_push(local_idx) in _extract_constructor_fields (otherwise $gc_sp / $gc_stack_limit aren't exported when a function's only allocation trigger is a heap-pointer match binding). Plus: the sibling ast.BindingPattern path (match @Json.0 { @Json -> ... }) was missed entirely by the original Set<T_heap> values stored in _set_store are invisible to conservative GC scan — sibling to #695 #705 fix — applied the same gc_shadow_push + needs_alloc discipline there for non-inline i32 scrutinees.

  5. HISTORY.md footer "158 tagged releases" (line 347): valid. Bumped to 159; README was already at 159.

Validation: mypy clean; Map/Set + GC reachability (50), browser (99), JSON+HTML collection (83), match/pattern (47), conformance (425), walker GC + reclamation (15) all pass. Full pre-commit pytest (including the 4-minute 10000-element test_map_chain_reclaims_transients) passed on the commit.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan: All five fixes land cleanly — thank you for the thorough writeup on each.

A few notes on the more subtle ones:

Fix 1 (browser ordering): The three-step discipline (root immediately → zero-fill → val-word-first iteration) is the right sequence. Zero-filling is an important hardening step — without it, free-list residue in the freshly-allocated bucket region would be indistinguishable from live pointers to a conservative scan.

Fix 2 (untag regression): Good catch that this was a self-introduced bug from the same PR. The | 0x80000000 / & 0x7FFFFFFF discipline matches the existing #578 CLI convention, so consistency is preserved across all consumers now.

Fix 3 (val_word ordering in _alloc_map_wrapper): Correct — writing the heap-pointer slot before _alloc_string is the only safe ordering under a conservative GC that traces all i32 words regardless of notional occupancy.

Fix 4 (BindingPattern gap): The needs_alloc = True omission was the more dangerous half — a function whose only allocation trigger is a heap-pointer match binding would have compiled without $gc_sp/$gc_stack_limit exports, silently breaking the shadow-stack invariant. The ast.BindingPattern gap being missed by the original #705 fix is a good secondary catch; both paths now carry the same gc_shadow_push + needs_alloc discipline.

Validation coverage across all six suites (including the 10 000-element chain reclaim stress test) is persuasive.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
vera/browser/runtime.mjs (1)

1262-1274: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep browser BigInt entries zero-filled in bucket slots.

The PR contract treats browser BigInt values as value-typed. Coercing them through Number(...) and then writeI32() truncates twice, so a large i64 key/value/element can alias a heap-looking word and pin unrelated blocks. These GC-only mirrors should leave bigint cases as 0.

Also applies to: 1296-1299

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vera/browser/runtime.mjs` around lines 1262 - 1274, The code currently
coerces BigInt keys/values via Number(...) before calling writeI32 (in the
valWord and kw assignments near slotBase usage), which can truncate large i64s
and confuse the GC mirror; change the conditional branches in the value and key
handling so that typeof v === 'bigint' and typeof k === 'bigint' do NOT use
Number(...) but instead fall through to the zero case (i.e., treat BigInt as 0)
before calling writeI32; update the same pattern referenced around the other
occurrences noted (the block using allocString, writeI32, valWord, kw, and
slotBase) so all BigInt paths write zero-filled slots rather than
Number(bigint).
vera/wasm/data.py (1)

195-210: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add heap-pointer rooting to _translate_let_destruct for scalar i32 ADT fields

vera/wasm/data.py::_translate_let_destruct extracts constructor fields into locals and pushes them to the env, but it never applies the same gc_shadow_push/needs_alloc rooting used for heap-pointer ADT fields in _extract_constructor_fields (if wt == "i32" and type_name not in _INLINE_I32_TYPES:). vera/wasm/context.py’s ast.LetDestruct path only appends the emitted destr_instrs, so there’s no later compensation.

With allocations between destructuring and first use, the GC can reclaim those extracted heap pointers.

Mirror the _extract_constructor_fields rooting block inside _translate_let_destruct right after local.set {local_idx} for the wt == "i32" and type_name not in _INLINE_I32_TYPES case, and add/extend a test that destructures a scalar heap-pointer field (not just @String pair fields) with an intervening allocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vera/wasm/data.py` around lines 195 - 210, _translate_let_destruct currently
extracts constructor fields into locals but omits the heap-rooting logic used in
_extract_constructor_fields, so scalar i32 ADT fields (i32 where type_name not
in _INLINE_I32_TYPES) can be reclaimed before use; after the line that emits
"local.set {local_idx}" inside _translate_let_destruct, add the same branching
logic used in _extract_constructor_fields: if wt == "i32" and type_name not in
_INLINE_I32_TYPES then emit a gc_shadow_push for the new local and record
needs_alloc for that local (mirroring the symbol usages gc_shadow_push and
needs_alloc), then continue pushing the binding into the environment
(new_env.push), and update tests to include a destructure of a scalar
heap-pointer field with an intervening allocation to ensure the rooting is
exercised.
♻️ Duplicate comments (1)
vera/browser/runtime.mjs (1)

1353-1358: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

allocMapWrapper() still allocates the bucket with an unrooted wrapper.

On this JS-side path, ptr exists only in a JS local between wrapHandle() and attach_bucket_to_wrapper(). If the first alloc() inside attach_bucket_to_wrapper() triggers $gc_collect, the fresh wrapper can be reclaimed before wrapperPtr + 8 is published, which re-opens the same UAF on the writeJson() / writeHtml() path. Mirror the CLI-side _ShadowGuard discipline around the attach call here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vera/browser/runtime.mjs` around lines 1353 - 1358, allocMapWrapper currently
creates a wrapper (ptr) but doesn't root it before calling
imports.vera.attach_bucket_to_wrapper, so a GC during attach can reclaim the
fresh wrapper and reopen the UAF; fix by mirroring the CLI-side _ShadowGuard
discipline: immediately root the newly wrapped ptr before calling
attach_bucket_to_wrapper (use the same rooting/guard API or mechanism used by
_ShadowGuard), perform attach_bucket_to_wrapper(ptr, 1, handle), then
release/unroot the guard after the attach completes so the wrapper is published
safely and the wrapperPtr+8 race is eliminated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@vera/browser/runtime.mjs`:
- Around line 1262-1274: The code currently coerces BigInt keys/values via
Number(...) before calling writeI32 (in the valWord and kw assignments near
slotBase usage), which can truncate large i64s and confuse the GC mirror; change
the conditional branches in the value and key handling so that typeof v ===
'bigint' and typeof k === 'bigint' do NOT use Number(...) but instead fall
through to the zero case (i.e., treat BigInt as 0) before calling writeI32;
update the same pattern referenced around the other occurrences noted (the block
using allocString, writeI32, valWord, kw, and slotBase) so all BigInt paths
write zero-filled slots rather than Number(bigint).

In `@vera/wasm/data.py`:
- Around line 195-210: _translate_let_destruct currently extracts constructor
fields into locals but omits the heap-rooting logic used in
_extract_constructor_fields, so scalar i32 ADT fields (i32 where type_name not
in _INLINE_I32_TYPES) can be reclaimed before use; after the line that emits
"local.set {local_idx}" inside _translate_let_destruct, add the same branching
logic used in _extract_constructor_fields: if wt == "i32" and type_name not in
_INLINE_I32_TYPES then emit a gc_shadow_push for the new local and record
needs_alloc for that local (mirroring the symbol usages gc_shadow_push and
needs_alloc), then continue pushing the binding into the environment
(new_env.push), and update tests to include a destructure of a scalar
heap-pointer field with an intervening allocation to ensure the rooting is
exercised.

---

Duplicate comments:
In `@vera/browser/runtime.mjs`:
- Around line 1353-1358: allocMapWrapper currently creates a wrapper (ptr) but
doesn't root it before calling imports.vera.attach_bucket_to_wrapper, so a GC
during attach can reclaim the fresh wrapper and reopen the UAF; fix by mirroring
the CLI-side _ShadowGuard discipline: immediately root the newly wrapped ptr
before calling attach_bucket_to_wrapper (use the same rooting/guard API or
mechanism used by _ShadowGuard), perform attach_bucket_to_wrapper(ptr, 1,
handle), then release/unroot the guard after the attach completes so the wrapper
is published safely and the wrapperPtr+8 race is eliminated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 548719d5-ef47-4663-823e-7a4dad182a55

📥 Commits

Reviewing files that changed from the base of the PR and between 755594c and b3eb760.

📒 Files selected for processing (4)
  • HISTORY.md
  • vera/browser/runtime.mjs
  • vera/codegen/api.py
  • vera/wasm/data.py

Three findings verified valid and fixed:

1. vera/browser/runtime.mjs `attach_bucket_to_wrapper` (Map and Set
   branches): BigInt values were being coerced via `Number(v)` before
   `writeI32` into the slot.  For Vera i64 keys/vals, large BigInts
   could (a) truncate misleadingly in the slot or (b) land inside the
   heap range and be misread as live heap pointers by the conservative
   scan, extending unrelated blocks' lifetimes.  Mirror is write-only
   (actual reads go through `mapStore` / `setStore`), so writing 0
   for BigInt is harmless and structurally safer.  Dropped the
   `typeof v === 'bigint' ? Number(v) : 0` branch so BigInt falls
   through to 0; same fix for the kw (key word) and ew (set element)
   paths.

2. vera/wasm/data.py `_translate_let_destruct` (lines 195-210):
   the parallel of the match-arm constructor-field path, but missing
   the heap-rooting discipline.  `let MyAdt(@JSON) = makeThing();`
   extracted an i32 field into a fresh local that was invisible to
   the conservative scan until shadow-pushed.  Added the same
   `if wt == "i32" and type_name not in _INLINE_I32_TYPES:
     self.needs_alloc = True; gc_shadow_push(local_idx)`
   pattern that round 1 added to `_extract_constructor_fields` and
   the `BindingPattern` branch.  Three rooting paths now matching
   each other:
     - match-arm BindingPattern (round 1)
     - match-arm constructor-field extraction (round 1)
     - let-destruct field extraction (round 2)

3. vera/browser/runtime.mjs `allocMapWrapper`: the wrapper returned
   from `wrapHandle` was registered with the wrap-table but NOT
   rooted via the shadow stack.  The wrap-table region lives below
   `gc_heap_start` and is NOT scanned by Phase 2a marking, so
   `register_wrapper` alone doesn't keep the wrapper alive.  Between
   `wrapHandle` returning and `attach_bucket_to_wrapper`'s internal
   bucket allocation, a `$gc_collect` would have reclaimed the
   fresh wrapper.  Fix: added JS-side `gcShadowPush` / `gcShadowPop`
   helpers using the exported `$gc_sp` / `$gc_stack_limit` mutable
   globals (added in v0.0.158 / #692 but never used from JS), and
   wrapped the attach call in a try/finally push/pop pair —
   mirroring the CLI `_ShadowGuard` discipline.

Validation: mypy clean (59 files); browser (99), Map/Set/GC + walker
(56), let-destruct (5), JSON+HTML (57) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread vera/browser/runtime.mjs
Comment thread vera/wasm/data.py
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

aallan and others added 2 commits May 28, 2026 20:02
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ultrareview cloud session surfaced 3 verified bugs.  2 are NEW (not
covered by CR rounds 1-2); the 3rd was already addressed in commit
1ff7f7c (CR round 2 duplicate of allocMapWrapper rooting).

1. vera/codegen/api.py `_alloc_bucket_array`:
   "Bucket arrays from $alloc free-list reuse contain stale i32s"

   The docstring at the prior implementation claimed `$alloc` returns
   zero-initialised memory unconditionally.  That holds for fresh
   bump-pointer allocations but not for the free-list reuse branch,
   which returns whatever the previous owner left in the block.  For
   a write-only mirror like the bucket array, stale i32s in slots
   beyond `len(d)` are not a correctness problem for map/set ops
   (those read through `_map_store` / `_set_store`), but they ARE
   traced by the conservative GC scan as potential heap pointers —
   extending unrelated blocks' lifetimes by aliasing their addresses.
   Added an explicit zero-fill via `_write_bytes(... b"\\x00" * total)`
   right after `_call_alloc`.  CLI parallel of the browser-side fix
   that `Uint8Array.fill(0)` applies in
   `vera/browser/runtime.mjs::attach_bucket_to_wrapper` (CR round 1).

2. vera/wasm/data.py pair-type field extraction (TWO sites):
   "let-destruct heap-pointer bindings not shadow-pushed (gap in #705)"

   The original #705 fix added `gc_shadow_push` + `needs_alloc = True`
   to the `wt == "i32"` branch of constructor-field extraction.  The
   parallel `if self._is_pair_type_name(type_name):` branch was
   missed.  For `match adt { Ctor(@string, ...) -> body }` or
   `let Ctor(@string, ...) = expr`, the `ptr_local` holds a heap
   pointer (string buffer or `Array<T>` backing) — invisible to the
   conservative scan from a WASM local until shadow-pushed.  A
   subsequent allocation in `body` could reclaim the buffer.

   Applied the same `self.needs_alloc = True; gc_shadow_push(ptr_local)`
   to:
     - `_extract_constructor_fields` (match-arm path), line ~509
     - `_translate_let_destruct` (let-destruct path), line ~192

   `len_local` is a length, not a pointer — no rooting needed.

   The #705 + CR rounds 1-2 + Ultrareview round 1 progression now
   covers four binding sites with consistent rooting discipline:
     - match-arm BindingPattern (#705 round 1)
     - match-arm constructor-field i32 (#705 round 1)
     - match-arm constructor-field pair-type (Ultrareview round 1)
     - let-destruct field i32 (CR round 2)
     - let-destruct field pair-type (Ultrareview round 1)

3. vera/browser/runtime.mjs allocMapWrapper rooting:
   Already fixed in commit 1ff7f7c (CR round 2 duplicate finding).
   Ultrareview reviewed an earlier commit; current code has the
   `gcShadowPush(ptr) / try / attach / finally gcShadowPop()` pattern.

Validation: mypy clean (59 files); browser (99), Map/Set/GC + walker
(56), destructure (6) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

PR Review (pr-review-toolkit, four agents in parallel)

Ran code-reviewer, pr-test-analyzer, comment-analyzer, and silent-failure-hunter against commit 4d69cb8. Findings below in severity order.

Critical (4)

# Finding Location
C1 gcShadowPush silently no-ops when wasm.gc_sp / wasm.gc_stack_limit aren't exported. CLI-side _ShadowGuard raises a clear RuntimeError for the same condition — JS degrades to UAF vera/browser/runtime.mjs:1366-1372
C2 wrapHandle silently skips register_wrapper if not exported → permanent _map_store leak per write, no warning vera/browser/runtime.mjs:1347-1349
C3 TestMapHostStoreGCReachability695 docstrings still reference an xfail marker that was removed, AND describe the "move" approach (#706) rather than the mirror approach shipped here tests/test_codegen.py:19308-19310, 19378-19380
C4 host_attach_bucket collapses "handle missing from _map_store" (UAF) and "handle present, empty map" (legitimate) into one silent return vera/codegen/api.py:2498-2506

Important (8)

  • I1 + I2 + I3: Three rooting fix sites have NO VERA_EAGER_GC=1 regression test:
    • Let-destruct heap rooting (data.py:189, 212) — round-2/round-3 fixes
    • Match-arm BindingPattern (data.py:441-453) — round-1 fix
    • Browser-side attach_bucket_to_wrapper, allocMapWrapper, BigInt-as-zero — covered only by stdout parity, no GC stress
  • I4: host_attach_bucket's elif kind == SET and result.set_ops_used collapses two error conditions
  • I5: JS wrapHandle lacks _validate_wrap_handle parity for bit-31 range check
  • I6 + I7 + I8: Comment-rot risks — "Phase B/C/D" labels not externally defined (10+ sites), api.py:3050 references nonexistent "Phase D move tracking issue", round-number attribution loses meaning post-squash-merge

Suggestions (5)

_INLINE_I32_TYPES duplicated across data.py + context.py; gcShadowPush/Pop guard asymmetry; missing tests/conformance/ch09_host_store_gc_reachability.vera; missing ~100-key stressed-size test; BigInt→0 fragile for the #706 follow-up.

Strengths

  • Architectural mirror-vs-move choice comprehensively documented
  • Critical ordering discipline consistent across CLI and browser (val-word-first + zero-fill + immediate wrapper+8 write)
  • Shadow-stack discipline at let/match binding sites correct (needs_alloc = True paired with gc_shadow_push)
  • Decimal exemption well-justified at three sites with matching rationale
  • Bit-31 mask fix in readJson/readHtml brings browser into parity with CLI Conservative GC scan can spuriously retain heap objects via wrapper handle field (#573 latent) #578 discipline
  • Version sync, doc counts, conformance (88/88), mypy all green

Action plan

Proceeding to address C1-C4 (critical) and I1-I3 (the test gaps — highest leverage) on this branch. I6/I7/I8 (comment cleanup) and the lower-priority items deferred to a separate cleanup commit if helpful, or to the #706 follow-up.

Aggregated four review agents (code-reviewer, pr-test-analyzer,
comment-analyzer, silent-failure-hunter) found 4 critical issues and
3 high-leverage test gaps.  All critical issues fixed; test gaps
addressed for CLI side (the new test caught a pre-existing browser
bug that needs its own follow-up, tracked as #708).

## Critical fixes

C1. `vera/browser/runtime.mjs` `gcShadowPush` / `gcShadowPop`: now
    raise `Error` when `$gc_sp` / `$gc_stack_limit` aren't exported,
    matching the CLI `_ShadowGuard.__init__` discipline that raises
    a clear `RuntimeError` for the same condition.  Prior behavior
    silently no-op'd → downstream UAF for any module built without
    GC support that tries to wrap a Map / Set value.

C2. `vera/browser/runtime.mjs` `wrapHandle`: now raises when
    `register_wrapper` isn't exported, matching the CLI-side
    `_call_register_wrapper`.  Prior behavior allocated the wrapper
    and seeded the JS-side mapStore entry but silently skipped the
    wrap-table registration → permanent leak per write.

C3. `tests/test_codegen.py::TestMapHostStoreGCReachability695`
    docstrings: rewrote the class docstring and the
    `test_eager_gc_json_object_with_array_child_post_walk_uaf`
    docstring to describe the mirror approach actually shipped
    (bucket-array reachability anchor), not the deferred "move"
    approach that the comments still described (the move is
    tracked as #706).  Also removed the stale "xfail marker must
    be removed" text — the marker was removed when the fix landed
    in 8b59454 / 7c4fe78.

C4. `vera/codegen/api.py` `host_attach_bucket`: distinguish
    "handle missing from store" (UAF — wrapper outlived its store
    entry, possibly via Phase 2c decref bug or wrap-table corruption)
    from "handle present, empty collection" (legitimate empty
    Map / Set).  Now raises `RuntimeError` for the UAF case.  Also
    split the Set branch's collapsed `kind == SET and set_ops_used`
    check (silent-failure-hunter I4) — unknown kinds + SET-without-
    set_ops_used now raise rather than silently leave bucket_ptr=0.

## High-leverage test additions

I1. `test_eager_gc_let_destruct_with_json_field_post_walk_uaf` —
    exercises BOTH the i32 heap-pointer field rooting (`@Json`)
    and the pair-type field rooting (`@String`) added to
    `_translate_let_destruct` in the round-2 / round-3 fixes via
    `let Tuple<@JSON, @string> = Tuple(...)` with an intervening
    `json_array_length` alloc.

I2. `test_eager_gc_match_binding_pattern_heap_pointer_post_walk_uaf`
    — exercises the bare `match @Json.0 { @JSON -> ... }`
    `ast.BindingPattern` branch added in round 1.  All three
    existing regression tests use `ConstructorPattern` (`Ok(@JSON) ->`)
    which goes through `_extract_constructor_fields`, leaving the
    BindingPattern path with no regression coverage.

I3 (partial). `TestBrowserMapHostStoreGCReachability695` added
    with three regression tests mirroring the CLI ones at
    `--target browser` with `VERA_EAGER_GC=1`.  All three currently
    `@pytest.mark.skip` because they surfaced a pre-existing
    browser-runtime bug: the JS-side bucket IS populated correctly
    but the heap block still gets reclaimed.  Reverting to commit
    `4d69cb8` (before the C1-C4 changes) reproduces the same
    failure — NOT introduced by this round.  Tracked as #708 so
    this PR can ship without being gated on a deeper browser
    investigation.

## Documentation

- `KNOWN_ISSUES.md` Bugs section: added #708 with full reproducer
  and what the debug instrumentation showed (bucket populated
  correctly per console.error, but Json still reclaimed).
- TESTING.md / ROADMAP.md doc counts: 3950 → 3955.

## Lower-priority items deferred

I4-I8 (silent-failure-hunter and comment-analyzer): the larger
comment-rot cleanup (round-number attribution, Phase B/C/D labels,
the `api.py:3050` "Phase D move tracking issue" reference,
`_INLINE_I32_TYPES` duplication) can land in a separate cleanup
commit or be addressed by #706.  None block this PR.

## Validation

- mypy clean (59 files)
- Map/Set + GC reachability: 5 passed (3 existing + 2 new)
- Browser tests: 99 passed + 3 skipped (with #708 rationale)
- JSON+HTML collection: 83 passed
- match/pattern + let-destruct: 11 passed
- Doc counts: consistent (3955 tests, 32 files, 88 conformance, 35 examples)
- Version: 0.0.159 consistent across 6 files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

Fixes Applied (pr-review-toolkit round)

All 4 critical + 2 of 3 high-leverage test gaps addressed in commit (link below). Lower-priority comment-rot items deferred — won't block merge.

Critical (4/4 ✅)

# Fix
C1 gcShadowPush / gcShadowPop now raise when $gc_sp / $gc_stack_limit aren't exported, matching the CLI _ShadowGuard discipline. No more silent UAF for modules built without GC support that try to wrap a Map / Set.
C2 wrapHandle now raises when register_wrapper isn't exported. No more silent permanent mapStore leak.
C3 Rewrote both stale TestMapHostStoreGCReachability695 docstrings — now describe the mirror approach actually shipped (not the deferred #706 "move"), and dropped the obsolete xfail-removal text.
C4 host_attach_bucket now distinguishes "handle missing from store" (UAF) from "handle present, empty collection" (legitimate), and split the Set-branch collapsed kind==SET and set_ops_used check. All silent no-ops on error states now raise.

High-leverage tests (2/3 + 1 follow-up)

# Fix
I1 ✅ test_eager_gc_let_destruct_with_json_field_post_walk_uaf — exercises BOTH the i32 and pair-type rooting in _translate_let_destruct via let Tuple<@Json, @String> = Tuple(...) with an intervening alloc. Passes.
I2 ✅ test_eager_gc_match_binding_pattern_heap_pointer_post_walk_uaf — exercises the bare match @Json.0 { @Json -> ... } ast.BindingPattern branch. Existing tests all used ConstructorPattern, leaving this path uncovered. Passes.
I3 ⚠️ TestBrowserMapHostStoreGCReachability695 added with three regression tests at --target browser + VERA_EAGER_GC=1. All three currently @pytest.mark.skip — they exposed a pre-existing browser-runtime bug that this PR's mirror approach doesn't close. Reverting to 4d69cb8 (before these C1-C4 fixes) reproduces the same failure, so NOT introduced by this round. Empirical instrumentation confirmed the JS-side bucket IS populated correctly (slot[0]+0 = Json ptr, wrapperPtr+8 = bucket ptr) but the heap block still gets reclaimed. Filed as #708 with full reproducer + diagnostic data — gated separately so this PR can ship the CLI fix without being blocked on a deeper browser investigation.

Lower-priority items deferred

I4-I8 (silent-failure-hunter and comment-analyzer) — the larger comment-rot cleanup:

None block this PR; can land in a follow-up cleanup commit or be absorbed by #706.

Validation

Related

Commit: 6a72b35 — fix: address pr-review-toolkit findings on PR #707

Root-caused #708 (the pre-existing browser-runtime bug surfaced by
the round-3 EAGER_GC tests) to a missing port of the v0.0.158 #692
`_ShadowGuard` fix from CLI to browser.  The CLI `write_json` /
`write_html` host walkers use `_ShadowGuard` to root intermediate
allocations across multi-alloc walks.  The browser `writeJson` /
`writeHtml` JS-side equivalents never got the same fix.

## Diagnostic

Empirical instrumentation showed the failing `Set<Json>` test had
`Json` at address X with `tag=0, +4=X, +8=10` — a `Result.Ok` shape
allocated on top of the original `JArray`'s freed block.  Tracing
backward: `writeJson([1,...,10])` allocates a JArray's array_backing
of 10 JNumber pointers, then recursively allocates each JNumber.
Under `VERA_EAGER_GC=1` every `alloc()` fires `$gc_collect`.  The
`arrPtr` for the backing is held only in a JS local — invisible to
the conservative scan.  By the time the recursive JNumber writes
into `arrPtr`, the backing block has been reclaimed.  Then the
outer JArray block's `tag` and `+4` get overwritten by subsequent
allocations.  Then `json_parse`'s `allocResultOkI32(jsonPtr)`
allocates a 8-byte block — which gets the freed JArray's block as
its address, with `tag=0` (Result.Ok) and `+4=jsonPtr=self`.

## Fix

1. **`gcGuard(fn)` helper** in `vera/browser/runtime.mjs` — saves
   `$gc_sp` at entry, restores on exit.  JS-side parallel of
   `_ShadowGuard.__enter__/__exit__`.

2. **`writeJson` / `writeHtml`** wrapped in `gcGuard` and updated
   to `gcShadowPush` every intermediate: `arrPtr` (JArray's
   backing), each recursive child result, string ptrs, `wrapperPtr`
   (JObject's map wrapper).  Mirrors the CLI `_ShadowGuard.push`
   discipline from #692.

3. **`json_parse` / `html_parse`** wrap their `allocResultOkI32`
   call in `gcGuard` and shadow-push `jsonPtr` / `nodePtr` so the
   result is rooted across the Result wrapper alloc.

## Tests

The three previously-skipped browser EAGER_GC regression tests
(`TestBrowserMapHostStoreGCReachability695`) all now pass.  The
`@pytest.mark.skip` decorators removed.

## Comment-rot cleanup (pr-review-toolkit comment-analyzer I6-I8)

- Stripped "round 1" / "round 2" / "round 3" / "round 4" from
  inline comments across api.py, runtime.mjs, data.py — these
  round-number attributions lose meaning post-squash-merge.
  Replaced with "PR #707 review" where the attribution adds value.
- Replaced "Phase A" / "Phase B" / "Phase B.3" / "Phase D move
  tracking issue" labels in api.py with concrete prose referring
  to #706 where applicable.  These were labels for an
  implementation plan that wasn't recorded anywhere external.
- Phase 2a / Phase 2c references (the GC architecture's own
  internal mark / wrap-table-compaction phases) preserved —
  those are real, documented phases of `$gc_collect`.

## `_INLINE_I32_TYPES` dedup (pr-review-toolkit code-reviewer S1)

Hoisted `_INLINE_I32_TYPES = frozenset({"Bool", "Byte", "Unit"})`
from `vera/wasm/data.py` and `vera/wasm/context.py` (where it was
duplicated with cross-referencing comments) to `vera/wasm/helpers.py`.
Both consumers now import from the single authoritative location.

## Doc updates

- KNOWN_ISSUES.md: removed #708 from Bugs (now closed); section
  reverts to "No known bugs."
- CHANGELOG.md: added #708 bullet describing the browser-side fix.
- HISTORY.md: extended the v0.0.159 row to cover #708.
- TESTING.md: test_browser.py line count corrected (skip
  decorators removed: 2014 → 1982).

## Validation

- mypy clean (59 files)
- Map/Set + GC reachability (CLI): 5 passed
- Browser EAGER_GC regression suite: 3 passed
- Full browser suite: 102 passed
- Doc counts: consistent (3955 tests, 32 files, 88 conformance,
  35 examples)
- Version: 0.0.159 consistent across 6 files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

Round 4 — #708 closed in-band + comment cleanup + #707 review wrap-up

The pre-existing browser bug #708 is now fixed in this PR rather than gated separately.

#708 root cause + fix

JS-side console.error instrumentation of the Json body identified the root cause as the missing port of the v0.0.158 #692 _ShadowGuard fix to the browser:

  • CLI write_json / write_html use _ShadowGuard to root intermediate allocations across multi-alloc walks (the v0.0.158 fix).
  • Browser JS-side writeJson / writeHtml never got the equivalent.
  • Under VERA_EAGER_GC=1, arrPtr (a JArray's 10-element backing) is held only in a JS local — invisible to the conservative scan. The recursive writeJson(value[i]) calls fire GC, reclaim arrPtr, then JArray's outer body block gets overwritten by json_parse's allocResultOkI32. The address bound as @Json ends up holding a Result.Ok(self) shape on top of the freed JArray block. json_array_length(@Json) reads tag=0 (Result.Ok, not JArray=4) → returns 0.

Fix: added JS-side gcGuard(fn) helper that mirrors _ShadowGuard.__enter__/__exit__, and gcShadowPush for every intermediate in writeJson / writeHtml / json_parse / html_parse. The three previously-skipped browser EAGER_GC tests now pass.

Comment cleanup (I6 + I7 + I8)

_INLINE_I32_TYPES dedup (S1)

Hoisted from data.py + context.py (where it was duplicated with cross-referencing comments) to helpers.py. Both consumers now import from a single authoritative location — a new inline-i32 type added later can't drift.

Doc updates

Final state

Issue Status
#695 Closed (CLI + browser)
#705 Closed (CLI + browser)
#708 Closed
#706 Tracked follow-up (architectural move)
Validation Result
mypy ✅ clean (59 files)
Map/Set + GC reachability (CLI) ✅ 5 passed
Browser EAGER_GC regression suite ✅ 3 passed (un-skipped)
Full browser suite ✅ 102 passed
Doc counts ✅ consistent (3955 tests, 32 files, 88 conformance, 35 examples)
Version sync ✅ 0.0.159 across 6 files

Commit: 413f42b — fix: close #708 (browser GC reachability) + final cleanup

…ce test

Post-merge documentation sweep across the project after closing #695,
#705, and #708 in PR #707:

- HISTORY.md: trim v0.0.149-v0.0.159 entries to single-sentence canonical
  form (Stage 1 pattern), remove inconsistent bold introduced at v0.0.149.
- tests/conformance/ch09_host_store_gc_reachability.vera: new conformance
  test exercising Map<String, Json> / Set<Json> user-level construction
  patterns (4 functions). Closes a coverage gap identified by the
  pr-test-analyzer agent on #707: the existing CLI/browser pytest tests
  cover the underlying mechanism but a conformance program nails the
  user-level contract.
- tests/conformance/manifest.json: register the new conformance test at
  the `run` level with `map`, `set`, `json`, `gc_reachability` tags.
- vera/README.md: document the JS-side GC reachability discipline
  (gcGuard / gcShadowPush) added for #708, paralleling the v0.0.158
  CLI-side _ShadowGuard discipline.
- Doc counts bumped from 88 → 89 conformance and 3,955 → 3,960 tests
  across SKILL.md, CLAUDE.md, AGENTS.md, README.md, FAQ.md, TESTING.md,
  ROADMAP.md, docs/index.html, docs/SKILL.md, docs/index.md, docs/llms*.

Validation: check_doc_counts.py, check_conformance.py, check_examples.py,
check_site_assets.py all pass.  mypy clean.  pytest tests/test_conformance.py
430 passed.

Skip-changelog: documentation sweep + test-only addition; no runtime behaviour change beyond what v0.0.159 already shipped.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

Round 5 — documentation sweep + new conformance test

You asked for the full documentation sweep across SKILL / CLAUDE / AGENTS / README / vera/README / FAQ / TESTING / HISTORY / KNOWN_ISSUES / spec / docs / examples / tests for consistency, stale workarounds, and limitations-list accuracy. Also asked me to write down anything in context but not yet documented, and to trim HISTORY entries to single sentences back to v0.0.157 at least, fixing the bold-since-v0.0.149 inconsistency. All done in 89afcb8.

HISTORY.md trim (v0.0.149 → v0.0.159)

Trimmed back further than the v0.0.157 floor — went all the way to v0.0.149 since that was where the bold started, and trimming half-way would have left the style inconsistency. All 11 entries are now single-sentence, no bold, [#NNN](url) reference only. Matches the Stage 1 canonical pattern. v0.0.146-v0.0.148 were already in that form.

New conformance test: ch09_host_store_gc_reachability

The pr-test-analyzer's coverage-gap finding on #707 was: the CLI / browser pytest tests cover the mechanism (EAGER_GC stress, post-walk UAF, mark-phase walks the bucket) but no conformance program exercises the user-level contract. Closed with 4 functions in a new conformance test at the run level:

  • test_map_string_json_user_leveljson_parse("[1,2,3,4,5]")map_insertjson_array_length round-trip
  • test_set_of_json_user_levelSet<Json> with array_fold summing lengths
  • test_map_chain_construction — 3 chained map_insert calls, verify size
  • test_set_chain_construction — 3 chained set_add calls, verify size

Each returns 0 on success per the established test_X(@Unit -> @Int) conformance pattern. Manifest entry tags: map, set, json, gc_reachability.

Newly-documented in-context knowledge

The user-instructed "write down anything in context but not yet documented" sweep surfaced one gap: vera/README.md Browser Runtime section described the host-binding architecture and the bundled Markdown parser but didn't document the GC reachability discipline that this PR added on the JS side. Added a paragraph documenting gcShadowPush / gcGuard / gcShadowPop, why they're necessary (multi-alloc walkers + JS-local intermediates + EAGER_GC), the canonical pattern (writeJson / writeHtml / json_parse / html_parse all wrap bodies in gcGuard and push intermediates), and the failure mode when omitted (Map<K, Json> / Set<Json> drop values, #708).

Doc counts

3,955 → 3,960 tests (the new conformance program contributes 5 pytest cases: parse + check + verify + run + format-idempotent). 88 → 89 conformance programs. Other counts unchanged. check_doc_counts.py passes.

Sweep findings (negative results)

Validation

  • mypy vera/ — clean (59 files)
  • pytest tests/test_conformance.py -v — 430 passed, 15 skipped (the new test contributes to the count)
  • python scripts/check_doc_counts.py3960 tests, 32 files, 89 conformance, 35 examples, 27 hooks, 8 CI jobs
  • python scripts/check_conformance.py — All 89 conformance programs pass
  • python scripts/check_examples.py — All 35 examples pass (check + verify)
  • python scripts/check_site_assets.py — Site assets are up-to-date
  • Pre-commit hooks all green (allowlists, conformance, examples, readme, skill, faq, html, doc counts, limitations sync, site assets).

What's next

Nothing further planned for this PR — it now closes #695 + #705 + #708 with conformance test, regression tests, browser parity tests, doc sweep, and HISTORY trim. After merge: tag v0.0.159, GitHub release, then move on to #706 (architectural cleanup: mirror → bucket-as-truth).

@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ROADMAP.md`:
- Line 7: The document has an inconsistent conformance count: the header shows
"89 conformance programs" but an older occurrence still reads "88"; update every
occurrence of the old value "88 conformance" (or the bare "88" used as the
conformance count) in ROADMAP.md to "89" so the file is consistent—search for
the string "88" or "88 conformance" and replace with "89" (verify both the
summary line and the later mention around the conformance programs paragraph).

In `@TESTING.md`:
- Line 83: Update the stale numeric reference for the conformance tests in
TESTING.md: find every literal "440" that refers to the parametrized
conformance-suite count for `test_conformance.py` (the entry labeled
`test_conformance.py`) and change it to "445" so all occurrences match the
updated count; search the file for any remaining "440" mentions tied to that
test description and update them to "445".
- Line 9: The Tests row under the "**Tests**" table currently has inconsistent
arithmetic (3,960 total vs components 3,920 passed + 16 stress + 14 skipped =
3,950); update the broken value so the sum of component counts equals the stated
total: either correct the total (e.g., to 3,950) or adjust the component counts
(passed/stress/skipped) to match the intended 3,960. Edit the table cell text in
TESTING.md's "**Tests**" row to reflect the reconciled numbers and ensure
formatting remains "| **Tests** | X across Y files (Z lines of test code; A
passed + B stress, C skipped) |".

In `@tests/test_browser.py`:
- Around line 1858-1865: The test helper that sets VERA_EAGER_GC and calls
_compile_vera / _run_node currently returns stdout but ignores result["error"],
so add an explicit assertion that the Node runtime produced no error before
returning stdout (e.g., assert that result.get("error") is falsy or empty),
referencing the local variable result obtained from _run_node to fail the test
if the harness reported an error.

In `@vera/browser/runtime.mjs`:
- Around line 1237-1239: The code silently returns when mapStore.get(handle)
yields no entry (variable d), which hides desynchronization/UAF issues; replace
the silent return with an explicit thrown error (e.g., throw new Error) that
includes the missing handle value and context ("Unknown host-store handle" or
similar) so failures fail fast; update both occurrences that read const d =
mapStore.get(handle) (the one using _BUCKET_INITIAL_CAPACITY and the other
similar block) to perform this check and throw instead of returning.
- Around line 1923-1927: The loop in writeJson calls gcShadowPush for each child
(gcShadowPush(ep)) but never calls gcShadowPop, causing the shadow stack to grow
and risk overflowing gc_stack_limit; fix by pairing each push with a
corresponding gcShadowPop immediately after the child pointer is stored (after
writeI32(arrPtr + i * 4, ep)) so each pushed entry is popped before the next
iteration, and make the same change to the other analogous loop using
gcShadowPush (the one later in the file that mirrors lines 2234-2238).

In `@vera/codegen/api.py`:
- Around line 1020-1023: The empty-slot sentinel uses key_word_0 == 0 but
_alloc_string("") returns (0,0), so empty string keys collide with the sentinel
in functions like _alloc_string and _probe_string_key; change the string-key
encoding so "" is never encoded as (0,0) (for example reserve a distinct
non-zero encoding for the empty string, or add an explicit occupancy bit before
interpreting key_word_0), and update all places that construct or test string
key words (references: _alloc_string, _probe_string_key and the related
population/probing code around the noted blocks) to use the new encoding or
occupancy check so probing does not treat "" as an empty slot.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7fe3ce7f-23d3-4169-a0c2-53512d113337

📥 Commits

Reviewing files that changed from the base of the PR and between ce9875c and 89afcb8.

⛔ Files ignored due to path filters (7)
  • docs/SKILL.md is excluded by !docs/**
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • tests/conformance/ch09_host_store_gc_reachability.vera is excluded by !**/*.vera
  • uv.lock is excluded by !**/*.lock, !uv.lock
📒 Files selected for processing (23)
  • AGENTS.md
  • CHANGELOG.md
  • CLAUDE.md
  • FAQ.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • SKILL.md
  • TESTING.md
  • pyproject.toml
  • tests/conformance/manifest.json
  • tests/test_browser.py
  • tests/test_codegen.py
  • vera/README.md
  • vera/__init__.py
  • vera/browser/runtime.mjs
  • vera/codegen/api.py
  • vera/codegen/assembly.py
  • vera/wasm/calls_containers.py
  • vera/wasm/context.py
  • vera/wasm/data.py
  • vera/wasm/helpers.py

Comment thread ROADMAP.md
Comment thread TESTING.md Outdated
Comment thread TESTING.md
Comment thread tests/test_browser.py
Comment thread vera/browser/runtime.mjs
Comment thread vera/browser/runtime.mjs
Comment thread vera/codegen/api.py
7 inline findings from CodeRabbit. 6 fixed, 1 deferred with documented rationale.

ROADMAP.md (CR1): stale "88 conformance programs" in the testing-gaps
table now reads 89, matching the v0.0.159 count (added in 89afcb8 via the
new ch09_host_store_gc_reachability program).

TESTING.md (CR2 + CR3): two count drift fixes.

- CR2: the "Via pytest (parametrized - 440 tests)" comment in the
  conformance-suite-running section now reads 445, matching the table
  row earlier in the file.
- CR3: the Tests row arithmetic (3,920 + 16 + 14 = 3,950 vs claimed
  3,960) reconciles to the actual pytest numbers: 3,929 passed + 16 stress
  + 15 skipped = 3,960.  The 15-skipped table was also missing the
  test_run[ch07_io_read_char] row (added in v0.0.157 with the IO.read_char
  effect, but not propagated to TESTING.md); added it inline with the
  other verify-level run skips.

tests/test_browser.py (CR4): the _run_eager_gc_node helper now asserts
result[error] is falsy before returning stdout - previously a Node
harness error during VERA_EAGER_GC=1 runs would be silently dropped,
hiding a fresh trap behind whatever partial stdout the IO.print buffer
had flushed.

vera/browser/runtime.mjs (CR5): silent return on undefined mapStore /
setStore handle in attach_bucket_to_wrapper now throws.  The only paths
that reach the host call with an unknown handle are real bugs (handle
constructed outside mapAlloc, or wrapper reached the host call after
host_decref_handle already evicted it); silent return left wrapper+8
unwritten and produced a confusing downstream trap.  Symmetric with the
PR #707 round-2 silent-failure-hunter fixes on gcShadowPush / wrapHandle.

vera/browser/runtime.mjs (CR6): the writeJson JArray and writeHtml
children loops now pair gcShadowPush(ep/cp) with gcShadowPop() after
writeI32 stores the pointer.  Once the pointer lives in arrPtr block
and arrPtr is rooted, the conservative scan reaches it via arrPtr -
the per-iteration push is no longer load-bearing.  Without the matching
pop the shadow stack grew O(count) and risked overflowing gc_stack_limit
on large arrays.  The JObject branch was inspected and intentionally
left unchanged with a comment: m.set(k, ep) writes to a JS Map (not WASM
memory), so ep stays on the shadow stack until allocMapWrapper writes
it into the WAT-resident bucket.

vera/codegen/api.py (CR7) DEFERRED with rationale: _alloc_string empty
returns (0, 0), so an empty-string key would collide with the
key_word_0 == 0 empty-slot sentinel in _probe_string_key.  Verified
that _probe_string_key has zero callers - the bucket array is a
write-only GC mirror, lookups go through _map_store / _set_store
Python dicts.  Documented the latent caveat in the bucket layout
docstring and posted a comment on #706 (which tracks the
mirror -> bucket-as-truth migration that will activate the probe
path for real).  Fixing prematurely would lock in an encoding choice
that depends on how probing is restructured under #706.

Validation:
- mypy vera/ clean (59 files)
- pytest tests/ -m "not stress" - 3,929 passed, 15 skipped (matches CR3 numbers)
- pytest tests/test_browser.py -v - 102 passed (full browser parity)
- pytest tests/test_browser.py -k eager_gc -v - 3 passed (regressions)
- check_doc_counts.py, check_site_assets.py, check_conformance.py,
  check_examples.py - all green

Skip-changelog: minor doc + comment cleanup + 3 minor runtime hardening fixes (silent-return -> throw, push/pop bound stack, assert harness error). No user-facing behaviour change beyond what v0.0.159 already shipped.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan

aallan commented May 28, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit findings — 6 fixed, 1 deferred with rationale

Commit b81cde4.

# File Status Notes
CR1 ROADMAP.md:192 ✅ Fixed "88 conformance programs" → 89 (stale since 89afcb8)
CR2 TESTING.md:194 ✅ Fixed "440 tests" → 445 (stale since v0.0.157's read_char addition)
CR3 TESTING.md:9 ✅ Fixed Reconciled 3,929 passed + 16 stress + 15 skipped = 3,960 (was claiming 3,920 + 16 + 14 = 3,950, off by 10). Also added the missing test_run[ch07_io_read_char] row to the skip table.
CR4 tests/test_browser.py:1865 ✅ Fixed _run_eager_gc_node now asserts result["error"] is falsy before returning stdout.
CR5 vera/browser/runtime.mjs:1237,1287 ✅ Fixed Both branches of attach_bucket_to_wrapper now throw on undefined handle. Symmetric with the round-2 gcShadowPush / wrapHandle silent-failure-hunter fixes.
CR6 vera/browser/runtime.mjs:1923,2237 ✅ Fixed JArray + writeHtml children loops pair gcShadowPush(ep/cp) with gcShadowPop() after writeI32. Once stored in arrPtr's block, the conservative scan reaches the pointer via arrPtr itself — per-iteration push is no longer load-bearing, and the matching pop keeps stack depth O(1) instead of O(count). JObject branch inspected and left unchanged with a comment: m.set(k, ep) writes to a JS Map (not WASM memory), so ep must stay on the shadow stack until allocMapWrapper builds the WAT bucket.
CR7 vera/codegen/api.py:1020 ⏸ Deferred _alloc_string("") returns (0, 0) collides with the empty-slot sentinel key_word_0 == 0. Verified _probe_string_key has zero callers — the bucket array is a write-only GC mirror; lookups go through _map_store / _set_store Python dicts which key on Python str correctly. Latent only. Documented the caveat in the bucket-layout docstring and posted a comment on #706 (issue-comment) — the right shape (distinct non-zero "" encoding vs. explicit occupancy bit vs. swapped (ptr, len) semantics) depends on how probing is restructured in the move-to-bucket-as-truth migration. Fixing now would lock in a choice that #706 needs to make.

Validation

@aallan aallan merged commit 7c30465 into main May 29, 2026
28 checks passed
@aallan aallan deleted the fix/695-705-host-store-gc-reachability branch May 29, 2026 06:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment