From fc17c54aec303ca8982b8c6d0c5e45c44748f076 Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Mon, 29 Jun 2026 18:05:17 +0530 Subject: [PATCH 1/3] feat: port test_exception to CTS --- PORTING.md | 2 +- .../test_exception/CMakeLists.txt | 1 + tests/js-native-api/test_exception/test.js | 146 ++++++++++++++++++ .../test_exception/testFinalizerException.js | 9 ++ .../testFinalizerException_child.mjs | 12 ++ .../test_exception/test_exception.c | 116 ++++++++++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 tests/js-native-api/test_exception/CMakeLists.txt create mode 100644 tests/js-native-api/test_exception/test.js create mode 100644 tests/js-native-api/test_exception/testFinalizerException.js create mode 100644 tests/js-native-api/test_exception/testFinalizerException_child.mjs create mode 100644 tests/js-native-api/test_exception/test_exception.c diff --git a/PORTING.md b/PORTING.md index 5a63cf6..cd9f2a2 100644 --- a/PORTING.md +++ b/PORTING.md @@ -55,7 +55,7 @@ Tests covering the engine-specific part of Node-API, defined in `js_native_api.h | `test_dataview` | Ported ✅ | Medium | | `test_date` | Ported ✅ | Easy | | `test_error` | Ported ✅ | Medium | -| `test_exception` | Not ported | Medium | +| `test_exception` | Ported ✅ | Medium | | `test_finalizer` | Not ported | Medium | | `test_function` | Ported ✅ | Medium | | `test_general` | Not ported | Hard | diff --git a/tests/js-native-api/test_exception/CMakeLists.txt b/tests/js-native-api/test_exception/CMakeLists.txt new file mode 100644 index 0000000..5ecd96a --- /dev/null +++ b/tests/js-native-api/test_exception/CMakeLists.txt @@ -0,0 +1 @@ +add_node_api_cts_addon(test_exception test_exception.c) diff --git a/tests/js-native-api/test_exception/test.js b/tests/js-native-api/test_exception/test.js new file mode 100644 index 0000000..2fc728e --- /dev/null +++ b/tests/js-native-api/test_exception/test.js @@ -0,0 +1,146 @@ +'use strict'; +// Flags: --expose-gc + +const theError = new Error('Some error'); + +// The test module throws an error during Init, but in order for its exports to +// not be lost, it attaches them to the error's "bindings" property. This way, +// we can make sure that exceptions thrown during the module initialization +// phase are propagated through require() into JavaScript. +// https://github.com/nodejs/node/issues/19437 +const test_exception = (function() { + let resultingException; + try { + loadAddon('test_exception'); + } catch (anException) { + resultingException = anException; + } + assert.strictEqual(resultingException.message, 'Error during Init'); + return resultingException.binding; +})(); + +{ + const throwTheError = () => { + throw theError; + }; + + // Test that the native side successfully captures the exception + let returnedError = test_exception.returnException(throwTheError); + assert.strictEqual(returnedError, theError); + + // Test that the native side passes the exception through + assert.throws( + () => { + test_exception.allowException(throwTheError); + }, + (err) => err === theError, + ); + + // Test that the exception thrown above was marked as pending + // before it was handled on the JS side + const exception_pending = test_exception.wasPending(); + assert.strictEqual( + exception_pending, + true, + 'Exception not pending as expected,' + + ` .wasPending() returned ${exception_pending}`, + ); + + // Test that the native side does not capture a non-existing exception + returnedError = test_exception.returnException(mustCall()); + assert.strictEqual( + returnedError, + undefined, + 'Returned error should be undefined when no exception is' + + ` thrown, but ${returnedError} was passed`, + ); +} + +{ + const throwTheError = class { + constructor() { + throw theError; + } + }; + + // Test that the native side successfully captures the exception + let returnedError = test_exception.constructReturnException(throwTheError); + assert.strictEqual(returnedError, theError); + + // Test that the native side passes the exception through + assert.throws( + () => { + test_exception.constructAllowException(throwTheError); + }, + (err) => err === theError, + ); + + // Test that the exception thrown above was marked as pending + // before it was handled on the JS side + const exception_pending = test_exception.wasPending(); + assert.strictEqual( + exception_pending, + true, + 'Exception not pending as expected,' + + ` .wasPending() returned ${exception_pending}`, + ); + + // Test that the native side does not capture a non-existing exception + returnedError = test_exception.constructReturnException(mustCall()); + assert.strictEqual( + returnedError, + undefined, + 'Returned error should be undefined when no exception is' + + ` thrown, but ${returnedError} was passed`, + ); +} + +{ + // Test that no exception appears that was not thrown by us + let caughtError; + try { + test_exception.allowException(mustCall()); + } catch (anError) { + caughtError = anError; + } + assert.strictEqual( + caughtError, + undefined, + 'No exception originated on the native side, but' + + ` ${caughtError} was passed`, + ); + + // Test that the exception state remains clear when no exception is thrown + const exception_pending = test_exception.wasPending(); + assert.strictEqual( + exception_pending, + false, + 'Exception state did not remain clear as expected,' + + ` .wasPending() returned ${exception_pending}`, + ); +} + +{ + // Test that no exception appears that was not thrown by us + let caughtError; + try { + test_exception.constructAllowException(mustCall()); + } catch (anError) { + caughtError = anError; + } + assert.strictEqual( + caughtError, + undefined, + 'No exception originated on the native side, but' + + ` ${caughtError} was passed`, + ); + + // Test that the exception state remains clear when no exception is thrown + const exception_pending = test_exception.wasPending(); + assert.strictEqual( + exception_pending, + false, + 'Exception state did not remain clear as expected,' + + ` .wasPending() returned ${exception_pending}`, + ); +} diff --git a/tests/js-native-api/test_exception/testFinalizerException.js b/tests/js-native-api/test_exception/testFinalizerException.js new file mode 100644 index 0000000..5bfe43c --- /dev/null +++ b/tests/js-native-api/test_exception/testFinalizerException.js @@ -0,0 +1,9 @@ +// An exception thrown from a C finalizer during GC surfaces as an uncaught +// exception on stderr; it runs in a spawned child so the crash doesn't take +// down the test runner. Skipped where the runtime can't spawn (WASM, RN). +if (!runtimeFeatures.spawn) { + skipTest(); +} + +const result = await spawnTest('testFinalizerException_child.mjs'); +assert.match(result.stderr, /Error during Finalize/); diff --git a/tests/js-native-api/test_exception/testFinalizerException_child.mjs b/tests/js-native-api/test_exception/testFinalizerException_child.mjs new file mode 100644 index 0000000..bf03347 --- /dev/null +++ b/tests/js-native-api/test_exception/testFinalizerException_child.mjs @@ -0,0 +1,12 @@ +// Child of testFinalizerException.js. The addon throws during Init with its +// exports on `.binding`; createExternal installs a finalizer that throws. +try { + loadAddon('test_exception'); +} catch (anException) { + anException.binding.createExternal(); +} + +// GC until the finalizer fires; its throw crashes the process, printing to +// stderr for the parent to match. The counter just bounds the loop. +let gcCount = 10; +await gcUntil('test', () => --gcCount <= 0); diff --git a/tests/js-native-api/test_exception/test_exception.c b/tests/js-native-api/test_exception/test_exception.c new file mode 100644 index 0000000..84b9919 --- /dev/null +++ b/tests/js-native-api/test_exception/test_exception.c @@ -0,0 +1,116 @@ +#include +#include "../common.h" +#include "../entry_point.h" + +static bool exceptionWasPending = false; +static int num = 0x23432; + +static napi_value returnException(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + napi_value global; + NODE_API_CALL(env, napi_get_global(env, &global)); + + napi_value result; + napi_status status = napi_call_function(env, global, args[0], 0, 0, &result); + if (status == napi_pending_exception) { + napi_value ex; + NODE_API_CALL(env, napi_get_and_clear_last_exception(env, &ex)); + return ex; + } + + return NULL; +} + +static napi_value constructReturnException(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + napi_value result; + napi_status status = napi_new_instance(env, args[0], 0, 0, &result); + if (status == napi_pending_exception) { + napi_value ex; + NODE_API_CALL(env, napi_get_and_clear_last_exception(env, &ex)); + return ex; + } + + return NULL; +} + +static napi_value allowException(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + napi_value global; + NODE_API_CALL(env, napi_get_global(env, &global)); + + napi_value result; + napi_call_function(env, global, args[0], 0, 0, &result); + // Ignore status and check napi_is_exception_pending() instead. + + NODE_API_CALL(env, napi_is_exception_pending(env, &exceptionWasPending)); + return NULL; +} + +static napi_value constructAllowException(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + + napi_value result; + napi_new_instance(env, args[0], 0, 0, &result); + // Ignore status and check napi_is_exception_pending() instead. + + NODE_API_CALL(env, napi_is_exception_pending(env, &exceptionWasPending)); + return NULL; +} + +static napi_value wasPending(napi_env env, napi_callback_info info) { + napi_value result; + NODE_API_CALL(env, napi_get_boolean(env, exceptionWasPending, &result)); + + return result; +} + +static void finalizer(napi_env env, void *data, void *hint) { + NODE_API_CALL_RETURN_VOID(env, + napi_throw_error(env, NULL, "Error during Finalize")); +} + +static napi_value createExternal(napi_env env, napi_callback_info info) { + napi_value external; + + NODE_API_CALL(env, + napi_create_external(env, &num, finalizer, NULL, &external)); + + return external; +} + +EXTERN_C_START +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor descriptors[] = { + DECLARE_NODE_API_PROPERTY("returnException", returnException), + DECLARE_NODE_API_PROPERTY("allowException", allowException), + DECLARE_NODE_API_PROPERTY("constructReturnException", constructReturnException), + DECLARE_NODE_API_PROPERTY("constructAllowException", constructAllowException), + DECLARE_NODE_API_PROPERTY("wasPending", wasPending), + DECLARE_NODE_API_PROPERTY("createExternal", createExternal), + }; + NODE_API_CALL(env, napi_define_properties( + env, exports, sizeof(descriptors) / sizeof(*descriptors), descriptors)); + + napi_value error, code, message; + NODE_API_CALL(env, napi_create_string_utf8(env, "Error during Init", + NAPI_AUTO_LENGTH, &message)); + NODE_API_CALL(env, napi_create_string_utf8(env, "", NAPI_AUTO_LENGTH, &code)); + NODE_API_CALL(env, napi_create_error(env, code, message, &error)); + NODE_API_CALL(env, napi_set_named_property(env, error, "binding", exports)); + NODE_API_CALL(env, napi_throw(env, error)); + + return exports; +} +EXTERN_C_END From e9f848c7b7ffa2d2f0e43457416b0a7b61b742b8 Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Sat, 18 Jul 2026 18:34:37 +0530 Subject: [PATCH 2/3] Update tests/js-native-api/test_exception/test.js Co-authored-by: Chengzhong Wu --- tests/js-native-api/test_exception/test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/js-native-api/test_exception/test.js b/tests/js-native-api/test_exception/test.js index 2fc728e..f2a0f40 100644 --- a/tests/js-native-api/test_exception/test.js +++ b/tests/js-native-api/test_exception/test.js @@ -1,5 +1,4 @@ 'use strict'; -// Flags: --expose-gc const theError = new Error('Some error'); From 7ee5921cc88b14b9f7818b8182c45819961c2dbe Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Sat, 18 Jul 2026 18:48:14 +0530 Subject: [PATCH 3/3] test: run testFinalizerException in-process via onUncaughtException --- .../test_exception/testFinalizerException.js | 33 ++++++++++++++----- .../testFinalizerException_child.mjs | 12 ------- 2 files changed, 25 insertions(+), 20 deletions(-) delete mode 100644 tests/js-native-api/test_exception/testFinalizerException_child.mjs diff --git a/tests/js-native-api/test_exception/testFinalizerException.js b/tests/js-native-api/test_exception/testFinalizerException.js index 5bfe43c..7484630 100644 --- a/tests/js-native-api/test_exception/testFinalizerException.js +++ b/tests/js-native-api/test_exception/testFinalizerException.js @@ -1,9 +1,26 @@ -// An exception thrown from a C finalizer during GC surfaces as an uncaught -// exception on stderr; it runs in a spawned child so the crash doesn't take -// down the test runner. Skipped where the runtime can't spawn (WASM, RN). -if (!runtimeFeatures.spawn) { - skipTest(); -} +// A C finalizer that throws during GC surfaces as an uncaught exception. This +// mirrors test_reference/test_finalizer.js, except the addon here throws from +// Init; its exports are still reachable via the error's `.binding`, so a +// finalizer can be installed even though initialization failed. +const binding = (function() { + let resultingException; + try { + loadAddon('test_exception'); + } catch (anException) { + resultingException = anException; + } + assert.strictEqual(resultingException.message, 'Error during Init'); + return resultingException.binding; +})(); -const result = await spawnTest('testFinalizerException_child.mjs'); -assert.match(result.stderr, /Error during Finalize/); +onUncaughtException(mustCall((err) => { + assert.match(err.message, /Error during Finalize/); +})); + +(async function() { + binding.createExternal(); + + // GC until the finalizer fires; the counter just bounds the loop. + let gcCount = 10; + await gcUntil('test', () => --gcCount <= 0); +})().then(mustCall()); diff --git a/tests/js-native-api/test_exception/testFinalizerException_child.mjs b/tests/js-native-api/test_exception/testFinalizerException_child.mjs deleted file mode 100644 index bf03347..0000000 --- a/tests/js-native-api/test_exception/testFinalizerException_child.mjs +++ /dev/null @@ -1,12 +0,0 @@ -// Child of testFinalizerException.js. The addon throws during Init with its -// exports on `.binding`; createExternal installs a finalizer that throws. -try { - loadAddon('test_exception'); -} catch (anException) { - anException.binding.createExternal(); -} - -// GC until the finalizer fires; its throw crashes the process, printing to -// stderr for the parent to match. The counter just bounds the loop. -let gcCount = 10; -await gcUntil('test', () => --gcCount <= 0);