fix: close #695 and #705 (Map/Set heap-pointer GC reachability)#707
Conversation
…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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis 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. ChangesMap/Set Heap-Value GC Reachability
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…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>
There was a problem hiding this comment.
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 winUpdate the HISTORY roll-up release total to 159.
After adding
v0.0.159at Line 306, the footer at Line 347 still says158 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.mdfooter 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
⛔ Files ignored due to path filters (5)
docs/index.htmlis excluded by!docs/**docs/index.mdis excluded by!docs/**docs/llms-full.txtis excluded by!docs/**docs/llms.txtis excluded by!docs/**uv.lockis excluded by!**/*.lock,!uv.lock
📒 Files selected for processing (15)
CHANGELOG.mdHISTORY.mdKNOWN_ISSUES.mdREADME.mdROADMAP.mdTESTING.mdpyproject.tomltests/test_codegen.pyvera/__init__.pyvera/browser/runtime.mjsvera/codegen/api.pyvera/codegen/assembly.pyvera/wasm/calls_containers.pyvera/wasm/context.pyvera/wasm/data.py
|
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>
|
@coderabbitai thanks for the round 1 review — all five findings verified and addressed in commit b3eb760:
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 |
|
Tip For best results, initiate chat on the files or code changes.
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 Fix 3 (val_word ordering in Fix 4 ( Validation coverage across all six suites (including the 10 000-element chain reclaim stress test) is persuasive. |
There was a problem hiding this comment.
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 winKeep browser
BigIntentries zero-filled in bucket slots.The PR contract treats browser
BigIntvalues as value-typed. Coercing them throughNumber(...)and thenwriteI32()truncates twice, so a large i64 key/value/element can alias a heap-looking word and pin unrelated blocks. These GC-only mirrors should leavebigintcases as0.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 winAdd heap-pointer rooting to
_translate_let_destructfor scalari32ADT fields
vera/wasm/data.py::_translate_let_destructextracts constructor fields into locals and pushes them to the env, but it never applies the samegc_shadow_push/needs_allocrooting 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’sast.LetDestructpath only appends the emitteddestr_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_fieldsrooting block inside_translate_let_destructright afterlocal.set {local_idx}for thewt == "i32" and type_name not in _INLINE_I32_TYPEScase, and add/extend a test that destructures a scalar heap-pointer field (not just@Stringpair 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,
ptrexists only in a JS local betweenwrapHandle()andattach_bucket_to_wrapper(). If the firstalloc()insideattach_bucket_to_wrapper()triggers$gc_collect, the fresh wrapper can be reclaimed beforewrapperPtr + 8is published, which re-opens the same UAF on thewriteJson()/writeHtml()path. Mirror the CLI-side_ShadowGuarddiscipline 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
📒 Files selected for processing (4)
HISTORY.mdvera/browser/runtime.mjsvera/codegen/api.pyvera/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>
|
Actionable comments posted: 0 |
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>
|
Actionable comments posted: 0 |
PR Review (pr-review-toolkit, four agents in parallel)Ran Critical (4)
Important (8)
Suggestions (5)
Strengths
Action planProceeding 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 |
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>
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 ✅)
High-leverage tests (2/3 + 1 follow-up)
Lower-priority items deferredI4-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: |
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>
Round 4 — #708 closed in-band + comment cleanup + #707 review wrap-upThe pre-existing browser bug #708 is now fixed in this PR rather than gated separately. #708 root cause + fixJS-side
Fix: added JS-side Comment cleanup (I6 + I7 + I8)
|
| 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>
Round 5 — documentation sweep + new conformance testYou 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, New conformance test:
|
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (7)
docs/SKILL.mdis excluded by!docs/**docs/index.htmlis excluded by!docs/**docs/index.mdis excluded by!docs/**docs/llms-full.txtis excluded by!docs/**docs/llms.txtis excluded by!docs/**tests/conformance/ch09_host_store_gc_reachability.verais excluded by!**/*.verauv.lockis excluded by!**/*.lock,!uv.lock
📒 Files selected for processing (23)
AGENTS.mdCHANGELOG.mdCLAUDE.mdFAQ.mdHISTORY.mdKNOWN_ISSUES.mdREADME.mdROADMAP.mdSKILL.mdTESTING.mdpyproject.tomltests/conformance/manifest.jsontests/test_browser.pytests/test_codegen.pyvera/README.mdvera/__init__.pyvera/browser/runtime.mjsvera/codegen/api.pyvera/codegen/assembly.pyvera/wasm/calls_containers.pyvera/wasm/context.pyvera/wasm/data.pyvera/wasm/helpers.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>
CodeRabbit findings — 6 fixed, 1 deferred with rationaleCommit b81cde4.
Validation
|
Summary
Closes #695 and #705 — silent use-after-free where heap-pointer values stored in
Map<K, T_heap>andSet<T_heap>were invisible to the conservative GC scan. A$gc_collectbetween map / set construction and value access would reclaim the heap blocks (observed atVERA_EAGER_GC=1returning 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:
attach_bucket_to_wrapperimport — dispatches tohost_attach_bucket(CLI) and the new browser-runtime JS implementation. Fires after every_emit_wrap_handlesomap_new/map_insert/map_remove/set_add/set_removereturning wrappers all get their bucket attached._alloc_map_wrapper— used bywrite_json'sJObjectbranch andwrite_html'sHtmlElementattrs. Populates the bucket inline with the wrapper allocation.vera/wasm/data.pyandvera/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, butmatchandletbinding sites did not. Heap-pointer Vera locals are now shadow-pushed on creation.Decimal is exempt:
PyDecimal(CLI) andBigInt(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_storeentirely 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
vera/codegen/api.py— bucket allocation helpers,host_attach_bucketdispatcher,_alloc_map_wrapper/_attach_bucket_from_setpopulators, Decimal exemption comment.vera/wasm/calls_containers.py—_emit_wrap_handleemits theattach_bucket_to_wrappercall after the shadow-push; Decimal exemption comment.vera/wasm/context.py— let-binding shadow-push for heap-pointer locals;_INLINE_I32_TYPESset.vera/wasm/data.py— match-arm BindingPattern shadow-push for heap-pointer field bindings.vera/codegen/assembly.py—attach_bucket_to_wrapperimport declaration in wrap-table emission.vera/browser/runtime.mjs— JS-sideattach_bucket_to_wrapperimplementation (was a no-op stub);wrapHandleallocates 12-byte body with bucket_ptr field;allocMapWrapperroutes through bucket attach.tests/test_codegen.py— three regression tests inTestMapHostStoreGCReachability695:test_eager_gc_set_of_json_post_walk_uaf(Set<T_heap> values stored in _set_store are invisible to conservative GC scan — sibling to #695 #705),test_eager_gc_json_object_with_array_child_post_walk_uaf(Map<K, T_heap> values stored in _map_store are invisible to conservative GC scan — post-walk reachability of JObject children #695 JSON parser path), and the newtest_eager_gc_map_of_json_user_level_post_walk_uaf(Set<T_heap> values stored in _set_store are invisible to conservative GC scan — sibling to #695 #705 follow-up: user-level Map<String, Json> scope).KNOWN_ISSUES.md— removed the Map<K, T_heap> values stored in _map_store are invisible to conservative GC scan — post-walk reachability of JObject children #695 bug row (now closed), added Move Map/Set host stores from Python mirror to bucket-as-truth #706 follow-up to "Refactoring needed".Test plan
VERA_EAGER_GC=1(Map JSON parser + user-level Map<String, Json> + user-level Set).TestHostHandleReclamation573reclamation tests still pass, including the 10 000-elementarray_fold(map_insert(...))chain (test_map_chain_reclaims_transients).TestMapCollection(24),TestSetCollection(23) tests pass.attach_bucket_to_wrapperand updatedwrapHandle.🤖 Generated with Claude Code
Closes #695
Closes #705
Closes #708
Summary by CodeRabbit
Bug Fixes
Tests
Documentation