From 62f7ed614dd809e1e624023b9e3d233e00d16cff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:47:32 +0000 Subject: [PATCH] test(napi): cover worker.terminate() with napi_async_work execute in flight Regression test for the worker-teardown race fixed by #37075. A worker queues four napi_async_works whose execute callbacks sleep and then write a 16 MiB ArrayBuffer on the pool thread; the parent terminates it while they are in flight. Before #37075 (at 52bf09cb1c, its parent) the subprocess died 3/3 under ASAN with a heap-use-after-free in the pool thread's completion post (EventLoop::vm_ref <- enqueue_task_concurrent <- napi_async_work::run, freed by WebWorker::shutdown). On main it passes 3/3. The addon uses only public node-api. --- test/napi/napi-app/binding.gyp | 11 ++ .../test_async_work_worker_terminate.c | 111 ++++++++++++++++++ test/napi/napi.test.ts | 60 ++++++++++ 3 files changed, 182 insertions(+) create mode 100644 test/napi/napi-app/test_async_work_worker_terminate.c diff --git a/test/napi/napi-app/binding.gyp b/test/napi/napi-app/binding.gyp index d0daf9c422a9..5c119bcf262b 100644 --- a/test/napi/napi-app/binding.gyp +++ b/test/napi/napi-app/binding.gyp @@ -297,5 +297,16 @@ "NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT=1", ], }, + { + "target_name": "test_async_work_worker_terminate", + "sources": ["test_async_work_worker_terminate.c"], + "include_dirs": [" +#include +#include +#include + +#ifdef _WIN32 +#include +static void sleep_ms(unsigned ms) { Sleep(ms); } +#else +#include +static void sleep_ms(unsigned ms) { usleep(ms * 1000); } +#endif + +#define CHECK(env, call) \ + do { \ + napi_status s_ = (call); \ + if (s_ != napi_ok) { \ + napi_throw_error((env), NULL, #call " failed"); \ + return NULL; \ + } \ + } while (0) + +typedef struct { + napi_async_work work; + napi_ref buf_ref; + napi_ref cb_ref; + unsigned char *data; + size_t len; + unsigned sleep_ms; +} work_t; + +static void exec_cb(napi_env env, void *arg) { + work_t *w = (work_t *)arg; + sleep_ms(w->sleep_ms); + // Touch the ArrayBuffer backing store. Before the fix, worker.terminate() + // could free it (via JSC VM teardown running the ArrayBuffer finalizer) + // while this callback is still running on the pool thread. + if (w->len > 0) { + volatile unsigned char first = w->data[0]; + (void)first; + memset(w->data, 0xab, w->len); + } +} + +static void done_cb(napi_env env, napi_status status, void *arg) { + work_t *w = (work_t *)arg; + napi_value cb = NULL, undef = NULL, argv[1]; + if (w->cb_ref != NULL) { + napi_get_reference_value(env, w->cb_ref, &cb); + } + napi_get_undefined(env, &undef); + napi_create_int32(env, (int)status, &argv[0]); + if (cb != NULL) { + napi_call_function(env, undef, cb, 1, argv, NULL); + } + napi_delete_reference(env, w->buf_ref); + if (w->cb_ref != NULL) napi_delete_reference(env, w->cb_ref); + napi_delete_async_work(env, w->work); + free(w); +} + +// queueWork(arrayBuffer, ms[, cb]) -> undefined +static napi_value queue_work(napi_env env, napi_callback_info info) { + size_t argc = 3; + napi_value argv[3]; + CHECK(env, napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + if (argc < 2) { + napi_throw_error(env, NULL, "expected (arrayBuffer, ms[, cb])"); + return NULL; + } + + work_t *w = (work_t *)calloc(1, sizeof(*w)); + + void *data = NULL; + size_t len = 0; + CHECK(env, napi_get_arraybuffer_info(env, argv[0], &data, &len)); + w->data = (unsigned char *)data; + w->len = len; + + int32_t ms = 0; + CHECK(env, napi_get_value_int32(env, argv[1], &ms)); + w->sleep_ms = (unsigned)(ms < 0 ? 0 : ms); + + CHECK(env, napi_create_reference(env, argv[0], 1, &w->buf_ref)); + if (argc >= 3) { + napi_valuetype t; + CHECK(env, napi_typeof(env, argv[2], &t)); + if (t == napi_function) { + CHECK(env, napi_create_reference(env, argv[2], 1, &w->cb_ref)); + } + } + + napi_value name; + CHECK(env, napi_create_string_utf8(env, "test_async_work_worker_terminate", + NAPI_AUTO_LENGTH, &name)); + CHECK(env, napi_create_async_work(env, NULL, name, exec_cb, done_cb, w, + &w->work)); + CHECK(env, napi_queue_async_work(env, w->work)); + + napi_value undef; + CHECK(env, napi_get_undefined(env, &undef)); + return undef; +} + +NAPI_MODULE_INIT() { + napi_value fn; + CHECK(env, napi_create_function(env, "queueWork", NAPI_AUTO_LENGTH, + queue_work, NULL, &fn)); + CHECK(env, napi_set_named_property(env, exports, "queueWork", fn)); + return exports; +} diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index 1e044ea4a4bf..df300599181c 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -550,6 +550,66 @@ describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { expect(output).toContain("success!"); expect(output).not.toContain("failure!"); }); + // worker.terminate() while execute callbacks are still running on the + // thread pool must not free the worker's VirtualMachine (the pool-thread + // completion would post into a freed event loop) or its JSC heap (the + // ArrayBuffer backing store would be finalized while the addon is still + // writing it). VM teardown counts queued napi_async_work and waits for the + // pool to hand each one back before destroying the VM. Before that the + // subprocess died with a heap-use-after-free in the pool thread's + // completion post instead of printing PASS. + it("worker.terminate() with execute callbacks in flight waits for them and does not UAF", async () => { + const addon = join(__dirname, "napi-app/build/Debug/test_async_work_worker_terminate.node"); + const workerSrc = /* js */ ` + const { parentPort, workerData } = require("node:worker_threads"); + const addon = require(workerData.addon); + const keep = []; + for (let i = 0; i < 4; i++) { + const ab = new ArrayBuffer(16 << 20); + keep.push(ab); + addon.queueWork(ab, 300 + i * 50, () => {}); + } + parentPort.postMessage("up"); + setInterval(() => {}, 1000); + `; + const script = /* js */ ` + const { Worker } = require("node:worker_threads"); + (async () => { + for (let r = 0; r < ${isASAN ? 3 : 5}; r++) { + const w = new Worker(process.env.WORKER_SRC, { + eval: true, + workerData: { addon: process.env.ADDON }, + }); + await new Promise((resolve, reject) => { + w.once("message", resolve); + w.once("error", reject); + w.once("exit", code => reject(new Error("worker exited before queueing work, code " + code))); + }); + await w.terminate(); + } + console.log("PASS"); + })().catch(e => { + console.error(String(e)); + process.exit(1); + }); + `; + await using proc = spawn({ + cmd: [bunExe(), "-e", script], + // LSan is off in the subprocess: a `-e` script that creates eval + // workers reports their source Blobs at exit regardless of the addon, + // and this test is about the crash. + env: { + ...bunEnv, + ADDON: addon, + WORKER_SRC: workerSrc, + ASAN_OPTIONS: "detect_leaks=0:allow_user_segv_handler=1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "PASS", stderr: "", exitCode: 0 }); + }, 30_000); }); describe("napi_threadsafe_function", () => {