Skip to content

napi: capture Ref<NapiEnv> in node_api_create_external_string_* finalizers - #32277

Closed
robobun wants to merge 7 commits into
mainfrom
farm/d2e093b4/napi-external-string-env-ref
Closed

robobun wants to merge 7 commits into
mainfrom
farm/d2e093b4/napi-external-string-env-ref

Conversation

@robobun

@robobun robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

node_api_create_external_string_latin1 and node_api_create_external_string_utf16 (src/jsc/bindings/napi.cpp) install an ExternalStringImpl free callback that calls env->doFinalizer(...). The lambda captured env (the raw napi_env pointer) by value:

[finalize_callback, env](void* hint, void* str, unsigned length) {
    env->doFinalizer(finalize_callback, str, hint);
}

NapiEnv is RefCounted; its primary owner is Zig::GlobalObject::m_napiEnvs, dropped in ~GlobalObject(). If the env's refcount reaches zero before the external string's StringImpl is dereferenced, the finalizer lambda calls doFinalizer (which reads m_napiModule.nm_version, m_vm, etc.) on freed memory.

The sibling finalizers in napi_add_finalizer (line 1167) and napi_create_external_buffer (line 2087) already capture WTF::Ref<NapiEnv>(*env) for exactly this reason. This change brings the two external-string paths in line.

Why this can't be gate-proven

