Skip to content

interp: generator GC finalization + parity review follow-ups (#549)#559

Merged
youknowone merged 5 commits into
mainfrom
wasm-jit
Jul 15, 2026
Merged

interp: generator GC finalization + parity review follow-ups (#549)#559
youknowone merged 5 commits into
mainfrom
wasm-jit

Conversation

@youknowone

Copy link
Copy Markdown
Owner

Follow-up to #549 (merged), rebased on current main. Two correctness commits; python3 pyre/check.py passes every synthetic bench on both backends and all touched behaviors match the CPython oracle. (Locally one unrelated bench, list_insert_pop_index from #552, BASEFAILs only because this machine's CPython oracle is 3.9 — its list.insert(float) message predates 3.11; pyre already emits the 3.11+/3.14 message.)

Generator finalization on GC

A generator suspended inside a try/finally, except, or with block did not run its cleanup when garbage-collected while still suspended — only an explicit close() did. It now registers a GC finalizer (gated on a non-empty code exception table, since PyPy's compile-time CO_YIELD_INSIDE_TRY flag is unavailable — the compiler is an external dependency) and, on collection, raises GeneratorExit into the frame when its current instruction is covered by a handler. Verified across try/finally, with __exit__, nested try, and yield from delegate cleanup. New snippet generator_gc_finalize.py.

Parity review follow-ups (from #549's Codex review)

  • abc: _abc_init defaults a base's __abstractmethods__ only on AttributeError (propagates other errors), iterates any iterable, and probes inherited methods with getattr so a descriptor implementation counts.
  • type creation: raises duplicate base class 'X'; runs C3 validation inside type.__new__ (default-metaclass path) instead of before dispatch, so a custom metaclass owns its bases.
  • list: list.__init__ appends incrementally via list.extend (retains a completed prefix on a mid-iteration failure); list.extend's storage fast path applies only to exact list/tuple so a subclass __iter__ override is honored.
  • functools.cmp_to_key: builds the stdlib K wrapper — __slots__ = ['obj'], comparator captured in the closure (no public cmp, no instance __dict__), comparisons without a same-type gate.
  • super: class-mode super(C, C).attr calls a descriptor __get__ with None as the object argument.
  • generator.throw: a non-exception argument or normalization failure is raised to the caller (not injected — matching CPython, not PyPy); yield from delegation forwards the original (type, value, traceback) triplet.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 79ac0c44-1e80-4759-baf7-24af6489401e

📥 Commits

Reviewing files that changed from the base of the PR and between 38173e3 and 7792672.

📒 Files selected for processing (13)
  • majit/majit-backend-wasm/src/codegen.rs
  • pyre/bench/synth/abstractmethods_nonstring_name.py
  • pyre/extra_tests/snippets/generator_gc_finalize.py
  • pyre/extra_tests/snippets/generator_yield_from_throw_arity.py
  • pyre/pyre-interpreter/src/baseobjspace.rs
  • pyre/pyre-interpreter/src/builtins.rs
  • pyre/pyre-interpreter/src/call.rs
  • pyre/pyre-interpreter/src/executioncontext.rs
  • pyre/pyre-interpreter/src/module/_abc/mod.rs
  • pyre/pyre-interpreter/src/module/_functools/mod.rs
  • pyre/pyre-interpreter/src/pyframe.rs
  • pyre/pyre-interpreter/src/type_methods.rs
  • pyre/pyre-interpreter/src/typedef.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wasm-jit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

A generator suspended inside a try/finally, except, or with block did not
run its cleanup when garbage-collected while still suspended; only an
explicit close() ran it.

Register a GC finalizer for a generator whose code object has a non-empty
exception table, and dispatch a generator-typed finalizer object to
generator_finalize (generator.py _finalize_): when the suspended frame is
still live and its current instruction is covered by an exception-table
handler, raise GeneratorExit into the frame so the pending finally/except/
with cleanup runs.

- FrameBox::into_generator registers the finalizer via
  register_generator_finalizer when the code has any exception handler.
- UserDelAction::_call_finalizer branches on is_generator to
  generator_finalize instead of the __del__ lookup.
- generator_finalize no-ops when the frame is finished (frame_ptr null) or
  not started (last_instr < 0).

PyPy gates registration on the compile-time CO_YIELD_INSIDE_TRY flag; that
flag is unavailable here because the compiler is an external dependency, so
registration gates on the runtime exception table, a necessary condition for
any reachable finally/except/with.

Assisted-by: Claude
… generator throw

- _abc_init: default a base's __abstractmethods__ only on AttributeError
  (propagate other errors), iterate any iterable, and probe an inherited
  method with getattr so a descriptor-provided implementation counts.
- Type creation: raise "duplicate base class X" for a repeated direct base,
  and run C3 validation inside type.__new__ (default-metaclass path) instead
  of before dispatch, so a custom metaclass owns its bases.
- list.__init__ appends incrementally via list.extend, retaining a completed
  prefix when a later next() fails; list.extend's storage fast path applies
  only to exact list/tuple so a subclass __iter__ override is honored.
- _functools.cmp_to_key builds the stdlib K wrapper: __slots__ = ['obj'],
  the comparator captured in the closure (no public cmp, no instance dict),
  comparisons compare other.obj without a same-type gate.
- Class-mode super(C, C).attr calls a descriptor __get__ with None as the
  object argument.
- generator.throw normalizes into the generator only on success; a
  non-exception or normalization failure is raised to the caller. yield-from
  delegation forwards the original (type, value, traceback) throw arguments.

Assisted-by: Claude

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa9ff8f698

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if let Some([w_type, w_val, w_tb]) = throw_args {
// PEP 380 / generator.py delegation forwards the original three
// throw arguments, rather than a synthesized exception instance.
return crate::call::call_function_impl_result(throw, &[w_type, w_val, w_tb]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve throw() arity for yield-from delegates

When outer.throw(...) is delegated through yield from to a non-generator iterator, this always calls the delegate's throw with three positional arguments, even if the caller supplied only one or two. CPython forwards the original argument list, so an iterator with def throw(self, typ): ... works for outer.throw(ValueError), but this path raises an argument-count TypeError or changes observable *args behavior. Track the original throw-argument count or store a slice/Vec rather than padding with None before forwarding.

Useful? React with 👍 / 👎.

// app_abc.py:69 — normal getattr lets descriptors and
// metaclass attributes provide a concrete implementation.
let value = match crate::baseobjspace::getattr_str(cls, unsafe {
pyre_object::w_str_get_value(name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-string abstract method names safely

Because this change now accepts any iterable __abstractmethods__, a base can provide something like [1]; the reference getattr(cls, name, None) raises TypeError for a non-string name, but this unsafe w_str_get_value(name) casts the arbitrary object as a W_UnicodeObject, which can read invalid memory or panic instead of surfacing a Python exception. Check is_str(name) and raise the appropriate error (or route through an object-level getattr) before extracting the string.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 5e4f249).

Files in the reviewed diff
majit/majit-backend-wasm/src/codegen.rs
pyre/bench/synth/abstractmethods_nonstring_name.py
pyre/extra_tests/snippets/generator_gc_finalize.py
pyre/extra_tests/snippets/generator_yield_from_throw_arity.py
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/call.rs
pyre/pyre-interpreter/src/executioncontext.rs
pyre/pyre-interpreter/src/module/_abc/mod.rs
pyre/pyre-interpreter/src/module/_functools/mod.rs
pyre/pyre-interpreter/src/pyframe.rs
pyre/pyre-interpreter/src/type_methods.rs
pyre/pyre-interpreter/src/typedef.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

  • pyre/pyre-interpreter/src/executioncontext.rs:2391 ↔ pypy/interpreter/generator.py:660 — the new generic finalizer treats every pyre generator object as a synchronous generator and calls generator_finalize/close(). Pyre also represents CO_ASYNC_GENERATOR frames with this object, but PyPy’s async-generator finalizer invokes the configured asyncgen-finalizer hook (self.w_finalizer) instead of synchronous close(). Async generators suspended in a handler will therefore take the wrong finalization path.

3. Pre-existing mismatches (already present before this patch)

  • pyre/pyre-interpreter/src/baseobjspace.rs:11288 ↔ pypy/interpreter/generator.py:192generator.throw(type, value, traceback) still reads w_tb but never validates it as a traceback or attaches it to the injected exception. PyPy validates the third argument with check_traceback() before delivery.

  • pyre/pyre-interpreter/src/module/_abc/mod.rs:15 ↔ pypy/module/_abc/app_abc.py:74 — ABC registries and caches remain strong Rust/Python lists with positive/negative caches omitted, whereas PyPy initializes SimpleWeakSet registry, positive cache, and negative cache. Registered classes can be retained and cache-observable behavior differs.

  • pyre/pyre-interpreter/src/baseobjspace.rs:973 ↔ pypy/interpreter/generator.py:435 — pyre’s single generator representation has no coroutine-specific finalizer. PyPy registers every coroutine and emits an unawaited-coroutine warning during finalization; pyre only registers frames with a non-empty exception table.

4. Structural adaptations

  • pyre/pyre-interpreter/src/pyframe.rs:435 ↔ pypy/interpreter/generator.py:25 — using a non-empty CPython 3.11 exception table as the registration gate substitutes for PyPy’s compiler-produced CO_YIELD_INSIDE_TRY flag. This is required because pyre consumes externally compiled CPython-compatible bytecode.

  • pyre/pyre-interpreter/src/baseobjspace.rs:11349 ↔ pypy/interpreter/generator.py:308 — pyre converts its CPython word-index last_instr to a byte offset before exception-table lookup; PyPy stores and queries its own bytecode offset convention directly.

  • majit/majit-backend-wasm/src/codegen.rs:852 ↔ rpython/jit/backend/aarch64/opassembler.py:1102 — wasm directly lowers eligible CALL_MAY_FORCE operations because its virtualizable is already materialized and GUARD_NOT_FORCED is inert. RPython backends retain force-descriptor storage and force-token handling.

…rect

The wasm backend routed every `CallMayForce{I,R,N}` residual call through
the host `jit_call` trampoline (guest->host->guest: reflect the callee
signature, marshal args through linear memory, call, write the result
back), while the non-forceable residual families
(`Call{,Pure,Loopinvariant}{I,R}`) already took a direct in-module
`call_indirect` into the callee's table slot. Since every `int`/`bool`
binary operation compiles to a `CallMayForceR` (descr `[Ref,Ref,Int] ->
Ref`, a uniform-i64 ABI), an int-heavy loop paid one ~158 ns host
crossing per operation: `arith_int_bool` issued 10.7M of them, which was
its entire wasm-vs-native gap.

`CallMayForce` was excluded only for parity conservatism: on RPython the
`jf_descr` force-descr is stored immediately before a CALL_MAY_FORCE so
`guard_not_forced` can observe a forced virtualizable. The wasm backend
has no force tokens — the virtualizable is always materialized and
`GuardNotForced` is lowered as an always-pass no-op — and the host
trampoline performs no `jf_descr` store either, so a direct call is
sound and matches RPython's own direct-CALL lowering. Extend
`residual_call_i64_arity` (`CallMayForceI|CallMayForceR`) and
`residual_call_void_word_arity` (`CallMayForceN`); the arity type-section
census and trampoline census follow through `direct_helper_i64_arity`.
Float / release-GIL / cond / assembler calls and non-reflectable descrs
stay on the trampoline.

arith_int_bool: jit_calls 10,705,693 -> 16,418; wasm 1.85s -> 0.55s
(11.1x -> 3.1x vs dynasm). No effect on traces without uniform-i64
CallMayForce ops (byte-identical modules). check.py --backend wasm
185/185 (list_insert_pop_index BASEFAIL is a local-oracle artifact).

Assisted-by: Claude
@youknowone

Copy link
Copy Markdown
Owner Author

Addressed both Codex review findings and rebased onto current main (#534).

  • abc non-string abstract-method names (8a2d805): _abc.abc_init now routes through object-level getattr(cls, name), which raises TypeError: attribute name must be string, not '<type>' for a non-string entry in a base's iterable __abstractmethods__, instead of an unsafe W_UnicodeObject cast (UB/panic).
  • throw() arity through yield from delegation (7792672): a non-generator delegate's throw is now called with exactly the caller's argument count (1/2/3) — matching CPython / PEP 380 — instead of always padding to a 3-tuple. Re-verified this is the CPython behavior (PyPy, by contrast, pads to 3); the compat target is CPython 3.14.

Also on this branch: a wasm-JIT perf commit (24037f5) lowering CallMayForce{I,R,N} residual calls through a direct in-module call_indirect instead of the host jit_call trampoline — the wasm virtualizable is always materialized so GuardNotForced is a no-op, making the direct call sound and matching RPython's own direct-CALL lowering. arith_int_bool wasm 1.85s→0.55s (jit_calls 10.7M→16K).

check.py --backend dynasm,wasm green apart from list_insert_pop_index, a pre-existing local cpython-3.9.6-vs-pypy-3.11 oracle mismatch on the list.pop(float) message (backend-independent; passes on CI's 3.14).

commented by Claude

@youknowone youknowone merged commit 048978b into main Jul 15, 2026
42 of 44 checks passed
@youknowone youknowone deleted the wasm-jit branch July 15, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant