From d31e4aea24c749b5802d6c310445edf0ffc884b3 Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Thu, 16 Apr 2026 20:05:34 +0530 Subject: [PATCH 1/5] feat: add assert.match helper --- implementors/node/assert.js | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/implementors/node/assert.js b/implementors/node/assert.js index de9934c..c49a264 100644 --- a/implementors/node/assert.js +++ b/implementors/node/assert.js @@ -1,24 +1,22 @@ - import { ok, strictEqual, notStrictEqual, deepStrictEqual, throws, + match, } from "node:assert/strict"; -const assert = Object.assign( - (value, message) => ok(value, message), - { - ok: (value, message) => ok(value, message), - strictEqual: (actual, expected, message) => - strictEqual(actual, expected, message), - notStrictEqual: (actual, expected, message) => - notStrictEqual(actual, expected, message), - deepStrictEqual: (actual, expected, message) => - deepStrictEqual(actual, expected, message), - throws: (fn, error, message) => throws(fn, error, message), - }, -); +const assert = Object.assign((value, message) => ok(value, message), { + ok: (value, message) => ok(value, message), + strictEqual: (actual, expected, message) => + strictEqual(actual, expected, message), + notStrictEqual: (actual, expected, message) => + notStrictEqual(actual, expected, message), + deepStrictEqual: (actual, expected, message) => + deepStrictEqual(actual, expected, message), + throws: (fn, error, message) => throws(fn, error, message), + match: (string, regex, message) => match(string, regex, message), +}); Object.assign(globalThis, { assert }); From 2795471b4fe0b850dacd34f5ca31619e2ccd74ac Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Thu, 16 Apr 2026 20:51:49 +0530 Subject: [PATCH 2/5] feat: add spawnTest harness global --- eslint.config.js | 3 +- implementors/node/child_process.d.ts | 16 ++++ implementors/node/child_process.js | 49 +++++++++++ implementors/node/tests.ts | 110 +++++------------------- tests/harness/spawn-test-fail-child.mjs | 4 + tests/harness/spawn-test-gc-child.mjs | 7 ++ tests/harness/spawn-test-ok-child.mjs | 5 ++ tests/harness/spawn-test.js | 55 ++++++++++++ 8 files changed, 161 insertions(+), 88 deletions(-) create mode 100644 implementors/node/child_process.d.ts create mode 100644 implementors/node/child_process.js create mode 100644 tests/harness/spawn-test-fail-child.mjs create mode 100644 tests/harness/spawn-test-gc-child.mjs create mode 100644 tests/harness/spawn-test-ok-child.mjs create mode 100644 tests/harness/spawn-test.js diff --git a/eslint.config.js b/eslint.config.js index c5c7e38..0fa1bca 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,7 @@ export default defineConfig([ tseslint.configs.recommended, { files: [ - "tests/**/*.js", + "tests/**/*.{mjs,js}", ], languageOptions: { // Only allow ECMAScript built-ins and CTS harness globals. @@ -22,6 +22,7 @@ export default defineConfig([ mustCall: "readonly", mustNotCall: "readonly", gcUntil: "readonly", + spawnTest: "readonly", experimentalFeatures: "readonly", napiVersion: "readonly", skipTest: "readonly", diff --git a/implementors/node/child_process.d.ts b/implementors/node/child_process.d.ts new file mode 100644 index 0000000..51e5e01 --- /dev/null +++ b/implementors/node/child_process.d.ts @@ -0,0 +1,16 @@ +export interface SpawnTestOptions { + cwd?: string; + nodeFlags?: string[]; +} + +export interface SpawnTestResult { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +export function spawnTest( + filePath: string, + options?: SpawnTestOptions +): SpawnTestResult; diff --git a/implementors/node/child_process.js b/implementors/node/child_process.js new file mode 100644 index 0000000..2dd89b8 --- /dev/null +++ b/implementors/node/child_process.js @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const HARNESS_MODULE_PATHS = [ + "features.js", + "assert.js", + "load-addon.js", + "gc.js", + "must-call.js", + "child_process.js", +].map((file) => path.join(import.meta.dirname, file)); + +/** + * Runs a test file in a fresh Node.js subprocess with the CTS harness globals + * pre-loaded, and returns its exit status, signal, and captured output. + * + * @param {string} filePath - Path to the JS/MJS file to execute. Resolved + * against `options.cwd` if relative. + * @param {{ cwd?: string, nodeFlags?: string[] }} [options] + * - `cwd`: working directory for the child; defaults to `process.cwd()`. + * - `nodeFlags`: CLI flags passed to `node` before the `--import` chain + * (e.g., `["--expose-gc"]`). Defaults to no flags so each caller declares + * what its child needs. + * @returns {{ status: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }} + */ +export const spawnTest = (filePath, options = {}) => { + const args = [...(options.nodeFlags ?? [])]; + for (const modulePath of HARNESS_MODULE_PATHS) { + args.push("--import", "file://" + modulePath); + } + args.push(filePath); + + const result = spawnSync(process.execPath, args, { + cwd: options.cwd ?? process.cwd(), + maxBuffer: 100 * 1024 * 1024, + }); + if (result.error) throw result.error; + return { + status: result.status, + signal: result.signal, + stderr: result.stderr?.toString() ?? "", + stdout: result.stdout?.toString() ?? "", + }; +}; + +// This module is loaded in both contexts: imported by the parent test runner +// (tests.ts) and `--import`ed into every spawned child. The side effect below +// installs `spawnTest` on the child's globalThis so tests can call it directly. +Object.assign(globalThis, { spawnTest }); diff --git a/implementors/node/tests.ts b/implementors/node/tests.ts index d4f8b2c..f9b7dee 100644 --- a/implementors/node/tests.ts +++ b/implementors/node/tests.ts @@ -1,8 +1,9 @@ import assert from "node:assert"; -import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { spawnTest } from "./child_process.js"; + assert( typeof import.meta.dirname === "string", "Expecting a recent Node.js runtime API version" @@ -10,36 +11,6 @@ assert( const ROOT_PATH = path.resolve(import.meta.dirname, "..", ".."); const TESTS_ROOT_PATH = path.join(ROOT_PATH, "tests"); -const FEATURES_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "features.js" -); -const ASSERT_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "assert.js" -); -const LOAD_ADDON_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "load-addon.js" -); -const GC_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "gc.js" -); -const MUST_CALL_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "must-call.js" -); export function listDirectoryEntries(dir: string) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -60,61 +31,26 @@ export function listDirectoryEntries(dir: string) { return { directories, files }; } -export function runFileInSubprocess( - cwd: string, - filePath: string -): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [ - // Using file scheme prefix when to enable imports on Windows - "--expose-gc", - "--import", - "file://" + FEATURES_MODULE_PATH, - "--import", - "file://" + ASSERT_MODULE_PATH, - "--import", - "file://" + LOAD_ADDON_MODULE_PATH, - "--import", - "file://" + GC_MODULE_PATH, - "--import", - "file://" + MUST_CALL_MODULE_PATH, - filePath, - ], - { cwd } - ); - - let stderrOutput = ""; - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderrOutput += chunk; - }); - - child.stdout.pipe(process.stdout); - - child.on("error", reject); - - child.on("close", (code, signal) => { - if (code === 0) { - resolve(); - return; - } - - const reason = - code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`; - const trimmedStderr = stderrOutput.trim(); - const stderrSuffix = trimmedStderr - ? `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` - : ""; - reject( - new Error( - `Test file ${path.relative( - TESTS_ROOT_PATH, - filePath - )} failed (${reason})${stderrSuffix}` - ) - ); - }); +export function runFileInSubprocess(cwd: string, filePath: string): void { + const { status, signal, stdout, stderr } = spawnTest(filePath, { + cwd, + nodeFlags: ["--expose-gc"], }); + + if (stdout) process.stdout.write(stdout); + + if (status === 0) return; + + const reason = + status !== null ? `exit code ${status}` : `signal ${signal ?? "unknown"}`; + const trimmedStderr = stderr.trim(); + const stderrSuffix = trimmedStderr + ? `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` + : ""; + throw new Error( + `Test file ${path.relative( + TESTS_ROOT_PATH, + path.join(cwd, filePath) + )} failed (${reason})${stderrSuffix}` + ); } diff --git a/tests/harness/spawn-test-fail-child.mjs b/tests/harness/spawn-test-fail-child.mjs new file mode 100644 index 0000000..b18caa2 --- /dev/null +++ b/tests/harness/spawn-test-fail-child.mjs @@ -0,0 +1,4 @@ +// Spawned by spawn-test.js. Throws an error with a recognizable marker so the +// parent can assert that stderr was captured and that the non-zero exit status +// is surfaced. +throw new Error('spawn-test-fail-marker'); diff --git a/tests/harness/spawn-test-gc-child.mjs b/tests/harness/spawn-test-gc-child.mjs new file mode 100644 index 0000000..e7de301 --- /dev/null +++ b/tests/harness/spawn-test-gc-child.mjs @@ -0,0 +1,7 @@ +// Spawned by spawn-test.js to verify that custom nodeFlags reach the child +// process. With --expose-gc, Node installs `gc` on globalThis; without the +// flag, it is undefined. +// eslint-disable-next-line no-restricted-syntax +if (typeof globalThis.gc !== 'function') { + throw new Error('Expected globalThis.gc to be a function when --expose-gc is forwarded'); +} diff --git a/tests/harness/spawn-test-ok-child.mjs b/tests/harness/spawn-test-ok-child.mjs new file mode 100644 index 0000000..b29ee8c --- /dev/null +++ b/tests/harness/spawn-test-ok-child.mjs @@ -0,0 +1,5 @@ +// Spawned by spawn-test.js. Confirms harness globals are injected into the +// child process by checking `assert` exists, then exits 0. +if (typeof assert !== 'function') { + throw new Error('Expected `assert` to be a CTS harness global inside spawned children'); +} diff --git a/tests/harness/spawn-test.js b/tests/harness/spawn-test.js new file mode 100644 index 0000000..fe7be44 --- /dev/null +++ b/tests/harness/spawn-test.js @@ -0,0 +1,55 @@ +// spawnTest is a function +if (typeof spawnTest !== 'function') { + throw new Error('Expected a global spawnTest function'); +} + +// Successful child: exits 0, stderr empty, and harness globals are available +// inside the child (the child checks `typeof assert === 'function'` itself). +{ + const result = spawnTest('spawn-test-ok-child.mjs'); + assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`); + assert.strictEqual(result.signal, null); + assert.strictEqual(result.stderr, ''); +} + +// Failing child: non-zero status and stderr contains the thrown marker. +{ + const result = spawnTest('spawn-test-fail-child.mjs'); + assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero'); + if (!result.stderr.includes('spawn-test-fail-marker')) { + throw new Error(`Expected stderr to include the failure marker, got:\n${result.stderr}`); + } +} + +// Result shape: all four fields are present. +{ + const result = spawnTest('spawn-test-ok-child.mjs'); + for (const key of ['status', 'signal', 'stdout', 'stderr']) { + if (!(key in result)) { + throw new Error(`Expected spawnTest result to have "${key}" field`); + } + } + assert.strictEqual(typeof result.stdout, 'string'); + assert.strictEqual(typeof result.stderr, 'string'); +} + +// nodeFlags are forwarded to the child: without --expose-gc the gc child +// exits non-zero; passing it via nodeFlags makes the child exit 0. +{ + const withoutFlag = spawnTest('spawn-test-gc-child.mjs'); + assert.notStrictEqual(withoutFlag.status, 0, 'gc child should fail when --expose-gc is not forwarded'); + + const withFlag = spawnTest('spawn-test-gc-child.mjs', { nodeFlags: ['--expose-gc'] }); + assert.strictEqual(withFlag.status, 0, `gc child exited with status ${withFlag.status}; stderr:\n${withFlag.stderr}`); +} + +// cwd is forwarded to the child: running from the parent of tests/harness +// makes the bare child filename unresolvable. The child's stderr must name +// the specific file Node tried to load, proving cwd actually shifted. +{ + const result = spawnTest('spawn-test-ok-child.mjs', { cwd: '..' }); + assert.notStrictEqual(result.status, 0, 'expected cwd ".." to make the child filename unresolvable'); + if (!result.stderr.includes('spawn-test-ok-child.mjs')) { + throw new Error(`Expected stderr to reference the unresolved child filename, got:\n${result.stderr}`); + } +} From 8fbf318f6cda67ef1c4c60acbd82b510cf3b13a3 Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Fri, 17 Apr 2026 13:55:42 +0530 Subject: [PATCH 3/5] feat: port test_finalizer to CTS --- PORTING.md | 2 +- .../test_finalizer/CMakeLists.txt | 1 + tests/js-native-api/test_finalizer/test.js | 45 ++++++ .../test_finalizer/testFatalFinalize.js | 20 +++ .../testFatalFinalize_child.mjs | 16 ++ .../test_finalizer/test_finalizer.c | 148 ++++++++++++++++++ 6 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/js-native-api/test_finalizer/CMakeLists.txt create mode 100644 tests/js-native-api/test_finalizer/test.js create mode 100644 tests/js-native-api/test_finalizer/testFatalFinalize.js create mode 100644 tests/js-native-api/test_finalizer/testFatalFinalize_child.mjs create mode 100644 tests/js-native-api/test_finalizer/test_finalizer.c diff --git a/PORTING.md b/PORTING.md index 4faac6b..bb0f02f 100644 --- a/PORTING.md +++ b/PORTING.md @@ -56,7 +56,7 @@ Tests covering the engine-specific part of Node-API, defined in `js_native_api.h | `test_date` | Ported ✅ | Easy | | `test_error` | Ported ✅ | Medium | | `test_exception` | Not ported | Medium | -| `test_finalizer` | Not ported | Medium | +| `test_finalizer` | Ported ✅ | Medium | | `test_function` | Not ported | Medium | | `test_general` | Not ported | Hard | | `test_handle_scope` | Ported ✅ | Easy | diff --git a/tests/js-native-api/test_finalizer/CMakeLists.txt b/tests/js-native-api/test_finalizer/CMakeLists.txt new file mode 100644 index 0000000..aaee9cc --- /dev/null +++ b/tests/js-native-api/test_finalizer/CMakeLists.txt @@ -0,0 +1 @@ +add_node_api_cts_experimental_addon(test_finalizer SOURCES test_finalizer.c) diff --git a/tests/js-native-api/test_finalizer/test.js b/tests/js-native-api/test_finalizer/test.js new file mode 100644 index 0000000..32b864d --- /dev/null +++ b/tests/js-native-api/test_finalizer/test.js @@ -0,0 +1,45 @@ +"use strict"; + +// The addon relies on node_api_post_finalizer and node_api_basic_env, both +// experimental. Skip when the implementor does not expose them. +if (!experimentalFeatures.postFinalizer) { + skipTest(); +} + +const test_finalizer = loadAddon("test_finalizer"); + +// Pure finalizers are documented to run in the current JS loop tick, but the +// harness only exposes the async gcUntil helper, so we drive GC through it +// here as well. This relaxes the original "single-tick" assertion but still +// verifies that the finalizer fires. +{ + (() => { + const obj = {}; + test_finalizer.addFinalizer(obj); + })(); + + await gcUntil( + "pure finalizer", + () => test_finalizer.getFinalizerCallCount() === 1, + ); + assert.strictEqual(test_finalizer.getFinalizerCallCount(), 1); +} + +// Finalizers that call back into JS cannot run in the same tick as the GC +// pass, so they are scheduled via node_api_post_finalizer and observed +// asynchronously. +{ + let js_is_called = false; + (() => { + const obj = {}; + test_finalizer.addFinalizerWithJS(obj, () => { + js_is_called = true; + }); + })(); + + await gcUntil( + "JS-calling finalizer", + () => test_finalizer.getFinalizerCallCount() === 2, + ); + assert.strictEqual(js_is_called, true); +} diff --git a/tests/js-native-api/test_finalizer/testFatalFinalize.js b/tests/js-native-api/test_finalizer/testFatalFinalize.js new file mode 100644 index 0000000..832cdd2 --- /dev/null +++ b/tests/js-native-api/test_finalizer/testFatalFinalize.js @@ -0,0 +1,20 @@ +"use strict"; + +// The child registers a finalizer that calls a JS-touching Node-API from a +// synchronous finalizer context, which Node-API treats as a fatal error. The +// process should abort and print a specific diagnostic to stderr. +if (!experimentalFeatures.postFinalizer) { + skipTest(); +} + +const result = spawnTest("testFatalFinalize_child.mjs", { + nodeFlags: ["--expose-gc"], +}); + +assert.notStrictEqual(result.status, 0); +const expected = /Finalizer is calling a function that may affect GC state/; +if (!expected.test(result.stderr)) { + throw new Error( + `Expected stderr to match ${expected}, got:\n${result.stderr}`, + ); +} diff --git a/tests/js-native-api/test_finalizer/testFatalFinalize_child.mjs b/tests/js-native-api/test_finalizer/testFatalFinalize_child.mjs new file mode 100644 index 0000000..dc7b9c6 --- /dev/null +++ b/tests/js-native-api/test_finalizer/testFatalFinalize_child.mjs @@ -0,0 +1,16 @@ +// Spawned as a child by testFatalFinalize.js. Installs a finalizer that +// illegally calls a JS-touching Node-API from a basic finalizer context; the +// expected outcome is that Node-API aborts the process with a specific +// diagnostic captured by the parent. +const test_finalizer = loadAddon("test_finalizer"); + +(() => { + const obj = {}; + test_finalizer.addFinalizerFailOnJS(obj); +})(); + +// Drive GC until the finalizer fires. Node will abort from inside the +// finalizer, so gcUntil will not return; the "guard" limit only matters if +// the illegal call is somehow accepted. +let gcCount = 10; +await gcUntil("fatal finalize", () => --gcCount <= 0); diff --git a/tests/js-native-api/test_finalizer/test_finalizer.c b/tests/js-native-api/test_finalizer/test_finalizer.c new file mode 100644 index 0000000..721ca12 --- /dev/null +++ b/tests/js-native-api/test_finalizer/test_finalizer.c @@ -0,0 +1,148 @@ +#include +#include +#include +#include +#include +#include "../common.h" +#include "../entry_point.h" + +typedef struct { + int32_t finalize_count; + napi_ref js_func; +} FinalizerData; + +static void finalizerOnlyCallback(node_api_basic_env env, + void* finalize_data, + void* finalize_hint) { + FinalizerData* data = (FinalizerData*)finalize_data; + int32_t count = ++data->finalize_count; + + // It is safe to access instance data + NODE_API_BASIC_CALL_RETURN_VOID(env, + napi_get_instance_data(env, (void**)&data)); + NODE_API_BASIC_ASSERT_RETURN_VOID(count = data->finalize_count, + "Expected to be the same FinalizerData"); +} + +static void finalizerCallingJSCallback(napi_env env, + void* finalize_data, + void* finalize_hint) { + napi_value js_func, undefined; + FinalizerData* data = (FinalizerData*)finalize_data; + NODE_API_CALL_RETURN_VOID( + env, napi_get_reference_value(env, data->js_func, &js_func)); + NODE_API_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined)); + NODE_API_CALL_RETURN_VOID( + env, napi_call_function(env, undefined, js_func, 0, NULL, NULL)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, data->js_func)); + data->js_func = NULL; + ++data->finalize_count; +} + +// Schedule async finalizer to run JavaScript-touching code. +static void finalizerWithJSCallback(node_api_basic_env env, + void* finalize_data, + void* finalize_hint) { + NODE_API_BASIC_CALL_RETURN_VOID( + env, + node_api_post_finalizer( + env, finalizerCallingJSCallback, finalize_data, finalize_hint)); +} + +static void finalizerWithFailedJSCallback(node_api_basic_env basic_env, + void* finalize_data, + void* finalize_hint) { + // Intentionally cast to a napi_env to test the fatal failure. + napi_env env = (napi_env)basic_env; + napi_value obj; + FinalizerData* data = (FinalizerData*)finalize_data; + ++data->finalize_count; + NODE_API_CALL_RETURN_VOID(env, napi_create_object(env, &obj)); +} + +static napi_value addFinalizer(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1] = {0}; + FinalizerData* data; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_CALL(env, + napi_add_finalizer( + env, argv[0], data, finalizerOnlyCallback, NULL, NULL)); + return NULL; +} + +// This finalizer is going to call JavaScript from finalizer and succeed. +static napi_value addFinalizerWithJS(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2] = {0}; + napi_valuetype arg_type; + FinalizerData* data; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_CALL(env, napi_typeof(env, argv[1], &arg_type)); + NODE_API_ASSERT( + env, arg_type == napi_function, "Expected function as the second arg"); + NODE_API_CALL(env, napi_create_reference(env, argv[1], 1, &data->js_func)); + NODE_API_CALL(env, + napi_add_finalizer( + env, argv[0], data, finalizerWithJSCallback, NULL, NULL)); + return NULL; +} + +// This finalizer is going to call JavaScript from finalizer and fail. +static napi_value addFinalizerFailOnJS(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1] = {0}; + FinalizerData* data; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_CALL( + env, + napi_add_finalizer( + env, argv[0], data, finalizerWithFailedJSCallback, NULL, NULL)); + return NULL; +} + +static napi_value getFinalizerCallCount(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + FinalizerData* data; + napi_value result; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_CALL(env, napi_create_int32(env, data->finalize_count, &result)); + return result; +} + +static void finalizeData(napi_env env, void* data, void* hint) { + free(data); +} + +EXTERN_C_START +napi_value Init(napi_env env, napi_value exports) { + FinalizerData* data = (FinalizerData*)malloc(sizeof(FinalizerData)); + NODE_API_ASSERT(env, data != NULL, "Failed to allocate memory"); + memset(data, 0, sizeof(FinalizerData)); + NODE_API_CALL(env, napi_set_instance_data(env, data, finalizeData, NULL)); + napi_property_descriptor descriptors[] = { + DECLARE_NODE_API_PROPERTY("addFinalizer", addFinalizer), + DECLARE_NODE_API_PROPERTY("addFinalizerWithJS", addFinalizerWithJS), + DECLARE_NODE_API_PROPERTY("addFinalizerFailOnJS", addFinalizerFailOnJS), + DECLARE_NODE_API_PROPERTY("getFinalizerCallCount", + getFinalizerCallCount)}; + + NODE_API_CALL( + env, + napi_define_properties(env, + exports, + sizeof(descriptors) / sizeof(*descriptors), + descriptors)); + + return exports; +} +EXTERN_C_END From 0f8d8ecf61dc4f18cbfb510caf05eb8abddf268d Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Fri, 17 Apr 2026 14:04:30 +0530 Subject: [PATCH 4/5] test: tighten fatal-finalize abort check and use assert.match --- .../test_finalizer/testFatalFinalize.js | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/js-native-api/test_finalizer/testFatalFinalize.js b/tests/js-native-api/test_finalizer/testFatalFinalize.js index 832cdd2..f6330bc 100644 --- a/tests/js-native-api/test_finalizer/testFatalFinalize.js +++ b/tests/js-native-api/test_finalizer/testFatalFinalize.js @@ -11,10 +11,17 @@ const result = spawnTest("testFatalFinalize_child.mjs", { nodeFlags: ["--expose-gc"], }); -assert.notStrictEqual(result.status, 0); -const expected = /Finalizer is calling a function that may affect GC state/; -if (!expected.test(result.stderr)) { - throw new Error( - `Expected stderr to match ${expected}, got:\n${result.stderr}`, - ); -} +// Node-API aborts the process from inside the finalizer. On POSIX this +// surfaces as a fatal signal; on Windows as one of a small set of exit +// codes. Mirrors Node.js's `common.nodeProcessAborted`. +const abortExitCodes = [132, 133, 134, 139, 0xc0000409, 0xc000001d]; +const aborted = + result.signal !== null || abortExitCodes.includes(result.status); +assert.ok( + aborted, + `Expected child to abort, got status=${result.status} signal=${result.signal}`, +); +assert.match( + result.stderr, + /Finalizer is calling a function that may affect GC state/, +); From a0fb4d077ab49046e60cfeb584421c72b5ed9bfb Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Fri, 17 Apr 2026 14:10:18 +0530 Subject: [PATCH 5/5] docs: clarify abort-detection comment in testFatalFinalize.js --- tests/js-native-api/test_finalizer/testFatalFinalize.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/js-native-api/test_finalizer/testFatalFinalize.js b/tests/js-native-api/test_finalizer/testFatalFinalize.js index f6330bc..e8f44bf 100644 --- a/tests/js-native-api/test_finalizer/testFatalFinalize.js +++ b/tests/js-native-api/test_finalizer/testFatalFinalize.js @@ -13,7 +13,10 @@ const result = spawnTest("testFatalFinalize_child.mjs", { // Node-API aborts the process from inside the finalizer. On POSIX this // surfaces as a fatal signal; on Windows as one of a small set of exit -// codes. Mirrors Node.js's `common.nodeProcessAborted`. +// codes. Mirrors Node.js's `common.nodeProcessAborted`. The POSIX 128+N +// codes and the Windows NTSTATUS codes share one list because the ranges +// don't overlap. Any non-null signal counts — the stderr match below is +// the real specificity guard. const abortExitCodes = [132, 133, 134, 139, 0xc0000409, 0xc000001d]; const aborted = result.signal !== null || abortExitCodes.includes(result.status);