fix(napi): prevent async iterator use-after-free during GC#3120
fix(napi): prevent async iterator use-after-free during GC#3120Brooooooklyn merged 3 commits intomainfrom
Conversation
Keep the instance alive while the async generator is in use by storing a reference as a hidden property on the generator object. Without this, GC can collect the instance mid-iteration, causing a use-after-free crash. Co-Authored-By: Claude Opus 4.6 <[email protected]>
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
📝 WalkthroughWalkthroughAttaches a hidden external instance reference to per-generator objects in the async-iterator runtime to prevent premature GC, adds a CounterRepro async-generator example and tests that exercise GC/use-after-free and hidden-property behavior, updates example exports and type declarations, and replaces several GitHub Actions docker-run-action steps with inline docker run invocations. Changes
Sequence Diagram(s)sequenceDiagram
participant JS as JavaScript
participant Gen as GeneratorObject
participant Inst as RustInstance
participant NAPI as N-API/Runtime
participant GC as GarbageCollector
JS->>NAPI: construct CounterRepro instance
NAPI->>Inst: allocate RustInstance
JS->>NAPI: create async generator for instance
NAPI->>Gen: allocate GeneratorObject
NAPI->>Inst: create strong Reference
NAPI->>Gen: attach external (INSTANCE_REF_KEY) -> Reference
loop iteration
JS->>Gen: next()/for-await-of
Gen->>NAPI: forward next to RustInstance (via Reference)
NAPI->>Inst: run next() future, yield value
end
GC->>Gen: collect unreachable GeneratorObject
GC->>NAPI: invoke external cleanup callback
NAPI->>Gen: remove stored Reference
NAPI->>Inst: drop Reference (allowing instance free)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@crates/napi/src/bindgen_runtime/async_iterator.rs`:
- Around line 228-284: The finalize callback cleanup_instance_ref currently
ignores the return status of sys::napi_delete_reference; update
cleanup_instance_ref to capture the return value and assert it succeeded (use
debug_assert! that status == sys::Status::napi_ok with a clear message) when
instance_ref is non-null, matching the pattern used in bindgen_runtime::mod.rs
and js_values::object.rs so failures during shutdown are checked consistently.
🧹 Nitpick comments (4)
examples/napi/__tests__/issue-3119-repro.spec.ts (1)
9-12: Stale comments: these describe the pre-fix behavior, not the current expectation.Lines 10-11 say "This test should fail/crash with the current implementation" — but with this PR's fix applied, the test should pass. Update the comments to reflect that this is a regression test.
📝 Suggested comment update
-// Exact reproduction of issue `#3119` -// This test should fail/crash with the current implementation -// Expected behavior: completes 5000 iterations -// Actual behavior with bug: crashes at ~559 iterations with "malloc(): unaligned tcache chunk detected" +// Regression test for issue `#3119` +// Before the fix, this would crash at ~559 iterations with "malloc(): unaligned tcache chunk detected" +// due to the instance being GC'd while the async generator was still in use.examples/napi/__tests__/hidden-property.spec.ts (1)
38-49: Silent pass when[[InstanceRef]]descriptor is missing.If the property doesn't exist (e.g., due to a regression in the Rust side),
descriptorwill beundefinedand the entire block is skipped—the test passes without verifying anything. Consider failing explicitly when the descriptor is absent.Suggested fix
const descriptor = Object.getOwnPropertyDescriptor( iterator, '[[InstanceRef]]', ) - if (descriptor) { - t.false(descriptor.enumerable, '[[InstanceRef]] should be non-enumerable') - t.false(descriptor.writable, '[[InstanceRef]] should be non-writable') - t.false( - descriptor.configurable, - '[[InstanceRef]] should be non-configurable', - ) - } + t.truthy(descriptor, '[[InstanceRef]] property descriptor should exist') + t.false(descriptor!.enumerable, '[[InstanceRef]] should be non-enumerable') + t.false(descriptor!.writable, '[[InstanceRef]] should be non-writable') + t.false( + descriptor!.configurable, + '[[InstanceRef]] should be non-configurable', + )examples/napi/__tests__/async-generator-gc.spec.ts (1)
5-6: Tests restricted to macOS ARM64 only.This means the GC-interaction tests will be silently skipped on Linux CI (and Intel Macs). If the intent is to only run where GC behavior is reliably reproducible, a brief comment explaining why would help future contributors understand this isn't an oversight.
examples/napi/src/async_generator_repro.rs (1)
28-45:self.currentincrements unconditionally, even pastmax.When
current >= max, the method returnsNone(signaling done) but has already incrementedcurrent. For a test repro this is harmless—JS stops callingnext()afterdone: true—but it's a small correctness nit if this is meant to be an example others copy.Optional: increment only when yielding a value
fn next( &mut self, _value: Option<Self::Next>, ) -> impl Future<Output = Result<Option<Self::Yield>>> + Send + 'static { let current = self.current; let max = self.max; - self.current += 1; + if current < max { + self.current += 1; + } async move {
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In @.github/workflows/test-release.yaml:
- Around line 546-555: Replace the mutable action reference
"tj-actions/docker-run@v2" with an immutable pinned commit SHA (e.g.
"tj-actions/docker-run@<full-commit-sha>") or remove the third-party action
entirely and implement the same behavior with a native job step using "run:" and
the docker CLI; preserve the step inputs (image: ${{
steps.image-name.outputs.docker-image }}, name: test-${{ matrix.settings.target
}}-node${{ matrix.node }}, options, args) and ensure you stop injecting the
repository GITHUB_TOKEN unless strictly necessary (or reduce its scope) to avoid
exposing secrets if the action is compromised.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In @.github/workflows/zig.yaml:
- Around line 154-161: The workflow's docker run commands don't forward the
GITHUB_TOKEN into containers, causing tests like
examples/napi/__tests__/async-exit.js (which reads process.env.GITHUB_TOKEN) to
fail; update each docker run invocation in the zig.yaml steps that currently set
env: GITHUB_TOKEN to include the environment forwarding flag -e GITHUB_TOKEN
(i.e., add -e GITHUB_TOKEN to the docker run arguments for the three invocations
so the container receives the token).
🧹 Nitpick comments (1)
.github/workflows/zig.yaml (1)
182-186: Mounting path differs from the other steps.This step mounts to
/napi-rs(-w /napi-rs) while the other two steps mount to/build(-w /build). This is likely intentional (carried over from the previous action config), but worth a quick sanity check that nothing depends on a consistent mount point across steps.

Keep the instance alive while the async generator is in use by storing
a reference as a hidden property on the generator object. Without this,
GC can collect the instance mid-iteration, causing a use-after-free crash.
Co-Authored-By: Claude Opus 4.6 [email protected]
Note
Medium Risk
Touches core async-iterator runtime memory management (N-API references/finalization), where mistakes can cause leaks or crashes, but changes are narrowly scoped and covered by new regression tests.
Overview
Fixes a GC-related use-after-free in
AsyncGeneratorasync iterators by attaching a hidden[[InstanceRef]]property to the generated iterator object that holds an N-API reference to the originating instance and cleans it up when the iterator is collected.Adds a minimal
CounterReproexample async iterator plus new AVA tests that reproduce issue #3119, stress GC duringfor await...of, and verify the hidden property is non-enumerable/non-writable/non-configurable; updates generated type snapshots/exports accordingly. Also replacesaddnab/docker-run-actionusage in CI workflows with directdocker runinvocations for cross-build/test steps.Written by Cursor Bugbot for commit ec64112. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Improvements
Tests