I tried to construct a deterministic fail-before and couldn't. The ordering between ~GlobalObject (frees the NapiEnv) and the string finalizer depends on JSC heap layout:

  • During worker teardown, WebWorker__teardownJSCVM runs collectNow(Sync, Full) while the Rust shutdown frame still has the global on its stack, so conservative roots keep the global (and anything rooted on it) marked through that collection.
  • The cells are then destroyed in ~VM → Heap::lastChanceToFinalize() → MarkedSpace::lastChanceToFinalize(), which runs forEachDirectory (block-directory cells, including the external-string JSStrings) before iterating m_preciseAllocations (where the GlobalObject's lower-tier precise allocation lives).
  • m_preciseAllocations is sorted by pointer address before each full GC (MarkedSpace::prepareForConservativeScan()), so even holders like DOMException that keep a WTF::String ref to the ExternalStringImpl and are themselves lower-tier precise depend on where the allocator placed them relative to the global.

I ran 30+ iterations of a worker fixture that stashes external strings in DOMException / Event / File holders (which share the StringImpl via toWTFString), with Malloc=1 + ASAN + BUN_JSC_stealEmptyBlocksFromOtherAllocators=0, and the GlobalObject's PreciseAllocation consistently sorted last, so ~NapiEnv ran after every string finalizer. Forcing the opposite order would require instrumenting src/ to perturb allocation addresses, which the gate strips.

The fix is still correct regardless of ordering and matches the established pattern. Any future change to sweep order, PreciseAllocation layout, or NapiEnv ownership that exposes the window will be caught by the new test, which routes the NapiEnv allocation through the system heap on the Linux ASAN lane.

Test

test/napi/napi-external-string-env.test.ts + a small C addon (test/napi/napi-app/external_string_addon.c):

  • Worker loads the addon, creates 64 external strings per variant (latin1 and utf16), roots them on globalThis plus a DOMException holder each, then exits.
  • The addon's finalizer increments a process-global counter; the main thread reloads the addon after the worker exits and asserts all 64 finalizers ran through env->doFinalizer().
  • On Linux the child runs with Malloc=1 so the TZONE-allocated NapiEnv is ASAN-visible.
bun bd test test/napi/napi-external-string-env.test.ts   # 2 pass
bun bd test test/napi/node-napi-tests/test/js-native-api/test_string/do.test.ts   # 3 pass

no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/napi-external-string-env.test.ts

@coderabbitai

coderabbitai Bot commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Fixes use-after-free in node_api_create_external_string_latin1 and node_api_create_external_string_utf16 by changing the ExternalStringImpl finalizer lambda capture from a raw env pointer to WTF::Ref<NapiEnv>(*env). Adds a new native test addon and a worker-teardown test to verify all 64 finalizers fire after the worker env is torn down.

Changes

NapiEnv lifetime fix and external string finalizer tests

Layer / File(s) Summary
Finalizer lambda env capture fix
src/jsc/bindings/napi.cpp
Both node_api_create_external_string_latin1 and node_api_create_external_string_utf16 now capture env as WTF::Ref<NapiEnv>(*env) in the ExternalStringImpl finalizer lambda instead of the raw env pointer.
Test addon: external string creation and finalization
test/napi/napi-app/binding.gyp, test/napi/napi-app/external_string_addon.c
New C addon with createLatin1, createUtf16, and finalizedCount N-API exports; per-variant finalizers free backing buffers and increment a shared counter. New gyp target external_string_addon sets NAPI_VERSION=10.
Worker-teardown test verifying finalization
test/napi/napi-external-string-env.test.ts
Parametrized test builds the addon, spawns a child process running an inline fixture that creates 64 external strings in a worker (wrapped in DOMException), and asserts clean exit with finalized=64. Linux-specific Malloc=1 and ASAN_OPTIONS are set to expose memory-ordering issues.

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: updating finalizers in external string functions to capture a Ref instead of a raw pointer for memory safety.
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.
Description check ✅ Passed The PR description covers both the change and verification steps, even though it uses different headings than the repository template.

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

@robobun

robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:10 PM PT - Jul 9th, 2026

❌ @robobun, your commit 4ff5e80 has 1 failures in Build #71190 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32277

That installs a local version of the PR into your bun-32277 executable, so you can run:

bun-32277 --bun

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@test/napi/napi-app/external_string_addon.c`:
- Around line 42-43: The malloc calls at lines 42 and 55 in
external_string_addon.c are not guarded with null checks before the subsequent
memcpy operations. If malloc fails and returns NULL, the memcpy will dereference
the NULL pointer and crash the process. Add null checks immediately after each
malloc call to verify the buffer was successfully allocated, and if allocation
fails, return a controlled N-API error (such as napi_generic_failure or similar
error handling) instead of allowing the crash to occur. Apply this same
defensive programming pattern to both malloc/memcpy pairs at the anchor location
(lines 42-43) and the sibling location (lines 55-56).
🪄 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: 69e20927-fdf5-405a-bc3e-9ffc08cb3019

📥 Commits

Reviewing files that changed from the base of the PR and between e0acad3 and 09c90a4.

📒 Files selected for processing (4)
  • src/jsc/bindings/napi.cpp
  • test/napi/napi-app/binding.gyp
  • test/napi/napi-app/external_string_addon.c
  • test/napi/napi-external-string-env.test.ts

Comment thread test/napi/napi-app/external_string_addon.c

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/napi/napi-external-string-env.test.ts:119-120 — Nit: per test/CLAUDE.md ("CRITICAL: Do not set a timeout on tests. Bun already has timeouts."), drop the 60_000 timeout argument here. The 120_000 on beforeAll is fine — node-gyp builds are slow and other napi tests use the same pattern — but the per-test timeout should go.

    Extended reasoning...

    What this is. test/CLAUDE.md:120 states:

    CRITICAL: Do not set a timeout on tests. Bun already has timeouts.

    The new test passes 60_000 as the third argument to test.skipIf(isWindows).each([...])(name, fn, 60_000) at the end of the file. This is a direct violation of the documented test convention for this repository.

    Why the rule exists. Bun's test runner already applies a global per-test timeout, and CI further wraps test files with its own timeout budget. Sprinkling per-test numeric timeouts creates a second source of truth that drifts from the runner default, makes it harder to globally tune CI timing, and tends to either mask hangs (when set too high) or cause spurious flakes (when set too low). The repo convention is to leave timing to the harness.

    Step-by-step.

    1. test.skipIf(isWindows).each(["createLatin1", "createUtf16"])(title, async fn => { ... }, 60_000) — the trailing 60_000 is the per-test timeout in ms.
    2. The test body spawns one child bun process that starts a worker, creates 64 external strings, tears the worker down, and prints two lines. There is no node-gyp work in the test body (that's already done in beforeAll), so this is a few-second operation at most — well under the runner default.
    3. Therefore the explicit 60_000 adds nothing functionally and only contradicts the style guide.

    What about beforeAll(..., 120_000)? That one is defensible: it shells out to bun install which runs the full node-gyp build of test/napi/napi-app (a dozen-plus targets). Several existing napi tests use the same beforeAll(..., 120_000) pattern for exactly this reason, so leaving it is consistent with the surrounding tree. Only the per-test 60_000 is the outlier.

    Precedent. There is one prior per-test timeout in test/napi/napi.test.ts:757 (25_000), so this isn't unprecedented — but the CLAUDE.md guidance is explicit and newer code should follow it rather than copy the exception.

    Fix. Delete the trailing 60_000, argument:

        },
    -   60_000,
      );

    This is a convention nit, not a correctness issue — the test passes either way.

Comment thread test/napi/napi-external-string-env.test.ts Outdated
Comment thread test/napi/napi-external-string-env.test.ts Outdated
Comment thread src/jsc/bindings/napi.cpp
Comment thread test/napi/napi-external-string-env.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@test/napi/napi-external-string-env.test.ts`:
- Around line 111-114: The test assertion in napi-external-string-env.test.ts
uses a strict `stderr: ""` check which can cause false positives due to
sanitizer and runtime noise in ASAN-sensitive tests. Replace the exact empty
string assertion for stderr with a check that verifies stderr does NOT contain
sanitizer error signatures (such as ASAN error patterns or UAF indicators). Keep
the combined object assertion structure but modify the stderr property to use a
negation matcher instead of requiring exact emptiness.
🪄 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: 595e4fb7-dc44-4a77-92e2-6c2e2c62b46a

📥 Commits

Reviewing files that changed from the base of the PR and between 09c90a4 and 57562c7.

📒 Files selected for processing (1)
  • test/napi/napi-external-string-env.test.ts

Comment thread test/napi/napi-external-string-env.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the Ref capture matches the established pattern at napi.cpp:1167/2087, and all prior feedback has been addressed.

Extended reasoning...

Overview

The production change is two lines in src/jsc/bindings/napi.cpp: the ExternalStringImpl free-callback lambdas in node_api_create_external_string_latin1 and node_api_create_external_string_utf16 now capture env = WTF::Ref<NapiEnv>(*env) instead of the raw napi_env pointer. This is a mechanical pattern-match against the identical capture already used in the same file at napi_add_finalizer (line 1167) and napi_create_external_buffer (line 2087). The remaining changes are test scaffolding: a small C addon (external_string_addon.c), a binding.gyp target, and a worker-teardown test that exercises both finalizer paths under Malloc=1 + ASAN on Linux.

Security risks

None introduced. The change is a strict lifetime tightening: holding a Ref<NapiEnv> in the lambda can only extend the env's lifetime, never shorten it, so it cannot create a new UAF. The Ref is released when the ExternalStringImpl is destroyed, so there is no leak or cycle — the same ownership shape already runs in production via the two sibling call sites. No auth, crypto, or permissions code is touched.

Level of scrutiny

Low-to-moderate. napi.cpp is core binding code, but this specific edit is a two-token capture change that brings two functions in line with an in-file precedent that has already been validated in production. The PR description gives a thorough analysis of why the UAF window is layout-dependent and hard to gate-prove, which is consistent with treating this as defensive hardening rather than a hot-path behavior change. Ref<T>::operator-> makes env->doFinalizer(...) compile identically, so there is no semantic drift in the call itself.

Other factors

I left four inline comments on earlier revisions; three were applied (dropped the stale collectContinuously comment + Windows skip in fe0bf0a, switched to the combined {stdout, stderr, exitCode} assertion in c20da88, moved to test.concurrent.each in 57562c7) and one was reasonably declined (the pre-existing copied out-param asymmetry is already tracked by a separate open PR). CodeRabbit's malloc null-check nit was declined consistently with the sibling isolate_finalizer_addon.c pattern. The bug-hunting pass on the current head found nothing. No CODEOWNERS cover these paths. Tests pass per the PR description, and the new test follows the project's subprocess-test conventions after iteration.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the two-line capture change matches the established Ref<NapiEnv> pattern at lines 1167/2087, and all prior review feedback has been addressed.

Extended reasoning...

Overview

The production change is two lines in src/jsc/bindings/napi.cpp: the ExternalStringImpl finalizer lambdas in node_api_create_external_string_latin1 and node_api_create_external_string_utf16 now capture env = WTF::Ref<NapiEnv>(*env) instead of the raw napi_env pointer. This is byte-for-byte the same pattern already used by napi_add_finalizer (line 1167) and napi_create_external_buffer (line 2087) in the same file. WTF::Ref::operator->() keeps env->doFinalizer(...) working unchanged. The remaining ~200 lines are test-only: a new C addon target in binding.gyp, the addon source, and a worker-teardown test.

Security risks

None introduced. The change is strictly defensive — it extends the lifetime of a RefCounted object across a callback that dereferences it, closing a theoretical use-after-free window. It cannot make memory safety worse; the only behavioral delta is one extra refcount inc/dec per external-string creation. No new attack surface, no auth/crypto/permission code touched.

Level of scrutiny

The src/ change is mechanical pattern-matching to two existing siblings in the same file and is verifiable by inspection. The test additions follow established test/napi/napi-app/ conventions (mirrors isolate_finalizer_addon.c) and the Malloc=1/ASAN subprocess pattern from websocket-server-upgrade-reentrant.test.ts. Low scrutiny is appropriate for the production change; the test code has already absorbed four rounds of review feedback.

Other factors

I previously left four inline comments on this PR. All were either applied (stale collectContinuously comment dropped in fe0bf0a, combined-object assertion in c20da88, test.concurrent.each in 57562c7) or reasonably declined (the pre-existing copied out-param asymmetry is deferred to PR #32259 to avoid a merge conflict; the new addon doesn't read copied). All review threads are resolved, the bug-hunting system found nothing, and there are no CODEOWNERS for these paths. The most recent commit (3f94689) is a CI retrigger with no code changes.

robobun and others added 6 commits July 9, 2026 22:01
…izers

The ExternalStringImpl free callbacks installed by
node_api_create_external_string_latin1 and _utf16 captured the raw
napi_env pointer by value. NapiEnv is RefCounted and its primary owner
is Zig::GlobalObject::m_napiEnvs, dropped in ~GlobalObject(). If the env
is released before the external string's StringImpl is dereferenced, the
finalizer calls env->doFinalizer() on freed memory.

Capture WTF::Ref<NapiEnv>(*env) instead, matching the existing pattern
in napi_add_finalizer and napi_create_external_buffer.

Add an addon and worker-teardown test that exercises both finalizer
paths through env->doFinalizer() with Malloc=1 so ASAN instruments the
TZONE-allocated NapiEnv. Whether ~GlobalObject precedes the string
finalizer is heap-layout dependent (MarkedSpace::prepareForConservativeScan
sorts m_preciseAllocations by address each full GC); under current
allocation patterns the GlobalObject sorts last so the UAF does not
fire, but the test makes any future reordering observable.
The Windows skip rationale referenced BUN_JSC_collectContinuously, which this
test never sets; drop the skip so non-Linux lanes still assert the finalizers
run (Malloc=1 remains Linux-only). Also remove the explicit 60s per-test
timeout per test/CLAUDE.md.
Asserting stderr is exactly empty under Malloc=1 + ASAN can flake on
benign sanitizer output across compiler-rt versions. The combined
object keeps the empty-stderr check (so an ASAN heap-use-after-free
report still fails the test) while surfacing all three values in one
failure diff.
@robobun
robobun force-pushed the farm/d2e093b4/napi-external-string-env-ref branch from 3f94689 to 6c99c80 Compare July 9, 2026 22:05
Comment thread test/napi/napi-external-string-env.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the two-line native change matches the established env = WTF::Ref<NapiEnv>(*env) capture pattern already used by napi_add_finalizer and napi_create_external_buffer in the same file, and all prior review feedback has been addressed.

Extended reasoning...

Overview

The PR changes two lambda captures in src/jsc/bindings/napi.cpp (node_api_create_external_string_latin1 and node_api_create_external_string_utf16) from capturing the raw napi_env pointer to capturing env = WTF::Ref<NapiEnv>(*env), so the finalizer lambda holds a strong reference to the RefCounted NapiEnv for its lifetime. It also adds a new C test addon (external_string_addon.c), a binding.gyp target, and a worker-teardown test (napi-external-string-env.test.ts).

I verified the native change against the current tree: the identical capture form [env = WTF::Ref<NapiEnv>(*env), ...] with env->doFinalizer(...) in the body already appears at napi.cpp:1185 (napi_add_finalizer) and napi.cpp:2105 (napi_create_external_buffer). This PR brings the two remaining raw-pointer-capturing finalizer lambdas in line with those siblings. WTF::Ref::operator->() makes env->doFinalizer(...) work unchanged, and the ref is released when the ExternalStringImpl's stored free callback is destroyed.

Security risks

None. The change strictly increases safety — holding a Ref where a raw pointer was previously captured cannot introduce a UAF and closes a theoretical one. No user-controlled input handling, auth, crypto, or permissions are touched.

Level of scrutiny

This is memory-safety C++ in the JSC/N-API bindings, which normally warrants careful review. However, the actual native diff is two tokens per line and is a mechanical pattern-match to two existing sibling call sites in the same file that were written for exactly this reason. The change is defensively correct regardless of whether the sweep-ordering window ever opens (as the PR description honestly explains it currently does not under observed heap layouts). There is no plausible way this capture change regresses behavior.

Other factors

I have reviewed this PR across four prior rounds; every comment (stale skip rationale, combined-object stderr assertion, test.concurrent.each, canBuildNodeAddons() gate) has been applied, and the one declined suggestion (pre-existing copied out-param asymmetry) is correctly deferred to the separate open PR for it. The bug-hunting system found nothing. The test follows harness conventions (tempDir, bunEnv, concurrent subprocess drain, combined-object assertion, describe.skipIf(!canBuildNodeAddons())), and the addon mirrors the sibling isolate_finalizer_addon.c shape. The remaining risk is CI-only (whether the new node-gyp target builds cleanly on all lanes), which BuildKite will surface.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the new test (test/napi/napi-external-string-env.test.ts) passes on every completed lane across builds 62398, 62531, and 71190.

Remaining red is unrelated to this diff:

  • 71190: proxy-stress-concurrent.test.ts on darwin 14 x64 (HTTP proxy stress test); plus known flaky retries in hot.test.ts, express/res.send.test.ts, node-http.test.ts, bun-install-*, solc.test.ts, and the pre-existing napi.test.ts test_wrap_lifetime GC-timing probe.
  • 62398: fetch-abort-slow-connect.test.ts on darwin 14 aarch64 (50 ms AbortSignal.timeout race).

The src/ change here is two lambda-capture tokens in napi.cpp; it cannot affect HTTP proxy, fetch connect timing, hot reload, or the install CLI. Ready for review/merge on the strength of the pattern match with napi_add_finalizer / napi_create_external_buffer (see PR body for the fail-before analysis).

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-09, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant