Compare commits

..

2 Commits

Author SHA1 Message Date
Claude Bot
ed2b323578 Add regression test for NAPI finalizer segfault (#24552)
Added test coverage to prevent regression of the NAPI finalizer crash
that occurs when finalizers run during env teardown.

Test components:
1. New NAPI addon (test_finalizer_on_teardown.c) - creates objects with
   finalizers that attempt to access the env during cleanup
2. Regression test (24552.test.ts) - spawns subprocesses to reproduce
   the crash scenario, simulating rspack/rsbuild usage pattern

The tests verify:
- Finalizers don't crash when env is being torn down
- Multiple subprocess spawns in a loop don't cause crashes
- No segfault or panic messages in output

Without the fix in src/napi/napi.zig, these tests would segfault.
With the fix, they pass by safely skipping finalizer execution when
the env is no longer valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 03:36:01 +00:00
Claude Bot
fa198b1441 Fix NAPI finalizer segfault when running rspack/rsbuild
Fixes #24552

When NAPI modules (like rspack/rsbuild via SWC) are used in subprocesses,
finalizers can be enqueued during garbage collection but may attempt to run
after the NapiEnv has started tearing down, causing segmentation faults at
address 0x0.

The crash occurs because:
1. NAPI objects with finalizers are garbage collected
2. Finalizers are enqueued to run on the next tick
3. The NapiEnv begins cleanup (m_globalObject becomes null/invalid or VM terminates)
4. When the finalizer runs, it dereferences the invalid globalObject pointer

The fix:
- Add NapiEnv__canRunFinalizer() to check if env, globalObject are valid and VM is not terminating
- Call this check in Finalizer.run() before accessing the env
- Skip finalizer execution if the env is no longer valid

This prevents the segfault while ensuring finalizers run when safe to do so.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 03:29:11 +00:00
7 changed files with 191 additions and 154 deletions

View File

@@ -1000,9 +1000,6 @@ pub fn VisitStmt(
try stmts.append(stmt.*);
}
pub fn s_if(noalias p: *P, noalias stmts: *ListManaged(Stmt), noalias stmt: *Stmt, noalias data: *S.If) !void {
// For deeply nested if-else chains, we need to avoid stack overflow.
// We do this by handling the "else if" chain iteratively rather than recursively.
// Process the test expression and yes branch for the first if statement
data.test_ = p.visitExpr(data.test_);
if (p.options.features.minify_syntax) {
@@ -1020,87 +1017,20 @@ pub fn VisitStmt(
}
// The "else" clause is optional
// For else-if chains, process iteratively to avoid stack overflow
if (data.no) |no| {
// Check if this is the start of an else-if chain
var current_no = no;
var parent_data: ?*S.If = data;
while (current_no.data == .s_if) {
var current_if_data: *S.If = current_no.data.s_if;
// Process this if statement in the chain
current_if_data.test_ = p.visitExpr(current_if_data.test_);
if (p.options.features.minify_syntax) {
current_if_data.test_ = SideEffects.simplifyBoolean(p, current_if_data.test_);
}
const chain_effects = SideEffects.toBoolean(p, current_if_data.test_.data);
if (chain_effects.ok and !chain_effects.value) {
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
current_if_data.yes = p.visitSingleStmt(current_if_data.yes, StmtsKind.none);
p.is_control_flow_dead = old;
} else {
current_if_data.yes = p.visitSingleStmt(current_if_data.yes, StmtsKind.none);
}
// Move to the next else clause
if (current_if_data.no) |next_no| {
// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
if (@as(Stmt.Tag, next_no.data) == .s_empty) {
current_if_data.no = null;
break;
}
}
// Check if the next else is another if (continuing the chain)
if (next_no.data == .s_if) {
current_no = next_no;
parent_data = current_if_data;
continue; // Continue processing the chain
} else {
// Not an else-if, process the final else branch normally
if (chain_effects.ok and chain_effects.value) {
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
defer p.is_control_flow_dead = old;
current_if_data.no = p.visitSingleStmt(next_no, .none);
} else {
current_if_data.no = p.visitSingleStmt(next_no, .none);
}
// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
if (current_if_data.no != null and @as(Stmt.Tag, current_if_data.no.?.data) == .s_empty) {
current_if_data.no = null;
}
}
break;
}
} else {
break; // No more else clauses
}
if (effects.ok and effects.value) {
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
defer p.is_control_flow_dead = old;
data.no = p.visitSingleStmt(no, .none);
} else {
data.no = p.visitSingleStmt(no, .none);
}
// If we didn't enter the loop (no was not an if statement), process normally
if (parent_data == data and current_no.data != .s_if) {
if (effects.ok and effects.value) {
const old = p.is_control_flow_dead;
p.is_control_flow_dead = true;
defer p.is_control_flow_dead = old;
data.no = p.visitSingleStmt(current_no, .none);
} else {
data.no = p.visitSingleStmt(current_no, .none);
}
// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
if (data.no != null and @as(Stmt.Tag, data.no.?.data) == .s_empty) {
data.no = null;
}
// Trim unnecessary "else" clauses
if (p.options.features.minify_syntax) {
if (data.no != null and @as(Stmt.Tag, data.no.?.data) == .s_empty) {
data.no = null;
}
}
}

View File

@@ -2970,4 +2970,10 @@ extern "C" void NapiEnv__deref(napi_env env)
env->deref();
}
extern "C" bool NapiEnv__canRunFinalizer(napi_env env)
{
// Check if the global object is still valid and VM is not terminating
return env && env->globalObject() && !env->isVMTerminating();
}
}

View File

@@ -1366,6 +1366,7 @@ pub export fn napi_internal_suppress_crash_on_abort_if_desired() void {
}
extern fn napi_internal_remove_finalizer(env: napi_env, fun: napi_finalize, hint: ?*anyopaque, data: ?*anyopaque) callconv(.C) void;
extern fn NapiEnv__canRunFinalizer(*NapiEnv) bool;
pub const Finalizer = struct {
env: NapiEnv.Ref,
@@ -1375,6 +1376,14 @@ pub const Finalizer = struct {
pub fn run(this: *Finalizer) void {
const env = this.env.get();
// Safety check: Ensure the env is still valid before running the finalizer.
// This prevents crashes when finalizers are enqueued but the NapiEnv is being
// torn down (e.g., when a subprocess using NAPI modules is terminating).
if (!NapiEnv__canRunFinalizer(env)) {
return;
}
const handle_scope = NapiHandleScope.open(env, false);
defer if (handle_scope) |scope| scope.close(env);

View File

@@ -232,5 +232,16 @@
"NAPI_VERSION_EXPERIMENTAL=1",
],
},
{
"target_name": "test_finalizer_on_teardown",
"sources": ["test_finalizer_on_teardown.c"],
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
"libraries": [],
"dependencies": ["<!(node -p \"require('node-addon-api').gyp\")"],
"defines": [
"NAPI_DISABLE_CPP_EXCEPTIONS",
"NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1",
],
},
]
}

View File

@@ -0,0 +1,74 @@
#include <node_api.h>
#include <stdio.h>
#include <stdlib.h>
// This test reproduces issue #24552 where finalizers crash when run during
// env teardown (e.g., when a subprocess using NAPI modules terminates).
static int finalize_count = 0;
// Finalizer that tries to access the env
static void finalizer_that_uses_env(napi_env env, void* finalize_data, void* finalize_hint) {
finalize_count++;
// These operations would crash if env->globalObject() is null or VM is terminating
// The fix in Finalizer.run() checks NapiEnv__canRunFinalizer() before allowing this
napi_value global;
napi_status status = napi_get_global(env, &global);
if (status == napi_ok) {
printf("Finalizer %d: Successfully accessed global object\n", finalize_count);
} else {
printf("Finalizer %d: Could not access global (expected during teardown)\n", finalize_count);
}
// Free the allocated data
free(finalize_data);
}
static napi_value create_objects_with_finalizers(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, NULL, NULL);
int count = 10;
if (argc >= 1) {
napi_get_value_int32(env, args[0], &count);
}
napi_value result_array;
napi_create_array_with_length(env, count, &result_array);
for (int i = 0; i < count; i++) {
napi_value obj;
napi_create_object(env, &obj);
int* data = (int*)malloc(sizeof(int));
*data = i;
// Wrap with finalizer that will try to access the env
napi_wrap(env, obj, data, finalizer_that_uses_env, NULL, NULL);
napi_set_element(env, result_array, i, obj);
}
return result_array;
}
static napi_value get_finalize_count(napi_env env, napi_callback_info info) {
napi_value result;
napi_create_int32(env, finalize_count, &result);
return result;
}
static napi_value init(napi_env env, napi_value exports) {
napi_property_descriptor properties[] = {
{ "createObjects", 0, create_objects_with_finalizers, 0, 0, 0, napi_default, 0 },
{ "getFinalizeCount", 0, get_finalize_count, 0, 0, 0, napi_default, 0 }
};
napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)

