Skip to content

Comments

fix(napi): prevent async iterator use-after-free during GC#3120

Merged
Brooooooklyn merged 3 commits intomainfrom
fix/async-iterator-double-free
Feb 14, 2026
Merged

fix(napi): prevent async iterator use-after-free during GC#3120
Brooooooklyn merged 3 commits intomainfrom
fix/async-iterator-double-free

Conversation

@Brooooooklyn
Copy link
Member

@Brooooooklyn Brooooooklyn commented Feb 14, 2026

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 AsyncGenerator async 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 CounterRepro example async iterator plus new AVA tests that reproduce issue #3119, stress GC during for await...of, and verify the hidden property is non-enumerable/non-writable/non-configurable; updates generated type snapshots/exports accordingly. Also replaces addnab/docker-run-action usage in CI workflows with direct docker run invocations 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

    • Added CounterRepro class with async-iteration support (usable in for-await-of).
    • Exposed CounterRepro across example module formats for easier consumption.
  • Improvements

    • Improved async-generator robustness to prevent premature cleanup during iteration and reduce GC-related crashes.
  • Tests

    • Added tests validating GC interactions, iterator lifecycle, property visibility, and large-iteration stability.

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]>
Copy link
Member Author


How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • ready-to-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent hot fixes, skip the queue and merge this PR next

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.

@Brooooooklyn Brooooooklyn changed the title fix(napi): prevent async iterator use-after-free during GC (#3119) fix(napi): prevent async iterator use-after-free during GC Feb 14, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

📝 Walkthrough

Walkthrough

Attaches 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

Cohort / File(s) Summary
Runtime change
crates/napi/src/bindgen_runtime/async_iterator.rs
Attach a non-enumerable external INSTANCE_REF_KEY on generator objects, create a reference to the Rust instance, wrap it as an external with a cleanup callback, and delete the stored reference when the generator is GC'd to keep the instance alive during iteration.
Example async generator
examples/napi/src/async_generator_repro.rs, examples/napi/src/lib.rs
Add CounterRepro async generator (constructor + AsyncGenerator impl using a short Tokio sleep) and expose it via the examples module.
Exports & types
examples/napi/index.cjs, examples/napi/example.wasi.cjs, examples/napi/example.wasi-browser.js, examples/napi/index.d.cts, examples/napi/__tests__/__snapshots__/values.spec.ts.md
Export CounterRepro in CommonJS/WASI/browser entrypoints and add its ambient type/signature to declarations and snapshot.
Tests
examples/napi/__tests__/async-generator-gc.spec.ts, examples/napi/__tests__/hidden-property.spec.ts, examples/napi/__tests__/issue-3119-repro.spec.ts
Add AVA tests that validate GC interactions (mass iteration with forced GC), verify the hidden/non-enumerable/non-configurable instance-ref property, and reproduce issue #3119 conditions.
CI workflow updates
.github/workflows/docker.yaml, .github/workflows/test-release.yaml, .github/workflows/zig.yaml
Replace uses of addnab/docker-run-action@v3 with inline docker run invocations (single-run sh -c strings), preserving platform, mounts, and environment while reorganizing steps.

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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hid a tiny key beneath the mossy log,
So yields and naps won't spill my nog.
I hop through sleeps and keep my stash,
The GC prowls but cannot crash.
Hooray — my instance stays, hoorah!

🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes scope creep: GitHub Actions workflow changes replacing docker-run-action@v3 with docker run commands are unrelated to the core async iterator use-after-free fix. Extract the GitHub Actions workflow changes (docker.yaml, test-release.yaml, zig.yaml) into a separate PR focused on CI/CD improvements.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main change: preventing async iterator use-after-free during garbage collection.
Linked Issues check ✅ Passed The PR implementation directly addresses issue #3119 by storing a strong N-API reference on the async iterator as a hidden property to keep the backing Rust instance alive during iteration, preventing premature GC and use-after-free crashes.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/async-iterator-double-free

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 and usage tips.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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), descriptor will be undefined and 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.current increments unconditionally, even past max.

When current >= max, the method returns None (signaling done) but has already incremented current. For a test repro this is harmless—JS stops calling next() after done: 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 {

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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.

@Brooooooklyn Brooooooklyn merged commit c5ef7e5 into main Feb 14, 2026
75 checks passed
@Brooooooklyn Brooooooklyn deleted the fix/async-iterator-double-free branch February 14, 2026 08:10
@github-actions github-actions bot mentioned this pull request Feb 14, 2026
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.

AsyncGenerator: use-after-free after garbage collection

1 participant