Skip to content

v0.0.132 — Opaque-handle GC-rooting hygiene (closes #347 and #490; #346 deferred to #573)#572

Merged
aallan merged 4 commits into
mainfrom
claude/fix-346-347-490-handle-hygiene
May 5, 2026
Merged

v0.0.132 — Opaque-handle GC-rooting hygiene (closes #347 and #490; #346 deferred to #573)#572
aallan merged 4 commits into
mainfrom
claude/fix-346-347-490-handle-hygiene

Conversation

@aallan

@aallan aallan commented May 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Closes #347 and #490 — the codegen-time half of the opaque-handle hygiene work.
  • Defers #346 to follow-up #573 — the active-reclamation half needed a different design than originally bundled here (see the deferral note below).
  • Shared infrastructure: a new _is_host_handle_type classifier in vera/wasm/helpers.py.
  • 3 new test cases across 2 new test classes; test count 3,695 → 3,698.

What this PR ships

Issue Where it lived Fix
#347 — opaque handles polluting shadow stack vera/codegen/functions.py:123 + 3 sites in vera/codegen/closures.py Apply _is_host_handle_type to exclude Map/Set/Decimal from gc_pointer_params
#490 — over-rooting in array_fold/array_map u_is_adt and t_is_adt heuristics in vera/wasm/calls_arrays.py Same classifier added to the heuristics

Why #346 is deferred

The original issue tracker grouped #346 + #347 + #490 under "opaque-handle hygiene" with a comment that "fix is a shared 'is-this-actually-a-Vera-heap-pointer' classifier." In practice the three issues split into two fundamentally different problems:

An earlier draft of this PR (forced-pushed away in this revision) attempted to close all three by adding a host_gc_sweep host import that walked the live Vera heap + shadow stack. The design grew six interlocking pieces: heap walk, shadow-stack scan, transitive closure through store contents, re-entrancy guard, let-binding shadow_push, JSON/HTML emission gates. Each piece was necessary for the previous one to work. CodeRabbit's review caught two critical correctness bugs that would have shipped to browser users.

The complexity was disproportionate to the practical impact: the leak is bounded by Python GC at execute() exit, and Vera doesn't yet have long-running execution contexts where the leak would matter. Single-shot programs (vera run, conformance, examples) are unaffected.

The recommended follow-up design (tracked in #573) is heap-wrap-as-ADT: have handle-creating ops return a Vera-heap MapHandle(i32) so the existing mark-sweep GC handles reclamation via a destructor callback. One mechanism instead of six interlocking pieces; integrates with infrastructure that's already mature.

Tests

Class Tests Coverage
TestOpaqueHandleParamRooting347 1 WAT inspection — local.get $p0; i32.store shadow-push idiom is absent for a Map<K, V> parameter
TestArrayFoldHandleRooting490 2 Structural pin (global.set $gc_sp count parity between Int and Decimal accumulators) + functional regression

Test plan

  • All 3 new tests pass
  • Pre-existing 3,695 tests pass (no regressions)
  • mypy vera/ clean
  • ruff check --select S vera/ clean
  • 82 conformance programs pass
  • 33 examples pass (check + verify)
  • doc-counts + version-sync + limitations-sync + license-check + site-assets all clean
  • CI green

Closes #347
Closes #490

(Defers #346 — see #573 for the active-reclamation follow-up.)

Summary by CodeRabbit

  • Bug Fixes

    • Corrected GC-rooting for opaque handle types (Map, Set, Decimal): parameters are no longer improperly pushed to the GC shadow stack and array-fold/array-map accumulators are no longer over-rooted.
  • Tests

    • Added regression tests verifying opaque-handle parameter rooting and accumulator behaviour remain correct and produce expected results.
  • Documentation

    • Updated changelog, history, roadmap, README, known issues and testing metrics for v0.0.132 and bumped project version.

@coderabbitai

coderabbitai Bot commented May 5, 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

Added a module-private host-handle classifier (_is_host_handle_type) and used it across wasm helpers and codegen to stop treating opaque i32 host-store values (Map, Set, Decimal) as Vera heap pointers for GC shadow-stack rooting. Structural and runtime regression tests were added; package version and release/docs files bumped to v0.0.132.

Changes

Opaque-Handle GC-Rooting Hygiene

Layer / File(s) Summary
Type Classifier
vera/wasm/helpers.py
Added _HOST_HANDLE_TYPES: frozenset[str] and `_is_host_handle_type(type_name: str
WASM helpers import
vera/wasm/calls_arrays.py, vera/codegen/functions.py, vera/codegen/closures.py
Imported _is_host_handle_type into modules that decide GC-rooting classification.
Array fold / sort translation
vera/wasm/calls_arrays.py
Refined ADT/handle classification in _translate_array_fold and _translate_array_sort_by so u_is_adt/t_is_adt exclude host-handle types from heap-pointer rooting decisions.
Codegen parameter/return rooting
vera/codegen/functions.py
Excluded host-handle types from gc_pointer_params collection and from ret_is_pointer computation so i32-backed Map/Set/Decimal params/returns are not shadow-pushed.
Closure capture & return rooting
vera/codegen/closures.py
Extended capture-root emission and closure-return pointer detection to skip opaque host-handle types for i32 captures/returns.
Tests
tests/test_codegen.py
Added TestOpaqueHandleParamRooting347 (WAT inspection to ensure no shadow-stack push for param 0 with Map/Set/Decimal) and TestArrayFoldHandleRooting490 (structural WAT-count and runtime Decimal-fold correctness).
Version & docs
pyproject.toml, vera/__init__.py, CHANGELOG.md, HISTORY.md, KNOWN_ISSUES.md, README.md, ROADMAP.md, TESTING.md
Bumped project/package version to 0.0.132, added changelog entry and updated history/metrics; consolidated known-issues rows per release notes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

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 captures the primary change: v0.0.132 release implementing opaque-handle GC-rooting hygiene, closing issues #347 and #490 with #346 deferred. It is specific, concise, and clearly summarises the main scope of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% 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 claude/fix-346-347-490-handle-hygiene

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

@codecov

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.93%. Comparing base (9a5a4e7) to head (e3a1f23).

Files with missing lines Patch % Lines
vera/wasm/helpers.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #572      +/-   ##
==========================================
- Coverage   90.96%   90.93%   -0.03%     
==========================================
  Files          59       59              
  Lines       22588    22596       +8     
  Branches      259      259              
==========================================
+ Hits        20547    20548       +1     
- Misses       2034     2041       +7     
  Partials        7        7              
Flag Coverage Δ
javascript 56.83% <ø> (ø)
python 94.77% <94.11%> (-0.04%) ⬇️

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.

@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: 11

🤖 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 11: The ROADMAP.md statement and the CI tooling table are inconsistent:
the top summary says "3,700 tests" while the CI tooling table still lists
"3,658". Update the downstream reference in the CI tooling table to match the
canonical test count or replace the hardcoded number with a dynamic/non-numeric
phrase (e.g., "tests (see CI)"); locate the CI tooling table entry referenced in
the file (the line shown around the table) and change the numeric value to the
canonical count produced by running pytest tests/ --collect-only -q or python
scripts/check_doc_counts.py, or swap the literal with a non-numeric reference to
avoid future drift.
- Line 25: The ROADMAP contains a stale backlog entry for issue `#490` in the
"Compiler internals" table that contradicts the paragraph (which states `#490` was
closed in v0.0.132); remove or update the table row referencing "#490" so it no
longer appears as open — either delete that row entirely or change its
status/notes to indicate it was closed in v0.0.132 (and optionally add a short
parenthetical "closed in v0.0.132" to match the paragraph). Ensure you update
only the table entry for "#490" in ROADMAP.md so the paragraph and table are
consistent.

In `@tests/test_codegen.py`:
- Around line 15573-15600: Update test_transient_map_handles_freed_after_gc to
assert that intermediate transient maps are actually freed: after executing the
program via _run (or the executor used), invoke the host GC sweep
(host_gc_sweep) or trigger gc_collect, then query the host-side map store size
(expose or call a helper like _map_store_size or inspect _map_store) and assert
it decreased to only contain the final live Map; keep the functional assertion
(result == 3) but add the explicit post-GC assertion that the internal map store
length/count equals the expected number (e.g., 1) so the test fails pre-fix.
Ensure you reference test_transient_map_handles_freed_after_gc, _run,
host_gc_sweep/_gc_collect and the map store accessor when making the changes.
- Around line 15476-15502: The test test_array_fold_with_decimal_accumulator
only calls _compile_ok and checks result.exports, so it never exercises the
runtime fold; change it to instantiate and invoke the compiled "main" export so
the runtime path runs and would trap on regression: after obtaining result from
_compile_ok (or its returned module/instance), instantiate or get the runtime
export for "main" (refer to how other runtime-executing tests invoke exports in
this test suite), call/invoke that export and assert it completes without
raising/trapping (e.g., no exception or specific success value), ensuring the
Decimal accumulator fold actually runs; update references to
result.exports/result.instance as needed to match the test harness.
- Around line 15424-15458: Replace the brittle sp_advance_count check (counting
"global.set $gc_sp") in the lookup_or_zero WAT test with a direct detection of
the shadow-push idiom that roots the first parameter ($p0): search fn_body for
the sequence that indicates a param shadow-push — "global.get $gc_sp" followed
shortly by "local.get 0" (or "local.get $p0") and then "i32.store" (and
optionally the matching "global.set $gc_sp") — and assert that this exact
sequence does NOT appear for $p0; keep the existing explanatory message but base
the assertion on that concrete pattern instead of counting global.set
occurrences.

In `@vera/browser/runtime.mjs`:
- Around line 2044-2045: The early return in imports.vera.host_gc_sweep (the
line "if (heapPtr <= heapStart) return;") prevents store pruning when the live
heap is empty; update the condition so pruning still runs when heapPtr ===
heapStart (e.g., change to "if (heapPtr < heapStart) return;" or remove the
return) so that candidates can be empty and transient host handles/stores are
still dropped; ensure the rest of the function still uses heapStart, heapPtr and
candidates as before so pruning logic executes.
- Around line 2065-2074: The current pruning loop for mapStore, setStore, and
decimalStore only keeps keys present in candidates, dropping nested handles
reachable only through those stores; modify the sweep to compute a reachable set
(starting from candidates) and expand it by traversing entries in mapStore,
setStore, and decimalStore to include any handles referenced as values/contents
(repeat until fixed point), then delete keys from mapStore, setStore, and
decimalStore that are not in this reachable set; ensure you reference the
existing variables mapStore, setStore, decimalStore and candidates and perform
the traversal before the deletion loop so nested live host handles are
preserved.

In `@vera/codegen/api.py`:
- Around line 1616-1622: The stores _map_store, _set_store, and _decimal_store
allocate handles starting at 1 which collides when the sweep routine prunes by
raw positive i32 membership; change allocation so handles are globally unique by
either (A) using a shared allocator used by all stores (move their counters into
a common generator) or (B) bit-encode the store kind into the handle value
(e.g., top bits or a tag) so the sweep's numeric membership test can distinguish
kinds before pruning; update any creation/lookup code that references
_map_store/_set_store/_decimal_store handles and the _handle_stores registration
so lookups still work with the new global allocator or encoded handles (also
apply the same fix at the other occurrences mentioned).

In `@vera/wasm/calls_arrays.py`:
- Around line 1065-1076: The current logic sets u_is_adt/t_is_adt to false for
host-handle types via _is_host_handle_type(), which clears
u_needs_root/t_needs_root and causes accumulators/elements in array_fold and
array_sort_by to live only in locals (unrooted) across call_indirect; to fix,
ensure host-handle values are shadow-stack rooted when they can survive a
call_indirect: either stop excluding host-handle types in the u_is_adt/t_is_adt
check (remove the "and not _is_host_handle_type(...)" condition) or explicitly
force u_needs_root/t_needs_root true for host-handle types before emitting the
local/accumulator stores in array_fold (array_fold accumulator at ~lines
1106–1108) and array_sort_by (element at ~2169–2170), so that the generated code
pushes/populates the shadow stack around call_indirect and host_gc_sweep can see
them.
🪄 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: f4d8f5c8-833c-474e-84e8-2ed3ac8ae490

📥 Commits

Reviewing files that changed from the base of the PR and between 9a5a4e7 and aa45b89.

⛔ 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 (16)
  • 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/codegen/closures.py
  • vera/codegen/functions.py
  • vera/wasm/calls_arrays.py
  • vera/wasm/helpers.py
💤 Files with no reviewable changes (1)
  • KNOWN_ISSUES.md

Comment thread ROADMAP.md Outdated
Comment thread ROADMAP.md Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread vera/browser/runtime.mjs Outdated
Comment thread vera/codegen/api.py Outdated
Comment thread vera/codegen/api.py Outdated
Comment thread vera/codegen/assembly.py Outdated
Comment thread vera/wasm/calls_arrays.py Outdated
aallan added a commit that referenced this pull request May 5, 2026
Big revision driven by a critical correctness finding that
reframes the whole #347/#490 fix approach.

Inline (5):

  (1) ROADMAP.md line 11 (CI tooling table 3,658 → dynamic phrase)
      Replaced "3,658 tests" with "the test suite" to avoid
      future drift between paragraph and table.

  (2) ROADMAP.md line 25 (stale #490 row)
      Removed the now-stale "Tighten GC-rooting heuristic" row
      from Compiler Internals — closed in v0.0.132.  Also
      updated the campaign-tracker prose to accurately describe
      the host_gc_sweep mechanism (was misleading before).

  (3) test_transient_map_handles_freed_after_gc
      Now asserts result.host_store_sizes["map"] == 3 (i.e.,
      the empty map_new() was freed; 3 let-bound handles
      remain).  Required new ExecuteResult.host_store_sizes
      field exposing post-execute store state.  Test uses
      array_range(100, 10100) values to avoid handle-index
      collision (1..4) with Int payloads in heap.

  (4) test_array_fold_with_decimal_accumulator
      Now actually runs via _run with a decimal_eq comparison
      (instead of just _compile_ok + exports check).  Verifies
      0+1+2+3+4 = 10 in Decimal arithmetic.

  (5) test_map_param_*_shadow_pushed (renamed from
      test_map_param_NOT_shadow_pushed — the assertion FLIPPED
      after the revert below).  Replaced sp_advance_count with
      a direct re.compile pattern for the shadow_push idiom
      `global.get $gc_sp; local.get 0; i32.store`.  The test
      now asserts the push EXISTS — Map params MUST be rooted
      so host_gc_sweep can see them.

Browser (2):

  (6) runtime.mjs early-return
      Removed the "if heapPtr <= heapStart return" guard that
      blocked store pruning when the heap was empty.  Now:
      shadow stack is still scanned, candidates can be empty,
      transient handles still get dropped.

  (7) runtime.mjs nested handles
      Added transitive closure of `candidates` via store
      contents.  A Map containing Set handles as values now
      keeps those Sets alive.  Mirror of the Python-side
      _handle_value_ints + fixed-point iteration.

Code (2):

  (9) calls_arrays.py rooting (THE CRITICAL FINDING)
      My #490 fix excluded host handles from u_is_adt/t_is_adt,
      meaning array_fold/array_map accumulators of host handles
      (e.g. Decimal) lived only in WASM locals across
      call_indirect.  When GC fired in the closure body,
      host_gc_sweep walked the heap, didn't find the handle in
      live objects, and PRUNED the store entry — corrupting the
      next iteration.

      Reverted that exclusion.  Same correctness logic applies
      to the #347 fix in functions.py + closures.py (the same
      pruning would affect Map/Set/Decimal parameters), so I
      reverted those too.  Host handles are back on the GC
      shadow stack at every rooting site.

      The original #347/#490 framings (don't waste shadow stack
      on non-pointers; avoid spurious heap retention from
      handle values in heap range) become weaker concerns now
      that #346's host_gc_sweep mechanism makes the rooting
      necessary.  Tradeoff documented in `_is_host_handle_type`
      docstring.

      Knock-on changes to make host_gc_sweep work correctly:
        - Sweep signature changed from (heap_start, heap_ptr)
          to (heap_start, heap_ptr, gc_stack_base, gc_sp).
        - Sweep now scans BOTH heap AND shadow stack for
          candidate handles.
        - Transitive closure through store contents (so a
          retained Map keeps its referenced Sets alive).
        - New re-entrancy guard `_in_host_alloc` flag — when
          host_gc_sweep is called from a nested $alloc inside
          a host helper, skip the sweep (the helper has handles
          in flight on the Python stack that aren't visible to
          the heap or shadow stack).  Skipping is safe — the
          handles will be freed on the next top-level cycle.
        - context.py's let-binding handler now shadow_push
          host-handle let-bindings (previously only ADT
          constructors emitted the push, leaving host-handle
          lets unrooted).

Skipped (1):

  (8) api.py handle collision (globally-unique handles)
      Skipped — current behaviour is over-retention only (a
      heap value matching a Map handle index also retains a
      Set entry with the same index).  This is a precision
      improvement, not a correctness fix.  Documented in the
      `host_gc_sweep` docstring and `_is_host_handle_type`
      module comment.

Test count: 3,700 → unchanged (5 tests modified, 0 added).
ruff S clean, mypy clean, 82 conformance + 33 examples pass.

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

@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

♻️ Duplicate comments (1)
vera/codegen/api.py (1)

1682-1688: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Raw-integer pruning still needs a shared handle namespace.

_map_store, _set_store, and _decimal_store still allocate from separate counters starting at 1, while host_gc_sweep retains entries by membership in one set[int]. Unrelated integers and same-numbered handles across stores therefore keep each other alive, so pruning remains lossy.

Also applies to: 1964-1967, 2139-2141, 2923-3059

🤖 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/codegen/api.py` around lines 1682 - 1688, The code currently uses
separate per-store integer counters for _map_store, _set_store, and
_decimal_store while host_gc_sweep tracks live handles as plain ints, causing
cross-store collisions that prevent proper pruning; change all stores to
allocate handles from a single shared counter/namespace (e.g., _next_handle or a
shared make_handle() function) so every store inserts keys using that global
handle and update any creation functions that return handles (map/set/decimal
constructors and places referenced by symbols _map_store, _set_store,
_decimal_store and host_gc_sweep) to consume the shared generator; ensure any
existing places that assumed per-store counters are adjusted to use the unified
handle allocation and that host_gc_sweep continues to check membership against
those global integer handles.
🤖 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 37: Update the `#570` entry to accurately reflect current implementation:
note that _translate_array_map() roots arr_ptr, fn_tmp, and dst once before the
loop and does not push each produced element onto the GC shadow stack per
iteration, so reword the failure mode accordingly (remove the incorrect claim
that each iteration pushes a new ptr). Also delete or soften the blanket
assertion that siblings like array_flatten share the pattern (array_flatten has
no callback path); suggest the correct mitigation options (perform per-iteration
shadow-stack unwind in array_map codegen or avoid per-element roots by writing
directly into the already-rooted dst) and keep the issue link to `#570`.

In `@tests/test_codegen.py`:
- Around line 15512-15633: The test only asserts pruning of _map_store but `#346`
also affects _set_store and _decimal_store; update
TestHostGcSweep346.test_transient_map_handles_freed_after_gc to also assert
exec_result.host_store_sizes.get("set") and
exec_result.host_store_sizes.get("decimal") have the expected sizes (likely 0 or
the appropriate retained count for this scenario) alongside the existing map
assertion so regression in Set/Decimal sweeping is caught; locate the test by
the method name test_transient_map_handles_freed_after_gc and add the two
mirrored assertions immediately after the map assertion.
- Around line 15498-15505: The Decimal fold test using array_fold with
accumulator `@Decimal` doesn't force a GC while the accumulator is live; modify
the fold callback (the anonymous fn used in array_fold that calls decimal_add
and decimal_from_int) to create deliberate heap pressure so a collection is
unavoidable (for example, allocate and populate a large temporary array or
perform many small allocations inside the fold body, or invoke the runtime
gc_collect API if available) so the rooting behavior is exercised during the
fold; ensure the added allocations are confined to the callback and don't change
the final numeric logic checked by decimal_eq.

In `@vera/browser/runtime.mjs`:
- Around line 2055-2123: The sweep currently uses a single numeric Set
candidates which mixes independent per-store handle namespaces (mapStore,
setStore, decimalStore) and lets low numeric handles from one store keep entries
in other stores alive; fix by tracking candidates per store: create separate
Sets (e.g., mapCandidates, setCandidates, decimalCandidates), populate the
appropriate per-store set when scanning heap/stack/iterating mapStore/setStore
(use the same logic that recognizes whether a numeric handle refers to
map/set/decimal), update the transitive-closure loops to iterate and add into
the matching per-store candidate Set (use mapStore, setStore, decimalStore as
the sources), and finally prune each store by checking its own candidate Set
rather than the global candidates.
- Around line 2050-2124: The host_gc_sweep implementation can run re-entrantly
(e.g., via wasm.alloc) and must bail out if already sweeping; add a
module-scoped boolean guard (e.g., hostGcSweeping) and wrap the body of
imports.vera.host_gc_sweep with: if (hostGcSweeping) return; hostGcSweeping =
true; try { ... } finally { hostGcSweeping = false; } so the logic that scans
heap/stack and prunes mapStore, setStore, decimalStore only runs when not
already in progress; update references to imports.vera.host_gc_sweep, mapStore,
setStore, decimalStore accordingly.

In `@vera/codegen/assembly.py`:
- Around line 262-277: The import gate for host_gc_sweep only triggers when
self._map_ops_used/_set_ops_used/_decimal_ops_used are true, but
write_json()/write_html() populate self._map_store without setting those flags
so JSON/HTML-backed map entries are never GC-scanned; update the condition in
assembly generation (the block guarded by self._needs_alloc and the tuple of op
flags, and the similar block at the second occurrence around the other import)
to also check for the presence of map-backed data (e.g., if self._map_store is
non-empty) or set a new boolean flag (e.g., self._map_store_used) inside
write_json() and write_html() and include that flag in the conditional, ensuring
the host_gc_sweep import is emitted when map-backed entries exist.

In `@vera/wasm/helpers.py`:
- Around line 265-268: The usage comment for _is_host_handle_type is stale;
update its docstring to state that the classifier IS consulted by GC-rooting
code paths—specifically mention that vera/wasm/context.py::translate_block()
uses _is_host_handle_type to root let-bound host handles—so readers are not
redirected to the old files (vera/codegen/functions.py and
vera/wasm/calls_arrays.py) as the sole rooting references; keep the note that it
documents which types are host-handles but explicitly note its current runtime
usage in translate_block.

---

Duplicate comments:
In `@vera/codegen/api.py`:
- Around line 1682-1688: The code currently uses separate per-store integer
counters for _map_store, _set_store, and _decimal_store while host_gc_sweep
tracks live handles as plain ints, causing cross-store collisions that prevent
proper pruning; change all stores to allocate handles from a single shared
counter/namespace (e.g., _next_handle or a shared make_handle() function) so
every store inserts keys using that global handle and update any creation
functions that return handles (map/set/decimal constructors and places
referenced by symbols _map_store, _set_store, _decimal_store and host_gc_sweep)
to consume the shared generator; ensure any existing places that assumed
per-store counters are adjusted to use the unified handle allocation and that
host_gc_sweep continues to check membership against those global integer
handles.
🪄 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: 9dccfedb-e0da-4ee0-b919-9ebe91efb111

📥 Commits

Reviewing files that changed from the base of the PR and between aa45b89 and 3011d42.

📒 Files selected for processing (11)
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py
  • vera/browser/runtime.mjs
  • vera/codegen/api.py
  • vera/codegen/assembly.py
  • vera/codegen/closures.py
  • vera/codegen/functions.py
  • vera/wasm/calls_arrays.py
  • vera/wasm/context.py
  • vera/wasm/helpers.py

Comment thread ROADMAP.md Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread vera/browser/runtime.mjs Outdated
Comment thread vera/browser/runtime.mjs Outdated
Comment thread vera/codegen/assembly.py Outdated
Comment thread vera/wasm/helpers.py Outdated
aallan added a commit that referenced this pull request May 5, 2026
Two critical bugs and several test/doc improvements.

Critical (2):

  (188) vera/browser/runtime.mjs — re-entrancy guard
        Mirror the Python `_in_host_alloc` flag in JS. Added
        module-scoped `inHostAlloc` counter, incremented in
        the wrapping `alloc()` helper, checked at the top of
        `host_gc_sweep`. Without this, browser-compiled
        programs would prune in-flight handles when the WASM
        allocator triggered GC mid-host-helper.

  (198) vera/codegen/assembly.py — JSON/HTML gates
        `_map_store` is also populated by `write_json()`
        (JObject keyed-by-string members) and `write_html()`
        (HtmlElement attrs), but the original gate only
        checked map/set/decimal_ops_used. A JSON-only or
        HTML-only program would emit no host_gc_sweep at all,
        so its backing map entries would leak for the entire
        execute() lifetime. Extended the gate to include
        json_ops_used and html_ops_used.

Doc accuracy (1):

  (173) ROADMAP.md / KNOWN_ISSUES.md #570 description
        My earlier description was wrong — array_map shadow-
        pushes arr_ptr/fn_tmp/dst once before the loop, NOT
        per-iteration. The actual leak comes from the lifted
        closure's epilogue: it pushes the return-value root
        AFTER the gc_sp restore (so the caller can root the
        in-flight return), but iterative-builder callers
        like array_map's loop store the return into dst[idx]
        immediately and never unwind the post-restore push.
        One slot leaks per iteration → 4096-element array_map
        exhausts the shadow stack. Updated both ROADMAP.md
        and KNOWN_ISSUES.md to describe the actual mechanism;
        also corrected the sibling-builder list (array_flatten
        has no callback path so isn't affected).

Test improvements (2):

  (181) test_array_fold_with_decimal_accumulator — force GC
        Added `let @array<Int> = array_range(100, 10100)`
        inside the fold callback so each iteration allocates
        ~80 KB, forcing multiple $gc_collect cycles while
        the @decimal accumulator is live. Bumped the iteration
        count to 20 (sum 0..19 = 190) so the GC pressure
        accumulates. Pre-fix this would prune the in-flight
        accumulator and the next decimal_add would KeyError.

  (186) test_transient_*_handles_freed_after_gc — Set + Decimal
        Added two new tests mirroring the Map case for the
        Set and Decimal stores. A regression isolated to
        either Set or Decimal sweeping would have passed the
        Map-only test pre-fix.

Doc (1):

  (206) vera/wasm/helpers.py — _is_host_handle_type comment
        The "isn't currently consulted" note was stale —
        context.py's translate_block now uses the classifier
        to decide whether a let-bound i32 needs an explicit
        shadow_push. Updated to point readers at the actual
        usage site.

Skipped (1):

  (195) vera/browser/runtime.mjs — per-store candidate sets
        Same shape as the Python-side handle-collision
        finding (already skipped). Current shared-namespace
        candidates set causes over-retention only (Map[5]
        and Set[5] coexist; both retained when value 5 is in
        candidates). Globally-unique handles or per-store
        candidate sets would prune more aggressively but are
        a precision improvement, not a correctness fix.

Test count: 3,700 → 3,702 (2 new tests; 1 modified to add
GC pressure).
ruff S clean, mypy clean, 82 conformance + 33 examples pass.

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

@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

🤖 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 `@HISTORY.md`:
- Line 269: Update the v0.0.132 HISTORY.md row to reflect that the change
introduces a shared classifier `_is_host_handle_type` (which treats Map, Set,
Decimal as host-handle candidates) combined with a new host import
`host_gc_sweep` that prunes transient handles after each `$gc_collect`, rather
than saying Map/Set/Decimal are simply excluded from GC rooting; mention
classification + sweep behavior and that host handles remain on the shadow stack
for `host_gc_sweep` to discover before pruning.

In `@tests/test_codegen.py`:
- Around line 15411-15459: Extend the test to cover Set and Decimal handle
parameters the same way as Map: for each of the types `@Set<_>` and `@Decimal`
create equivalent `src` snippets (or parameterize the existing one) that declare
a function like `lookup_or_zero` taking that handle as param 0, compile with
`_compile_ok`, extract the function body using the same `fn_start`/`fn_end`
logic, and assert the same `push_pattern` (`global.get $gc_sp` + `local.get 0` +
`i32.store`) is present. Keep the current `push_pattern` and error message but
update or duplicate it to refer to Set and Decimal (or generalize the message)
and ensure the new assertions run within `test_map_param_is_shadow_pushed` or in
new tests named e.g. `test_set_param_is_shadow_pushed` and
`test_decimal_param_is_shadow_pushed`.
- Around line 15541-15590: The existing assertions in
test_host_gc_sweep_sweep_import_emitted_for_map_program and
test_no_host_gc_sweep_for_pure_adt_program only check for the string
"host_gc_sweep" in result.wat which can be misleading if other output formats
(JSON/HTML) are emitting it; update the tests to explicitly compile/request WAT
output (call _compile_ok with an explicit output format or assert the result is
WAT) before checking result.wat and ensure the negative test similarly confirms
no host_gc_sweep in WAT only—modify usages of _compile_ok and the checks around
result.wat in these two tests
(test_host_gc_sweep_sweep_import_emitted_for_map_program and
test_no_host_gc_sweep_for_pure_adt_program) so the gate is exercised against WAT
output only.

In `@vera/codegen/assembly.py`:
- Around line 627-633: Update the Phase 4 docstring to reflect the actual
arguments passed to the imported $host_gc_sweep call when uses_host_handles is
True: it pushes four globals—gc_heap_start, heap_ptr, gc_stack_base, and
gc_sp—so replace the current mention of "(gc_heap_start, heap_ptr)" with the
correct four-tuple or list of parameters and mention $host_gc_sweep and
uses_host_handles to make the behavior explicit.
🪄 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: 2dc85870-0112-4988-bba4-9d03fac1f770

📥 Commits

Reviewing files that changed from the base of the PR and between 3011d42 and 6fb0711.

📒 Files selected for processing (9)
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py
  • vera/browser/runtime.mjs
  • vera/codegen/assembly.py
  • vera/wasm/helpers.py

Comment thread HISTORY.md Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread tests/test_codegen.py Outdated
Comment thread vera/codegen/assembly.py Outdated
@aallan aallan changed the title v0.0.132 — Opaque-handle hygiene batch (closes #346, #347, #490) v0.0.132 — Opaque-handle GC-rooting hygiene (closes #347 and #490; #346 deferred to #573) May 5, 2026
Closes #347 and #490 — the codegen-time half of the opaque-handle
hygiene work.  #346 (active reclamation half — host-store leak) is
deferred and tracked separately at #573.

This is a much smaller PR than an earlier draft attempted: that
draft tried to close all three issues with a host_gc_sweep
mechanism (heap walk + shadow stack scan + transitive closure +
re-entrancy guard + let-binding shadow_push + JSON/HTML emission
gates) but the resulting six-piece design had too much complexity
for the practical impact.  This revision drops that machinery and
ships only the cleanly-fixable codegen-time work.

Shared infrastructure
=====================

New _is_host_handle_type classifier in vera/wasm/helpers.py
distinguishes types that lower to i32 indices into Python-side
host stores (Map / Set / Decimal) from real Vera-heap pointers.

#347 — opaque handles no longer pushed to GC shadow stack
=========================================================

Pre-fix vera/codegen/functions.py and vera/codegen/closures.py
excluded only Bool / Byte from gc_pointer_params, so a Map / Set /
Decimal parameter (i32 handle index) was treated as a heap pointer
and pushed onto the shadow stack at every function entry.  Wasted
shadow-stack space; a handle value that landed in the heap-pointer
range with valid alignment would have caused the conservative
mark phase to spuriously mark an unrelated heap object as live
(memory retention, not corruption).

Post-fix the new classifier excludes opaque handles at four
rooting decision sites: top-level params + return type in
functions.py, closure params + captures + return type in
closures.py.

#490 — array_fold/array_map no longer over-root opaque accumulators
====================================================================

Pre-fix the u_is_adt/t_is_adt heuristics in calls_arrays.py
classified Map/Set/Decimal accumulators as ADT pointers.  Same
classifier excludes them post-fix.

Tests
=====

Two new test classes (3 tests):

  TestOpaqueHandleParamRooting347 — pins #347 fix via WAT inspection
    test_map_param_not_shadow_pushed: the canonical local.get $p0;
      i32.store shadow-push idiom is absent for a Map parameter.

  TestArrayFoldHandleRooting490 — pins #490 fix
    test_decimal_accumulator_not_rooted: global.set $gc_sp count
      parity between Int and Decimal fold accumulators.
    test_array_fold_with_decimal_runs_correctly: functional pin.

Test count: 3,695 -> 3,698.
mypy clean, ruff S clean, 82 conformance programs pass, 33
examples pass, browser parity passes.

#346 follow-up
==============

The active-reclamation half (the actual host-store leak) is
deferred to #573.  Recommended path: heap-wrap-as-ADT — have
handle-creating ops return a Vera-heap MapHandle(i32) so the
existing mark-sweep GC handles reclamation via a destructor
callback.  Single mechanism instead of six interlocking pieces;
integrates with mature infrastructure.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan aallan force-pushed the claude/fix-346-347-490-handle-hygiene branch from 6fb0711 to a270514 Compare May 5, 2026 19:34
@aallan

aallan commented May 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Reset notice: This PR has been force-pushed to a substantially smaller scope after maintainer feedback that the original design was "overly complex (but correct)".

What changed

The earlier draft (commits aa45b896fb0711) attempted to close all three of #346 + #347 + #490 by adding a host_gc_sweep host import that walked the live heap + shadow stack to identify reachable handle indices and prune Python-side stores. Your review caught three findings I'm grateful for:

  1. The array_fold / array_map heuristic exclusion broke host_gc_sweep reachability (round 1 finding 9) — required reverts + the post-restore root push fix
  2. The JS runtime missed the inHostAlloc re-entrancy guard the Python side had (round 2 finding 188) — would have shipped a critical bug to browser users
  3. JSON / HTML programs bypassed the host_gc_sweep emission gate (round 2 finding 198) — leak path missed by my original gate condition

All three were correctly identified and fixed. But the cumulative complexity (six interlocking pieces: heap walk, shadow-stack scan, transitive closure, re-entrancy guard, let-binding shadow_push, JSON/HTML gates) was disproportionate to the actual practical impact — #346's leak is bounded by Python GC at execute() exit, and Vera doesn't yet have long-running execution contexts where it would matter.

What this PR ships now

Only the codegen-time half: #347 (opaque handles excluded from GC shadow-stack rooting) + #490 (same exclusion in array_fold/array_map heuristics). Single shared _is_host_handle_type classifier. ~200 line diff vs. the earlier ~600.

No host_gc_sweep, no shadow-stack scanning, no transitive closure, no re-entrancy guard, no let-binding shadow_push, no JSON/HTML gates. All of those have been reverted.

What #346 becomes

#346 (the active-reclamation half) is deferred and tracked at #573. The recommended design there is heap-wrap-as-ADT — return a Vera-heap MapHandle(i32) from handle-creating ops so the existing mark-sweep GC handles reclamation via a destructor callback. Single mechanism instead of six interlocking pieces; integrates with mature infrastructure.

Thank you for the detailed review on the earlier draft — your findings drove the decision to simplify. This is a much better PR for it.

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Tip

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

@aallan — understood, and thank you for the detailed reset summary. Triggering a fresh review against the force-pushed branch now.

A few observations on the revised scope before the review runs:

The simplification is the right call. The six-piece host_gc_sweep design was correct but each interlock was load-bearing: removing any one (e.g. the inHostAlloc guard, the JSON/HTML gate) silently introduced a latent bug. That's a bad invariant-to-complexity ratio for something whose practical impact is bounded by Python GC at execute() exit.

The _is_host_handle_type predicate is now the single trust boundary. With the narrowed scope the correctness argument reduces to: every i32 that carries a host-handle index must return True from _is_host_handle_type, and nothing else must. Prefix-matching on "<" for parametric forms (e.g. Map<K,V>) is the part I'll look at most carefully — a missed head or an over-broad match there is the main residual risk in this PR.

The heap-wrap-as-ADT path for #346 is the right long-term design. Letting the existing mark-sweep destructor callback drive reclamation means the invariant is maintained by one mechanism with mature test coverage, not a parallel shadow system.

I'll flag anything on the classifier logic, its call sites in functions.py / closures.py / calls_arrays.py, and the test coverage in test_codegen.py.

[full_review]

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
vera/wasm/calls_arrays.py (1)

2043-2050: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Bring the sort-by GC-rooting comment back into sync with the code.

The block above still says Map / Set / Regex / Decimal flow through t_needs_root, but the new predicate now explicitly excludes host handles, and Regex is not a value type here. Leaving that comment stale documents the opposite invariant from the implementation on a very GC-sensitive path.

Also applies to: 2150-2157

🤖 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/calls_arrays.py` around lines 2043 - 2050, The comment describing
GC-rooting for sort-by is out of sync: it claims Map/Set/Regex/Decimal are
handled via the t_needs_root path and that Regex is a value type, but the
current predicate explicitly excludes host handles and Regex is not a value
type; update the prose around the t_needs_root, tmp_a, tmp and comparator
call_indirect discussion (and the similar block at 2150-2157) to accurately
reflect that host handles are excluded from t_needs_root, list the exact types
that do require shadow-stack rooting (e.g., pair-typed T like String/Array<X>
via tmp_a, and ADTs/user handles via tmp), and remove/replace the incorrect
references to Regex and host-handle flow so the comment matches the
implementation.
♻️ Duplicate comments (2)
tests/test_codegen.py (2)

15481-15568: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This Decimal fold regression still misses the real failure mode.

The structural check encodes the wrong invariant for host handles, and the runtime test never forces a collection while the Decimal accumulator is live. That means this block can stay green even when array_fold lets host_gc_sweep drop the in-flight accumulator.

Suggested fix
-    def test_decimal_accumulator_not_rooted(self) -> None:
-        """`array_fold` over `Decimal` accumulator should NOT emit
-        the per-iteration accumulator root that ADT accumulators
-        get.
-
-        Structural pin via `global.set $gc_sp` count: the same
-        fold over `Int` (no rooting needed) and `Decimal` (post-
-        fix: also no rooting per `#490`) should produce the same
-        count of `global.set $gc_sp` idioms in `$main`.  An ADT
-        accumulator (which IS a Vera-heap pointer) would emit one
-        more.  This test pins that Decimal counts match Int.
-        """
-        # Reference: fold over Int (no rooting needed) — baseline
-        int_src = """
-public fn main(`@Unit` -> `@Int`)
-  requires(true) ensures(true) effects(pure)
-{
-  array_fold(
-    array_range(0, 5),
-    0,
-    fn(`@Int`, `@Int` -> `@Int`) effects(pure) { `@Int.0` + `@Int.1` }
-  )
-}
-"""
-        # Test: fold over Decimal (post-fix: also no rooting per `#490`)
-        decimal_src = """
-public fn main(`@Unit` -> `@Decimal`)
-  requires(true) ensures(true) effects(pure)
-{
-  array_fold(
-    array_range(0, 5),
-    decimal_from_int(0),
-    fn(`@Decimal`, `@Int` -> `@Decimal`) effects(pure) {
-      decimal_add(`@Decimal.0`, decimal_from_int(`@Int.0`))
-    }
-  )
-}
-"""
-        int_wat = _compile_ok(int_src).wat
-        decimal_wat = _compile_ok(decimal_src).wat
-
-        def count_main_pushes(wat: str) -> int:
-            fn_start = wat.index("(func $main")
-            if "\n  (func " in wat[fn_start + 1:]:
-                fn_end = wat.index("\n  (func ", fn_start + 1)
-            else:
-                fn_end = len(wat)
-            return wat[fn_start:fn_end].count("global.set $gc_sp")
-
-        int_count = count_main_pushes(int_wat)
-        decimal_count = count_main_pushes(decimal_wat)
-        assert decimal_count == int_count, (
-            f"`array_fold` over a Decimal accumulator emits "
-            f"{decimal_count} `global.set $gc_sp` idioms in $main "
-            f"vs. {int_count} for an Int accumulator.  Post-#490 "
-            f"these should be equal — Decimal is an opaque host "
-            f"handle, not a Vera-heap pointer, and shouldn't be "
-            f"rooted around the call_indirect.  An extra count "
-            f"means `_is_host_handle_type` isn't firing in the "
-            f"`u_is_adt` heuristic."
-        )
-
-    def test_array_fold_with_decimal_runs_correctly(self) -> None:
-        """Functional pin: the fold over Decimal still produces the
-        right result.  Pre- and post-fix this works (the
-        conservative GC's heap-range check rejects small handle
-        values either way), so this test passes in both states —
-        but it pins that the structural optimisation didn't break
-        anything.
-
-        Sum 0+1+2+3+4 = 10; comparing to `decimal_from_int(10)`
-        via `decimal_eq` returns 1.
-        """
+    def test_array_fold_with_decimal_accumulator_survives_gc(self) -> None:
+        """Force GC while the Decimal accumulator is live."""
         src = """
 public fn main(`@Unit` -> `@Int`)
   requires(true) ensures(true) effects(pure)
 {
   let `@Decimal` = array_fold(
-    array_range(0, 5),
+    array_range(0, 20),
     decimal_from_int(0),
     fn(`@Decimal`, `@Int` -> `@Decimal`) effects(pure) {
+      let `@Array`<Int> = array_range(100, 10100);
       decimal_add(`@Decimal.0`, decimal_from_int(`@Int.0`))
     }
   );
-  if decimal_eq(`@Decimal.0`, decimal_from_int(10)) then { 1 } else { 0 }
+  if decimal_eq(`@Decimal.0`, decimal_from_int(190)) then { 1 } else { 0 }
 }
 """
         assert _run(src) == 1

Based on learnings: Decimal-result tests should use _run with decimal_eq(...), and handle-rooting regressions need deliberate GC pressure such as array_range(100, 10100) inside the fold body.


15409-15459: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Flip this regression: Map params must stay shadow-rooted.

This assertion is backwards for the current host-handle model. Map/Set/Decimal params need the shadow-push so host_gc_sweep can discover a live host-store handle during/after $gc_collect; asserting that the push is absent bakes the reverted bug into the suite.

Suggested fix
-class TestOpaqueHandleParamRooting347:
-    """`#347`: opaque host handles (Map / Set / Decimal) MUST NOT be
-    pushed to the GC shadow stack as roots when they appear as
-    function parameters.
+class TestOpaqueHandleParamRooting347:
+    """`#347`: opaque host handles (Map / Set / Decimal) must stay
+    discoverable by `host_gc_sweep` when passed as function
+    parameters.
@@
-    def test_map_param_not_shadow_pushed(self) -> None:
-        """A `Map<Nat, Nat>` parameter must not appear in any
-        gc_shadow_push sequence in the function's prologue.
+    def test_map_param_is_shadow_pushed(self) -> None:
+        """A `Map<Nat, Nat>` parameter must be shadow-pushed in the
+        function prologue.
@@
-        assert not push_pattern.search(fn_body), (
-            "Found a shadow_push of param 0 (the Map handle) in "
-            "lookup_or_zero — the opaque-handle exclusion (`#347`) "
-            "isn't being applied.  Map / Set / Decimal handles are "
-            "i32 indices into Python-side stores, not Vera-heap "
-            "pointers; rooting them wastes shadow-stack space and "
-            "could cause spurious heap-object retention via the "
-            "conservative GC's heap-range check."
+        assert push_pattern.search(fn_body), (
+            "Map handle parameter $p0 was not shadow-pushed.  Live "
+            "host handles must stay on the shadow stack so "
+            "`host_gc_sweep` can find them during/after "
+            "`$gc_collect`."
         )

Based on learnings: Map/Set/Decimal are opaque host-handle types and MUST be shadow-stack rooted when passed as function parameters so host_gc_sweep can discover live handles.

🤖 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 `@tests/test_codegen.py` around lines 15409 - 15459, The test
test_map_param_not_shadow_pushed is asserting the wrong behavior:
Map/Set/Decimal params must be shadow-pushed so change the assertion to require
the canonical shadow_push sequence is present; in the test body (function
lookup_or_zero) replace the negative check "assert not
push_pattern.search(fn_body)" with a positive check "assert
push_pattern.search(fn_body)" and update the failure message to state that the
Map param (param 0) must be shadow-rooted so host_gc_sweep can find live
host-store handles; keep references to push_pattern, lookup_or_zero and the
host-handle exclusion/_is_host_handle_type rationale.
🤖 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 `@HISTORY.md`:
- Line 269: Update the v0.0.132 release row for "Opaque-handle GC-rooting
hygiene" to explicitly reference the deferred follow-up issue `#573`: replace or
augment the phrase "`#346` deferred — active reclamation needs a
heap-wrap-as-ADT design, tracked as follow-up" with wording that includes a link
to issue `#573` (e.g., "tracked as follow-up (see `#573`)") so the release note
for v0.0.132 clearly names the concrete deferred issue.

In `@vera/codegen/functions.py`:
- Around line 129-133: The i32 branch guarding GC-pointer rooting misses Json
because _is_host_handle_type() doesn't currently classify Json; update the
classifier so Json is considered a host-handle (i.e., add "Json" to the set of
types returned true by _is_host_handle_type or otherwise extend its logic) so
the condition in the if-block that checks wt == "i32" and type_name not in
("Bool","Byte", None) will correctly exclude Json; apply the same fix for the
identical pattern used around the wt/type_name check at the other occurrence
(the one referenced at 285-288) so Json is consistently treated as an opaque
host handle across both branches.

---

Outside diff comments:
In `@vera/wasm/calls_arrays.py`:
- Around line 2043-2050: The comment describing GC-rooting for sort-by is out of
sync: it claims Map/Set/Regex/Decimal are handled via the t_needs_root path and
that Regex is a value type, but the current predicate explicitly excludes host
handles and Regex is not a value type; update the prose around the t_needs_root,
tmp_a, tmp and comparator call_indirect discussion (and the similar block at
2150-2157) to accurately reflect that host handles are excluded from
t_needs_root, list the exact types that do require shadow-stack rooting (e.g.,
pair-typed T like String/Array<X> via tmp_a, and ADTs/user handles via tmp), and
remove/replace the incorrect references to Regex and host-handle flow so the
comment matches the implementation.

---

Duplicate comments:
In `@tests/test_codegen.py`:
- Around line 15409-15459: The test test_map_param_not_shadow_pushed is
asserting the wrong behavior: Map/Set/Decimal params must be shadow-pushed so
change the assertion to require the canonical shadow_push sequence is present;
in the test body (function lookup_or_zero) replace the negative check "assert
not push_pattern.search(fn_body)" with a positive check "assert
push_pattern.search(fn_body)" and update the failure message to state that the
Map param (param 0) must be shadow-rooted so host_gc_sweep can find live
host-store handles; keep references to push_pattern, lookup_or_zero and the
host-handle exclusion/_is_host_handle_type rationale.
🪄 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: 6fc9b9cd-9f66-4be2-adc5-495fd11921c0

📥 Commits

Reviewing files that changed from the base of the PR and between 6fb0711 and a270514.

⛔ 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 (13)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • pyproject.toml
  • tests/test_codegen.py
  • vera/__init__.py
  • vera/codegen/closures.py
  • vera/codegen/functions.py
  • vera/wasm/calls_arrays.py
  • vera/wasm/helpers.py

Comment thread HISTORY.md Outdated
Comment thread vera/codegen/functions.py
…t + Decimal

Two cleanups responding to maintainer + CodeRabbit feedback after the
v0.0.132 reset:

1. Close #346 as superseded by #573
   The previous commit kept #346 in the priority table with a "→ #573"
   pointer, which was muddled — two issues tracking the same bug.  This
   commit closes #346 as superseded (via gh issue close), updates the
   ROADMAP priority table + campaign tracker prose to reference #573
   only, and updates the KNOWN_ISSUES tracking-ID column to #573 (with
   #346 mentioned only as a parenthetical "originally tracked at #346").
   HISTORY + CHANGELOG language updated from "deferred" to
   "superseded by #573" to match the GitHub state.

2. Extend TestOpaqueHandleParamRooting347 to Set + Decimal
   CodeRabbit (PR #572 round 3) noted that the test only covered Map
   parameters, leaving Set + Decimal uncovered.  Refactored the
   pattern check into a static helper `_assert_param0_not_shadow_pushed`
   and added two new tests:

     test_set_param_not_shadow_pushed
       — Set<Nat> param via contains_or_false fn
     test_decimal_param_not_shadow_pushed
       — Decimal param via is_positive_or_false fn

   A regression isolated to either Set or Decimal codegen would now be
   caught (previously only the Map path was pinned).

Test count: 3,698 -> 3,700.  mypy clean, ruff S clean, full pytest +
82 conformance + 33 examples pass.

Other CodeRabbit findings from round 3 (HISTORY framing, host_gc_sweep
import test, Phase 4 docstring) reference the previous-design code
that was reverted in the simplification — skipped as moot.

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

@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: 2

♻️ Duplicate comments (2)
ROADMAP.md (2)

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

Sync the remaining stale test-count reference.

ROADMAP.md now says 3,700 tests here, but the CI tooling table still says “3,658 tests” (Line 217). Please align that downstream reference (or keep it non-numeric, e.g. “the test suite”) to avoid drift.

Based on learnings, the canonical test count in this repo should come from pytest tests/ --collect-only -q or python scripts/check_doc_counts.py.

🤖 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 `@ROADMAP.md` at line 11, Update the stale test-count in the CI tooling table
to match the canonical count used elsewhere (change “3,658 tests” to “3,700
tests” or replace with a non-numeric phrase like “the test suite”) so ROADMAP.md
is consistent; locate the CI tooling table text that currently reads “3,658
tests” and edit it to either the numeric 3,700 or a non-numeric reference, and
add/update a short comment or note (if present) to indicate the canonical source
for the count is produced by running `pytest tests/ --collect-only -q` or
`python scripts/check_doc_counts.py`.

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

Remove the stale open-issue reference for #490 in this same roadmap.

This line says #490 was closed in v0.0.132, but the “Compiler internals” section still lists #490 as open (Line 244). Please delete or update that backlog row so the roadmap is internally consistent.

As per coding guidelines, “Review Markdown files for factual accuracy against the codebase, broken links, and outdated information.”

🤖 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 `@ROADMAP.md` at line 25, The ROADMAP.md entry incorrectly lists issue `#490` as
still open under the "Compiler internals" backlog; open ROADMAP.md, find the
"Compiler internals" section and remove or update the backlog row that
references `#490` (it was closed in the v0.0.132 note), ensuring the backlog no
longer lists `#490` as open and that any related row/entry is adjusted for
consistency with the v0.0.132 closure mentioned in the v0.0.132 changelog
paragraph.
🤖 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 `@KNOWN_ISSUES.md`:
- Line 9: Update the KNOWN_ISSUES.md sentence that reads "v0.0.132 attempted a
`host_gc_sweep` design but the resulting six-piece interlocking machinery..." to
unambiguously state the timeline: say that an earlier draft of v0.0.132
attempted the `host_gc_sweep` design but it was reverted during development and
did not ship in v0.0.132, and then mention that the released v0.0.132 only
included the codegen fixes for `#347` and `#490`; keep the rest of the description
about the six-piece machinery and the recommended heap-wrap-as-ADT path
unchanged.

In `@tests/test_codegen.py`:
- Around line 15421-15434: The shadow-push detector currently only matches
"local.get 0" which misses forms like "local.get $p0" and also lacks a
non-vacuous check that GC prologue exists in the target function; update the
logic around result.wat/fn_body and push_pattern so the regex accepts both
numeric and named local.get forms (e.g., allow "local.get 0" or "local.get
$name"/any "$..." identifier) and after slicing fn_body confirm the prologue was
actually found (i.e., assert that the push_pattern search/match is non-empty)
before treating the push as detected; adjust the symbol references in tests to
use push_pattern, fn_body, fn_marker, and _compile_ok accordingly.

---

Duplicate comments:
In `@ROADMAP.md`:
- Line 11: Update the stale test-count in the CI tooling table to match the
canonical count used elsewhere (change “3,658 tests” to “3,700 tests” or replace
with a non-numeric phrase like “the test suite”) so ROADMAP.md is consistent;
locate the CI tooling table text that currently reads “3,658 tests” and edit it
to either the numeric 3,700 or a non-numeric reference, and add/update a short
comment or note (if present) to indicate the canonical source for the count is
produced by running `pytest tests/ --collect-only -q` or `python
scripts/check_doc_counts.py`.
- Line 25: The ROADMAP.md entry incorrectly lists issue `#490` as still open under
the "Compiler internals" backlog; open ROADMAP.md, find the "Compiler internals"
section and remove or update the backlog row that references `#490` (it was
closed in the v0.0.132 note), ensuring the backlog no longer lists `#490` as
open and that any related row/entry is adjusted for consistency with the
v0.0.132 closure mentioned in the v0.0.132 changelog paragraph.
🪄 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: faf33f0c-94a6-412e-b451-d2141f0e003c

📥 Commits

Reviewing files that changed from the base of the PR and between a270514 and 506f10b.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py

Comment thread KNOWN_ISSUES.md Outdated
Comment thread tests/test_codegen.py
…fixed

Inline (2):

  KNOWN_ISSUES line 9 — timeline language clarified
    Pre-fix: "v0.0.132 attempted a host_gc_sweep design but the
      resulting six-piece interlocking machinery was too complex"
      — ambiguous about whether it shipped.
    Post-fix: "An earlier draft of v0.0.132 attempted a host_gc_sweep
      design but it was reverted during development — v0.0.132 as
      released only ships the codegen-time #347 + #490 fixes, not
      active reclamation."  Then explains the reverted design's
      complexity.  Unambiguous about what shipped vs. what was tried.

  test_codegen.py — TestOpaqueHandleParamRooting347 helper hardened
    Two improvements to _assert_param0_not_shadow_pushed:
    1. Non-vacuity check: assert the function's GC prologue WAS
       emitted (`global.get $gc_sp; local.set ...` pattern) before
       checking absence of param-0 push.  Without this, a function
       with no allocator activity passes trivially regardless of
       whether the exclusion fires.
    2. Push pattern accepts both numeric (`local.get 0`) and named
       (`local.get $p0`, `local.get $name`) forms.  Codegen currently
       emits numeric, but a future rename to named params shouldn't
       silently break this regression test.
    Tests still pass (3 host-handle types × hardened check = 3/3).

Duplicates (2 — regressions from the force-reset to main that lost
earlier fixes):

  ROADMAP CI tooling table — "3,658 tests" → "the test suite"
    The earlier complex draft (commits aa45b89..6fb0711) had this
    fixed.  When the branch was force-reset to main and reimplemented
    cleanly, the earlier fix was lost.  Re-applied: replaced the
    hardcoded count with a non-numeric phrase to avoid future drift.

  ROADMAP Compiler internals section — stale #490 row removed
    Same regression pattern.  #490 closed in v0.0.132; the backlog
    row "Tighten GC-rooting heuristic in iterative combinators"
    contradicts the campaign-tracker prose that says #490 closed.
    Removed the row (it was the only entry in "Compiler internals")
    and the section header along with it.

Test count: 3,700 → 3,700 (no new tests; helper restructured).
mypy clean, ruff S clean, full pytest + 82 conformance + 33 examples
pass.

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

aallan commented May 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai On the 2 duplicate findings from review 4231171595:\n\nROADMAP CI tooling table — 3,658 teststhe test suite ✅ Fixed in 63d844b. These were both regressions from the earlier force-reset to main: the original aa45b89→6fb0711 chain had these fixed, but resetting to main and reimplementing cleanly lost both edits. Re-applied:\n- Line 217 (Add mutation testing with mutmut row): replaced 3,658 tests with the test suite (non-numeric, won't drift).\n- The "Compiler internals" section row Tighten GC-rooting heuristic in iterative combinators | #490 removed (and the section header along with it, since it was the only entry). Matches the campaign-tracker prose that says #490 closed in v0.0.132.\n\nThanks for re-flagging both — they would have shipped stale otherwise. The force-reset was the right call (cleaner diff overall) but I should have audited the previously-fixed comments before resetting; noted for next time.

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Tip

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

@aallan — both fixes confirmed and well-applied:

  • The the test suite replacement is the right long-term choice — a numeric literal in a CI-tooling capability row will drift on every release whereas a non-numeric description is stable.
  • Removing the #490 row along with the now-empty "Compiler internals" section header is clean; an orphaned section header would have been worse than the stale row.

The "audit previously-fixed comments after a force-reset" note is a good process observation — the diff-scope change makes it easy to lose track of which editorial fixes were in the discarded chain.

@aallan

aallan commented May 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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.

Caution

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

⚠️ Outside diff range comments (1)
tests/test_codegen.py (1)

15560-15666: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a mirrored array_map regression test for the handle-classifier path.

This block validates array_fold only, but #490 also touches the array_map rooting heuristic (t_is_adt). Please add one structural/functional array_map case with a host-handle type so a regression isolated to array_map cannot pass unnoticed. As per coding guidelines: "DO flag: Missing edge cases for new compiler features."

🤖 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 `@tests/test_codegen.py` around lines 15560 - 15666, Add a mirrored test for
array_map similar to test_decimal_accumulator_not_rooted: the issue is missing
coverage for the array_map path (t_is_adt) so regressions there can slip by.
Create a new test (e.g. test_decimal_mapper_not_rooted) that compiles two
sources—one mapping to an Int accumulator and one mapping to a Decimal
host-handle—and uses the same global.set $gc_sp counting helper
(count_main_pushes) to assert the Decimal case emits the same number of
global.set $gc_sp idioms as the Int case; also add a functional run test
mirroring test_array_fold_with_decimal_runs_correctly to ensure array_map
produces the correct Decimal results. Target symbols to modify/add:
test_decimal_accumulator_not_rooted (as model), count_main_pushes, array_map,
and ensure the classifier exercised is t_is_adt.
♻️ Duplicate comments (1)
tests/test_codegen.py (1)

15578-15638: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Replace the global.set $gc_sp count proxy with a direct accumulator shadow-push pattern check.

Counting global.set $gc_sp in $main is fragile and can flip on unrelated GC prologue/epilogue changes. Please anchor this to the concrete push idiom for the fold accumulator in the generated fold helper, as you already do for param-0 in the #347 tests.

🤖 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 `@tests/test_codegen.py` around lines 15578 - 15638, In
test_decimal_accumulator_not_rooted replace the brittle global.set $gc_sp
counting logic (the count_main_pushes function and its uses on
int_wat/decimal_wat) with a direct check for the accumulator shadow-push idiom
inside the generated fold helper: locate the test function
test_decimal_accumulator_not_rooted, call _compile_ok(...) to get wat, find the
fold helper function emitted for array_fold (same helper referenced by the `#347`
param-0 tests), and assert the presence/absence (or equal counts) of the
concrete shadow-push instruction sequence used for the fold accumulator (the
same pattern you use in the `#347` tests) rather than counting "global.set $gc_sp"
in $main; update count_main_pushes to scan the fold helper body for that exact
shadow-push pattern and compare int_wat vs decimal_wat.
🤖 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 `@tests/test_codegen.py`:
- Around line 15560-15666: Add a mirrored test for array_map similar to
test_decimal_accumulator_not_rooted: the issue is missing coverage for the
array_map path (t_is_adt) so regressions there can slip by. Create a new test
(e.g. test_decimal_mapper_not_rooted) that compiles two sources—one mapping to
an Int accumulator and one mapping to a Decimal host-handle—and uses the same
global.set $gc_sp counting helper (count_main_pushes) to assert the Decimal case
emits the same number of global.set $gc_sp idioms as the Int case; also add a
functional run test mirroring test_array_fold_with_decimal_runs_correctly to
ensure array_map produces the correct Decimal results. Target symbols to
modify/add: test_decimal_accumulator_not_rooted (as model), count_main_pushes,
array_map, and ensure the classifier exercised is t_is_adt.

---

Duplicate comments:
In `@tests/test_codegen.py`:
- Around line 15578-15638: In test_decimal_accumulator_not_rooted replace the
brittle global.set $gc_sp counting logic (the count_main_pushes function and its
uses on int_wat/decimal_wat) with a direct check for the accumulator shadow-push
idiom inside the generated fold helper: locate the test function
test_decimal_accumulator_not_rooted, call _compile_ok(...) to get wat, find the
fold helper function emitted for array_fold (same helper referenced by the `#347`
param-0 tests), and assert the presence/absence (or equal counts) of the
concrete shadow-push instruction sequence used for the fold accumulator (the
same pattern you use in the `#347` tests) rather than counting "global.set $gc_sp"
in $main; update count_main_pushes to scan the fold helper body for that exact
shadow-push pattern and compare int_wat vs decimal_wat.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 59663bda-8667-487a-a5f1-c52dad2f77c4

📥 Commits

Reviewing files that changed from the base of the PR and between a270514 and 63d844b.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py

…ardening

Two changes responding to outside-diff and duplicate findings on
TestArrayFoldHandleRooting490:

1. Add array_map mirror — test_decimal_mapper_not_rooted

   The existing test_decimal_accumulator_not_rooted only exercised the
   array_fold u_is_adt heuristic.  array_map has its own t_is_adt
   heuristic (calls_arrays.py:2151) that gates element rooting; a
   regression isolated there would have slipped past.  New test
   compiles `array_length(array_map(0..5, fn(i) { decimal_from_int(i) }))`
   alongside an Int reference and asserts equal global.set $gc_sp
   counts in $main.

   Plus a new functional regression test_array_map_with_decimal_runs_correctly
   that builds a Decimal array via array_map, folds it, and asserts
   the sum (`array_map([0..5), fn(i) { decimal_from_int(i*2) })` →
   `[0, 2, 4, 6, 8]` → folded sum 20).

2. Refactor count_main_pushes into a shared helper + add non-vacuity
   assertion

   Pulled count_main_pushes out of the inline closure into a class-
   level @staticmethod _count_main_pushes, and added a new helper
   _assert_handle_not_extra_rooted that:
   - asserts int_count > 0 (non-vacuity — without this, a degenerate
     case where neither version emits any pushes would pass with
     0 == 0, exactly the same vacuity concern CodeRabbit raised on
     the param-rooting test in an earlier round)
   - asserts decimal_count == int_count (post-fix equality)

   Both fold and map tests now go through the same helper, so a future
   structural shift in count_main_pushes can't silently break either.

Test count: 3,700 → 3,702 (2 new tests; helper extracted).
mypy clean, ruff S clean, full pytest + 82 conformance + 33 examples
pass.

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

aallan commented May 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Both findings from review 4231366577 addressed in e3a1f23:

Outside-diff: array_map mirror test (t_is_adt path)

Added test_decimal_mapper_not_rooted to TestArrayFoldHandleRooting490. Compiles array_length(array_map(0..5, fn(i) { decimal_from_int(i) })) alongside an Int reference and asserts equal global.set $gc_sp counts in $main — exercising the t_is_adt exclusion in _translate_array_map (calls_arrays.py:2151), which the fold test was missing.

Also added test_array_map_with_decimal_runs_correctly as a functional pin: array_map([0..5), fn(i) { decimal_from_int(i*2) })[0, 2, 4, 6, 8] → folded sum = 20.

Duplicate: brittle count check

Refactored to address the underlying concern. count_main_pushes is now a class-level @staticmethod _count_main_pushes, and a new helper _assert_handle_not_extra_rooted is shared by both fold and map tests. Critically, the helper now asserts int_count > 0 before the equality comparison — same non-vacuity check as the param test (round earlier), so a degenerate case where the WAT structure shifts and neither version emits any pushes would fail loudly with a clear "helper isn't measuring real shadow-stack activity" message instead of passing trivially with 0 == 0.

Note on "fold helper function" in the original suggestion: there isn't actually a separate fold helper — array_fold is inlined into $main, with the closure body in a separate $anon_N. The shadow-push activity I'm measuring IS in $main (the source-array push, etc.), so slicing $main and counting is the right scope. But the non-vacuity check guards against the WAT structure shifting unexpectedly.

Test count: 3,700 → 3,702. ruff S clean, mypy clean, full pytest + 82 conformance + 33 examples pass.

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Tip

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

@aallan — both fixes confirmed:

array_map mirror testtest_decimal_mapper_not_rooted correctly targets the t_is_adt exclusion path in _translate_array_map, which the fold test couldn't reach. The functional pin (→ [0, 2, 4, 6, 8] → sum 20) is a useful sanity anchor in case the structural count check ever needs debugging.

Brittle count / shared helper — The refactor is the right shape. _assert_handle_not_extra_rooted as a shared static guard means both fold and map tests fail together if the WAT structure shifts unexpectedly, and the int_count > 0 precondition makes the failure message precise ("helper isn't measuring real shadow-stack activity") rather than a mystery equality failure.

The clarification on array_fold inlining is useful — $main scope for the global.set $gc_sp count is correct precisely because the fold body materialises there; the non-vacuity check is the right guard for future refactors where that might change.

3,702 tests, ruff S + mypy clean, full conformance suite passing — clean state.

@aallan aallan merged commit a04e39e into main May 5, 2026
20 checks passed
@aallan aallan deleted the claude/fix-346-347-490-handle-hygiene branch May 5, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Over-rooting of host-managed handles in array_fold (and future iterative combinators) GC shadow stack pollution from opaque handle parameters

1 participant