View File

@@ -1,73 +0,0 @@
// https://github.com/oven-sh/bun/issues/24548
// Test that Bun can handle deeply nested if-else chains without stack overflow
import { expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";
test("deeply nested if-else chains should not cause stack overflow", async () => {
// Generate a deeply nested if-else chain (similar to Gleam's entity resolver)
const depth = 2500; // More than the 2124 in the original issue
let code = "export function test(x) {\n";
for (let i = 0; i < depth; i++) {
if (i > 0) code += " else ";
code += `if (x === ${i}) {\n`;
code += ` return ${i};\n`;
code += " }";
}
code += " else {\n return -1;\n }\n}\n";
using dir = tempDir("issue-24548", {
"deep-if-else.js": code,
"index.js": `import { test } from "./deep-if-else.js";\nconsole.log(test(42));`,
});
// Test that bun can run the file
await using proc = Bun.spawn({
cmd: [bunExe(), "index.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"42"`);
expect(exitCode).toBe(0);
});
test("bun build should handle deeply nested if-else chains", async () => {
const depth = 2500;
let code = "export function test(x) {\n";
for (let i = 0; i < depth; i++) {
if (i > 0) code += " else ";
code += `if (x === ${i}) {\n`;
code += ` return ${i};\n`;
code += " }";
}
code += " else {\n return -1;\n }\n}\n";
using dir = tempDir("issue-24548-build", {
"deep-if-else.js": code,
});
// Test that bun build can bundle the file
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "deep-if-else.js", "--outfile=bundle.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Verify the bundle was created
const bundlePath = `${dir}/bundle.js`;
expect(await Bun.file(bundlePath).exists()).toBe(true);
});

View File

@@ -0,0 +1,80 @@
// Test for https://github.com/oven-sh/bun/issues/24552
// Segmentation fault when NAPI finalizers run during env teardown
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { join } from "path";
test("NAPI finalizers should not crash during subprocess teardown", async () => {
const addonPath = join(__dirname, "../../napi/napi-app/build/Debug/test_finalizer_on_teardown.node");
// Spawn a subprocess that loads NAPI modules with finalizers
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
// Load the NAPI addon
const addon = require('${addonPath}');
// Create objects with finalizers
const objects = addon.createObjects(20);
// Let them be garbage collected
// Force GC if available
if (global.gc) {
global.gc();
}
// Log that we're exiting normally
console.log("Exiting normally");
// Process will exit here, triggering finalizers
// Before the fix, this would crash with segfault at address 0x0
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The process should exit successfully without crashing
expect(exitCode).toBe(0);
expect(stdout).toContain("Exiting normally");
// Should not have segfault or crash messages
expect(stderr).not.toContain("Segmentation fault");
expect(stderr).not.toContain("panic");
});
test("NAPI finalizers in loop scenario (like rspack)", async () => {
const addonPath = join(__dirname, "../../napi/napi-app/build/Debug/test_finalizer_on_teardown.node");
// Simulate running rspack/rsbuild for multiple configs in a loop
const configs = ["config1", "config2", "config3"];
for (const config of configs) {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const addon = require('${addonPath}');
const objects = addon.createObjects(10);
console.log('Processing ${config}');
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toContain(`Processing ${config}`);
expect(stderr).not.toContain("Segmentation fault");
expect(stderr).not.toContain("panic");
}
});