diff --git a/PORTING.md b/PORTING.md index 74f06f6..03b0324 100644 --- a/PORTING.md +++ b/PORTING.md @@ -60,7 +60,7 @@ Tests covering the engine-specific part of Node-API, defined in `js_native_api.h | `test_function` | Ported ✅ | Medium | | `test_general` | Not ported | Hard | | `test_handle_scope` | Ported ✅ | Easy | -| `test_instance_data` | Not ported | Medium | +| `test_instance_data` | Ported ✅ | Medium | | `test_new_target` | Ported ✅ | Easy | | `test_number` | Ported ✅ | Easy | | `test_object` | Not ported | Hard | diff --git a/implementors/node/child_process.d.ts b/implementors/node/child_process.d.ts index d63b290..73bbc68 100644 --- a/implementors/node/child_process.d.ts +++ b/implementors/node/child_process.d.ts @@ -1,6 +1,7 @@ export interface SpawnTestOptions { cwd?: string; stdout?: 'pipe' | 'inherit'; + worker?: boolean; } export interface SpawnTestResult { diff --git a/implementors/node/child_process.js b/implementors/node/child_process.js index d43e85b..641cc91 100644 --- a/implementors/node/child_process.js +++ b/implementors/node/child_process.js @@ -7,6 +7,10 @@ import { pathToFileURL } from 'node:url'; // into one --import keeps the child's command line short. const HARNESS_MODULE_PATH = path.join(import.meta.dirname, 'harness.js'); +// Entry point used for `worker: true`: it takes the test file as an argument +// and runs it on a worker thread of the child rather than on its main thread. +const WORKER_ENTRY_PATH = path.join(import.meta.dirname, 'worker-entry.js'); + // Exit codes that signify the runtime aborted (rather than exiting cleanly with // a non-zero status). On POSIX an abort surfaces as a fatal signal; on Windows // as one of a small set of exit codes. Mirrors Node.js's @@ -22,12 +26,19 @@ const ABORT_EXIT_CODES = [132, 133, 134, 139, 0xc0000409, 0xc000001d]; * * @param {string} filePath - Path to the JS/MJS file to execute. Resolved * against `options.cwd` if relative. - * @param {{ cwd?: string, stdout?: 'pipe' | 'inherit' }} [options] + * @param {{ cwd?: string, stdout?: 'pipe' | 'inherit', worker?: boolean }} [options] * - `cwd`: working directory for the child; defaults to `process.cwd()`. * - `stdout`: `'pipe'` (default) captures the child's stdout into the result; * `'inherit'` streams it straight to the terminal as the child runs (so the * output of a slow or hanging test is visible immediately) and leaves the * returned `stdout` empty. stderr is always captured for diagnostics. + * - `worker`: run the file in a worker thread of the child instead of on its + * main thread, giving it a secondary Node-API environment. The result still + * describes the host process, which is the point: native output from the + * worker's environment (a printf from a finalizer or an instance-data delete + * hook) goes to the process's stdout, not to the worker's JS-level stream. + * Gate such a test in the parent file: `skipTest()` inside a worker ends + * that thread with code 0, which the caller cannot tell from a pass. * @returns {Promise<{ status: number | null, aborted: boolean, stdout: string, stderr: string }>} */ export const spawnTest = (filePath, options = {}) => { @@ -35,11 +46,13 @@ export const spawnTest = (filePath, options = {}) => { // without it. // pathToFileURL handles Windows drive letters and backslashes; a bare // 'file://' + path is malformed there (e.g. file://C:\...). + // In worker mode the child runs worker-entry.js, which takes the test file as + // its argument and starts it on a worker thread. const args = [ '--expose-gc', '--import', pathToFileURL(HARNESS_MODULE_PATH).href, - filePath, + ...(options.worker ? [WORKER_ENTRY_PATH, filePath] : [filePath]), ]; // spawn (not spawnSync) so a hung child doesn't block the event loop and the diff --git a/implementors/node/features.js b/implementors/node/features.js index fec2137..6bd388f 100644 --- a/implementors/node/features.js +++ b/implementors/node/features.js @@ -24,6 +24,13 @@ globalThis.runtimeFeatures = { // and need not provide a spawnTest implementation. spawn: true, + // Node.js can run a test file in a worker thread, giving it a secondary + // Node-API environment, so spawnTest accepts `{ worker: true }`. Declared + // separately from `spawn` because the two capabilities are independent: a + // browser has workers but no subprocesses. Runtimes with neither set both to + // false. + worker: true, + // napi_create_dataview accepts a SharedArrayBuffer-backed buffer only since // Node.js v24.13.1 and v25.4.0 (nodejs/node#60473). It was not backported to // v20.x or v22.x, where such calls fail with "invalid argument". diff --git a/implementors/node/worker-entry.js b/implementors/node/worker-entry.js new file mode 100644 index 0000000..9fd41a9 --- /dev/null +++ b/implementors/node/worker-entry.js @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +// Entry point for spawnTest(file, { worker: true }): boots the test file in a +// worker thread, giving it a secondary Node-API environment inside this process. +// +// The worker inherits this process's execArgv, so the harness --import applies +// there too and the test file sees the same globals as on the main thread. +// +// No 'error' handler is installed on purpose: an unhandled worker error is +// re-thrown on this thread, so a failing test file still exits the process +// non-zero with its stack on stderr, exactly as it would on the main thread. +const [filePath] = process.argv.slice(2); + +// Worker resolves a relative specifier against the cwd, but a bare filename +// (no leading './') would read as a package specifier - so make it absolute. +new Worker(pathToFileURL(path.resolve(filePath))); diff --git a/tests/harness/spawn-test-worker.js b/tests/harness/spawn-test-worker.js new file mode 100644 index 0000000..06c1128 --- /dev/null +++ b/tests/harness/spawn-test-worker.js @@ -0,0 +1,50 @@ +'use strict'; + +// Running a test file in a secondary environment (a worker) is an optional +// harness capability, declared separately from `spawn` because a runtime can +// have one without the other: a browser has workers but no subprocesses. +// +// The Node harness delivers it as an option on spawnTest rather than as its own +// global, because observing a secondary environment's *native* output requires +// capturing the stdout of the process hosting it - a worker's own piped stdout +// carries JS-level writes only, so a printf from an addon bypasses it. +assert.strictEqual( + typeof runtimeFeatures.worker, + 'boolean', + 'Expected runtimeFeatures.worker to be a boolean', +); +if (!runtimeFeatures.spawn || !runtimeFeatures.worker) { + skipTest(); +} + +// That the file ran in a secondary environment rather than the main one is not +// observable from portable ECMAScript; proving that needs per-environment +// Node-API state (see the test_instance_data suite). What is pinned here is the +// plumbing: the file runs, harness globals reach it, and a failure inside the +// worker still surfaces as a non-zero status with its stderr intact instead of +// being swallowed by the host thread. +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { worker: true }); + assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`); + assert.strictEqual(result.aborted, false); + assert.strictEqual(result.stderr, ''); +} + +{ + const result = await spawnTest('spawn-test-fail-child.mjs', { worker: true }); + assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero'); + assert.strictEqual(result.aborted, false); + if (!result.stderr.includes('spawn-test-fail-marker')) { + throw new Error(`Expected stderr to include the failure marker, got:\n${result.stderr}`); + } +} + +// cwd still applies in worker mode: the test file is resolved against it, so an +// unresolvable filename must fail loudly rather than pass as an empty worker. +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { worker: true, 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}`); + } +} diff --git a/tests/js-native-api/test_instance_data/CMakeLists.txt b/tests/js-native-api/test_instance_data/CMakeLists.txt new file mode 100644 index 0000000..0077b9f --- /dev/null +++ b/tests/js-native-api/test_instance_data/CMakeLists.txt @@ -0,0 +1 @@ +add_node_api_cts_addon(test_instance_data test_instance_data.c) diff --git a/tests/js-native-api/test_instance_data/test.js b/tests/js-native-api/test_instance_data/test.js new file mode 100644 index 0000000..56ec4b2 --- /dev/null +++ b/tests/js-native-api/test_instance_data/test.js @@ -0,0 +1,16 @@ +'use strict'; + +const test_instance_data = loadAddon('test_instance_data'); + +// The addon seeds its instance data with 41, so seeing 42 here proves the +// binding read back the very data the addon set at init. +assert.strictEqual(test_instance_data.increment(), 42); + +// Instance data is reachable from a finalizer too: the JS callback invoked +// below is held in a reference stored in that data. +let finalizerCalled = false; +test_instance_data.objectWithFinalizer(mustCall(() => { + finalizerCalled = true; +})); + +await gcUntil('instance data finalizer', () => finalizerCalled); diff --git a/tests/js-native-api/test_instance_data/testInstanceDataTeardown.js b/tests/js-native-api/test_instance_data/testInstanceDataTeardown.js new file mode 100644 index 0000000..38cfa9f --- /dev/null +++ b/tests/js-native-api/test_instance_data/testInstanceDataTeardown.js @@ -0,0 +1,20 @@ +'use strict'; + +// The delete hook passed to napi_set_instance_data only runs when the +// environment goes away, so observing it takes a child process. This is the +// main-thread environment; testInstanceDataWorker.js covers a secondary one. +if (!runtimeFeatures.spawn) { + skipTest(); +} + +const result = await spawnTest('testInstanceDataTeardown_child.mjs'); + +assert.strictEqual( + result.status, + 0, + `child exited with status ${result.status}; stderr:\n${result.stderr}`, +); +assert.strictEqual( + result.stdout.split(/\r\n?|\n/)[0], + 'deleting addon data', +); diff --git a/tests/js-native-api/test_instance_data/testInstanceDataTeardown_child.mjs b/tests/js-native-api/test_instance_data/testInstanceDataTeardown_child.mjs new file mode 100644 index 0000000..b1d4d04 --- /dev/null +++ b/tests/js-native-api/test_instance_data/testInstanceDataTeardown_child.mjs @@ -0,0 +1,6 @@ +// Child of testInstanceDataTeardown.js: arms the addon to print from its +// instance-data delete hook, then exits so the hook runs at environment +// teardown and the parent can read the line off stdout. +const test_instance_data = loadAddon('test_instance_data'); + +test_instance_data.setPrintOnDelete(); diff --git a/tests/js-native-api/test_instance_data/testInstanceDataWorker.js b/tests/js-native-api/test_instance_data/testInstanceDataWorker.js new file mode 100644 index 0000000..799157a --- /dev/null +++ b/tests/js-native-api/test_instance_data/testInstanceDataWorker.js @@ -0,0 +1,22 @@ +'use strict'; + +// Upstream's worker variant: the same body in a secondary environment, which +// covers instance data being per-environment and the delete hook running when +// that environment is torn down while the process keeps going. A conformant +// runtime looks the same as the main-thread run, so the value here is in +// exercising the secondary-environment path at all. +if (!runtimeFeatures.spawn || !runtimeFeatures.worker) { + skipTest(); +} + +const result = await spawnTest('testInstanceDataWorker_child.mjs', { worker: true }); + +assert.strictEqual( + result.status, + 0, + `child exited with status ${result.status}; stderr:\n${result.stderr}`, +); +assert.strictEqual( + result.stdout.split(/\r\n?|\n/)[0], + 'deleting addon data', +); diff --git a/tests/js-native-api/test_instance_data/testInstanceDataWorker_child.mjs b/tests/js-native-api/test_instance_data/testInstanceDataWorker_child.mjs new file mode 100644 index 0000000..f698818 --- /dev/null +++ b/tests/js-native-api/test_instance_data/testInstanceDataWorker_child.mjs @@ -0,0 +1,20 @@ +// Child of testInstanceDataWorker.js, run in a worker: a secondary Node-API +// environment. Runs the same body as test.js does on the main thread, since +// that is what the secondary environment has to reproduce. + +const test_instance_data = loadAddon('test_instance_data'); + +// Instance data is per-environment, so it is seeded at 41 here as well rather +// than continuing from another environment's count. +assert.strictEqual(test_instance_data.increment(), 42); + +let finalizerCalled = false; +test_instance_data.objectWithFinalizer(mustCall(() => { + finalizerCalled = true; +})); + +await gcUntil('instance data finalizer in worker', () => finalizerCalled); + +// Arm the delete hook so it prints when this environment - not the whole +// process - is torn down. +test_instance_data.setPrintOnDelete(); diff --git a/tests/js-native-api/test_instance_data/test_instance_data.c b/tests/js-native-api/test_instance_data/test_instance_data.c new file mode 100644 index 0000000..5e33ddd --- /dev/null +++ b/tests/js-native-api/test_instance_data/test_instance_data.c @@ -0,0 +1,96 @@ +#include +#include +#include +#include "../common.h" +#include "../entry_point.h" + +typedef struct { + size_t value; + bool print; + napi_ref js_cb_ref; +} AddonData; + +static napi_value Increment(napi_env env, napi_callback_info info) { + AddonData* data; + napi_value result; + + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_CALL(env, napi_create_uint32(env, ++data->value, &result)); + + return result; +} + +static void DeleteAddonData(napi_env env, void* raw_data, void* hint) { + AddonData* data = raw_data; + if (data->print) { + printf("deleting addon data\n"); + } + if (data->js_cb_ref != NULL) { + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, data->js_cb_ref)); + } + free(data); +} + +static napi_value SetPrintOnDelete(napi_env env, napi_callback_info info) { + AddonData* data; + + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + data->print = true; + + return NULL; +} + +static void TestFinalizer(napi_env env, void* raw_data, void* hint) { + (void) raw_data; + (void) hint; + + AddonData* data; + NODE_API_CALL_RETURN_VOID(env, napi_get_instance_data(env, (void**)&data)); + napi_value js_cb, undefined; + NODE_API_CALL_RETURN_VOID(env, + napi_get_reference_value(env, data->js_cb_ref, &js_cb)); + NODE_API_CALL_RETURN_VOID(env, napi_get_undefined(env, &undefined)); + NODE_API_CALL_RETURN_VOID(env, + napi_call_function(env, undefined, js_cb, 0, NULL, NULL)); + NODE_API_CALL_RETURN_VOID(env, napi_delete_reference(env, data->js_cb_ref)); + data->js_cb_ref = NULL; +} + +static napi_value ObjectWithFinalizer(napi_env env, napi_callback_info info) { + AddonData* data; + napi_value result, js_cb; + size_t argc = 1; + + NODE_API_CALL(env, napi_get_instance_data(env, (void**)&data)); + NODE_API_ASSERT(env, data->js_cb_ref == NULL, "reference must be NULL"); + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, &js_cb, NULL, NULL)); + NODE_API_CALL(env, napi_create_object(env, &result)); + NODE_API_CALL(env, + napi_add_finalizer(env, result, NULL, TestFinalizer, NULL, NULL)); + NODE_API_CALL(env, napi_create_reference(env, js_cb, 1, &data->js_cb_ref)); + + return result; +} + +EXTERN_C_START +napi_value Init(napi_env env, napi_value exports) { + AddonData* data = malloc(sizeof(*data)); + data->value = 41; + data->print = false; + data->js_cb_ref = NULL; + + NODE_API_CALL(env, napi_set_instance_data(env, data, DeleteAddonData, NULL)); + + napi_property_descriptor props[] = { + DECLARE_NODE_API_PROPERTY("increment", Increment), + DECLARE_NODE_API_PROPERTY("setPrintOnDelete", SetPrintOnDelete), + DECLARE_NODE_API_PROPERTY("objectWithFinalizer", ObjectWithFinalizer), + }; + + NODE_API_CALL(env, + napi_define_properties( + env, exports, sizeof(props) / sizeof(*props), props)); + + return exports; +} +EXTERN_C_END