From 996f63632ef3c328249e2710591cb6f3ed45fb94 Mon Sep 17 00:00:00 2001 From: Y1D7NG Date: Tue, 18 Aug 2026 05:03:39 +0800 Subject: [PATCH 1/5] fs: fix close listener leak in FileHandle streams Fixes: https://github.com/nodejs/node/issues/64214 Signed-off-by: y1d7ng PR-URL: https://github.com/nodejs/node/pull/64227 Reviewed-By: Chemi Atlow Reviewed-By: Claudio Wunder --- lib/internal/fs/streams.js | 14 ++++++- .../test-fs-promises-file-handle-stream.js | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/lib/internal/fs/streams.js b/lib/internal/fs/streams.js index 30617b3b5937..cbf247b3523c 100644 --- a/lib/internal/fs/streams.js +++ b/lib/internal/fs/streams.js @@ -159,7 +159,19 @@ function importFd(stream, options) { stream[kHandle] = options.fd; stream[kFs] = FileHandleOperations(stream[kHandle]); stream[kHandle][kRef](); - options.fd.on('close', FunctionPrototypeBind(stream.close, stream)); + + const onclose = FunctionPrototypeBind(stream.close, stream); + options.fd.on('close', onclose); + if (options.autoClose === false) { + function cleanup() { + options.fd.removeListener('close', onclose); + options.fd[kUnref](); + } + stream.once('end', cleanup); + stream.once('finish', cleanup); + stream.once('error', cleanup); + } + return options.fd.fd; } diff --git a/test/parallel/test-fs-promises-file-handle-stream.js b/test/parallel/test-fs-promises-file-handle-stream.js index 71f312b6f9d7..61d0b3ca2ec7 100644 --- a/test/parallel/test-fs-promises-file-handle-stream.js +++ b/test/parallel/test-fs-promises-file-handle-stream.js @@ -42,7 +42,46 @@ async function validateRead() { ); } +async function validateReusedCreateReadStream() { + const filePath = path.resolve(tmpDir, 'tmp-reused-stream.txt'); + fs.writeFileSync(filePath, Buffer.from('ab', 'utf8')); + + const fileHandle = await open(filePath, 'r'); + try { + await buffer(fileHandle.createReadStream({ + start: 0, + end: 0, + autoClose: false, + })); + assert.strictEqual(fileHandle.listenerCount('close'), 0); + + await buffer(fileHandle.createReadStream({ + start: 1, + end: 1, + autoClose: false, + })); + assert.strictEqual(fileHandle.listenerCount('close'), 0); + } finally { + await fileHandle.close(); + } +} + +async function validateReusedCreateWriteStream() { + const filePath = path.resolve(tmpDir, 'tmp-reused-write-stream.txt'); + const fileHandle = await open(filePath, 'w'); + try { + const stream = fileHandle.createWriteStream({ autoClose: false }); + stream.end('a'); + await finished(stream); + assert.strictEqual(fileHandle.listenerCount('close'), 0); + } finally { + await fileHandle.close(); + } +} + Promise.all([ validateWrite(), validateRead(), + validateReusedCreateReadStream(), + validateReusedCreateWriteStream(), ]).then(common.mustCall()); From 8391608088e446cce8a53a407ee54b63661d2df6 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 15 Aug 2026 22:09:21 +0000 Subject: [PATCH 2/5] string_decoder: decode UTF-8 via StringBytes::Encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StringDecoder used v8::String::NewFromUtf8() for UTF-8, while Buffer#toString() goes through StringBytes::Encode(), which has simdutf-backed ASCII, Latin-1 and UTF-16 paths and only falls back to NewFromUtf8() for input that contains invalid sequences. Route the decoder through the same function, so streams with setEncoding('utf8') and readline decode at the same speed as Buffer#toString(). U+FFFD replacement is unchanged because invalid input still ends up in NewFromUtf8(), and the ERR_STRING_TOO_LONG check is kept explicit so over-long input fails as before. benchmark/string_decoder/string-decoder.js (encoding=utf8) and a readline-over-pipe workload improve by 2-3x for chunks >= 1 KiB; 64 KiB newline-delimited JSON round trips over child stdio improve by ~30% on the reading side alone. Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/65324 Reviewed-By: Yagiz Nizipli Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Daniel Lemire Reviewed-By: James M Snell --- src/string_decoder.cc | 28 +++---- .../test-string-decoder-utf8-large.js | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 test/parallel/test-string-decoder-utf8-large.js diff --git a/src/string_decoder.cc b/src/string_decoder.cc index 4787a83aafe3..33f3ad94599a 100644 --- a/src/string_decoder.cc +++ b/src/string_decoder.cc @@ -28,23 +28,21 @@ MaybeLocal MakeString(Isolate* isolate, const char* data, size_t length, enum encoding encoding) { - MaybeLocal ret; - if (encoding == UTF8) { - MaybeLocal utf8_string; - if (length <= static_cast(v8::String::kMaxLength)) { - utf8_string = String::NewFromUtf8( - isolate, data, v8::NewStringType::kNormal, length); - } - if (utf8_string.IsEmpty()) { - isolate->ThrowException(node::ERR_STRING_TOO_LONG(isolate)); - return MaybeLocal(); - } else { - return utf8_string; - } - } else { - ret = StringBytes::Encode(isolate, data, length, encoding); + // StringBytes::Encode() would report an over-long UTF-8 input as + // ERR_BUFFER_TOO_LARGE (or clamp it); keep reporting it the way this + // decoder always has. + if (encoding == UTF8 && length > static_cast(v8::String::kMaxLength)) + [[unlikely]] { + isolate->ThrowException(node::ERR_STRING_TOO_LONG(isolate)); + return MaybeLocal(); } + // For UTF-8 this takes the simdutf-backed ASCII / Latin-1 / UTF-16 paths and + // only falls back to v8::String::NewFromUtf8() (the previous unconditional + // path here) for input containing invalid sequences, so U+FFFD replacement + // is unchanged. + MaybeLocal ret = StringBytes::Encode(isolate, data, length, encoding); + if (ret.IsEmpty()) { return {}; } diff --git a/test/parallel/test-string-decoder-utf8-large.js b/test/parallel/test-string-decoder-utf8-large.js new file mode 100644 index 000000000000..ccbabf26a27c --- /dev/null +++ b/test/parallel/test-string-decoder-utf8-large.js @@ -0,0 +1,81 @@ +'use strict'; +// The UTF-8 StringDecoder shares its byte->string conversion with +// Buffer#toString(): ASCII, Latin-1-representable and general inputs take +// different (SIMD) paths depending on content and size, and invalid input +// falls back to a replacing decoder. This test pins the decoder's output for +// inputs that cross those size thresholds, for chunkings that split multibyte +// sequences, and for invalid bytes embedded in otherwise large valid input. +require('../common'); +const assert = require('assert'); +const { StringDecoder } = require('string_decoder'); + +function decodeInChunks(buf, chunkSize) { + const decoder = new StringDecoder('utf8'); + let out = ''; + for (let i = 0; i < buf.length; i += chunkSize) { + out += decoder.write(buf.subarray(i, i + chunkSize)); + } + return out + decoder.end(); +} + +function check(str, label) { + const buf = Buffer.from(str, 'utf8'); + // Sanity: the expectation itself round-trips. + assert.strictEqual(buf.toString('utf8'), str, `${label}: toString`); + for (const chunkSize of [1, 2, 3, 4, 5, 7, 31, 32, 33, 255, 256, 257, + 4095, 4096, 65536, buf.length]) { + if (chunkSize > buf.length) continue; + // Keep the test fast: byte-sized chunks only for the smaller inputs. + if (buf.length > 100_000 && chunkSize < 4095) continue; + assert.strictEqual(decodeInChunks(buf, chunkSize), str, + `${label}: chunkSize=${chunkSize}`); + } +} + +const sizes = [31, 32, 33, 255, 256, 257, 4096, 70000, (1 << 20) + 5]; +for (const size of sizes) { + check('a'.repeat(size), `ascii ${size}`); + // Latin-1 range only (one-byte string in V8, two bytes each in UTF-8). + check('é'.repeat(size), `latin1 ${size}`); + // ASCII with a single Latin-1 character at the end / start. + check('a'.repeat(size - 1) + 'ÿ', `ascii+latin1 tail ${size}`); + check('Ä' + 'a'.repeat(size - 1), `latin1 head+ascii ${size}`); + // BMP beyond Latin-1 (three-byte sequences). + check('日'.repeat(size), `cjk ${size}`); + // Mixed, including astral plane characters (surrogate pairs, 4 bytes). + check(('abé日\u{1F600}').repeat(Math.ceil(size / 6)), `mixed ${size}`); +} + +// Invalid bytes inside otherwise valid input of every size class must still be +// replaced with U+FFFD exactly as before, regardless of chunking. +for (const size of [8, 40, 300, 5000, (1 << 20) + 5]) { + const valid = Buffer.from('a'.repeat(size)); + for (const bad of [[0xff], [0xc0, 0xaf], [0xe2, 0x28, 0xa1], + [0xed, 0xa0, 0x80] /* encoded surrogate */, + [0xf0, 0x9f, 0x98] /* truncated 4-byte */]) { + const buf = Buffer.concat([valid, Buffer.from(bad), valid]); + const expected = buf.toString('utf8'); + assert.ok(expected.includes('�'), `size=${size} bad=${bad}`); + for (const chunkSize of [1, 3, 64, size, size + 1, buf.length]) { + if (buf.length > 100_000 && chunkSize < size) continue; + assert.strictEqual(decodeInChunks(buf, chunkSize), expected, + `invalid ${bad} in ${size}, chunkSize=${chunkSize}`); + } + } +} + +// A lone continuation / lead byte split across the size classes at the very +// end is buffered by the decoder and flushed as U+FFFD by end(). +{ + const decoder = new StringDecoder('utf8'); + const big = Buffer.concat([Buffer.from('a'.repeat(300)), Buffer.from([0xe2, 0x82])]); + assert.strictEqual(decoder.write(big), 'a'.repeat(300)); + assert.strictEqual(decoder.end(), '�'); +} +{ + const decoder = new StringDecoder('utf8'); + const big = Buffer.concat([Buffer.from('é'.repeat(300)), Buffer.from([0xe2, 0x82])]); + assert.strictEqual(decoder.write(big), 'é'.repeat(300)); + assert.strictEqual(decoder.write(Buffer.from([0xac])), '€'); + assert.strictEqual(decoder.end(), ''); +} From 42fd92677aaaeb07d27eadd0f1b5b913944333dc Mon Sep 17 00:00:00 2001 From: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:42:35 +0200 Subject: [PATCH 3/5] Revert "fs: fix close listener leak in FileHandle streams" This reverts commit 8488e1324af0631105cfaf365e0e2673de295696. It was advised that the fix in question is broken since it will unref the handle multiple times. Signed-off-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65387 Refs: https://github.com/nodejs/node/pull/64227 Refs: https://github.com/nodejs/node/issues/64214 Reviewed-By: Claudio Wunder Reviewed-By: Chemi Atlow Reviewed-By: Robert Nagy Reviewed-By: Luigi Pinca --- lib/internal/fs/streams.js | 14 +------ .../test-fs-promises-file-handle-stream.js | 39 ------------------- 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/lib/internal/fs/streams.js b/lib/internal/fs/streams.js index cbf247b3523c..30617b3b5937 100644 --- a/lib/internal/fs/streams.js +++ b/lib/internal/fs/streams.js @@ -159,19 +159,7 @@ function importFd(stream, options) { stream[kHandle] = options.fd; stream[kFs] = FileHandleOperations(stream[kHandle]); stream[kHandle][kRef](); - - const onclose = FunctionPrototypeBind(stream.close, stream); - options.fd.on('close', onclose); - if (options.autoClose === false) { - function cleanup() { - options.fd.removeListener('close', onclose); - options.fd[kUnref](); - } - stream.once('end', cleanup); - stream.once('finish', cleanup); - stream.once('error', cleanup); - } - + options.fd.on('close', FunctionPrototypeBind(stream.close, stream)); return options.fd.fd; } diff --git a/test/parallel/test-fs-promises-file-handle-stream.js b/test/parallel/test-fs-promises-file-handle-stream.js index 61d0b3ca2ec7..71f312b6f9d7 100644 --- a/test/parallel/test-fs-promises-file-handle-stream.js +++ b/test/parallel/test-fs-promises-file-handle-stream.js @@ -42,46 +42,7 @@ async function validateRead() { ); } -async function validateReusedCreateReadStream() { - const filePath = path.resolve(tmpDir, 'tmp-reused-stream.txt'); - fs.writeFileSync(filePath, Buffer.from('ab', 'utf8')); - - const fileHandle = await open(filePath, 'r'); - try { - await buffer(fileHandle.createReadStream({ - start: 0, - end: 0, - autoClose: false, - })); - assert.strictEqual(fileHandle.listenerCount('close'), 0); - - await buffer(fileHandle.createReadStream({ - start: 1, - end: 1, - autoClose: false, - })); - assert.strictEqual(fileHandle.listenerCount('close'), 0); - } finally { - await fileHandle.close(); - } -} - -async function validateReusedCreateWriteStream() { - const filePath = path.resolve(tmpDir, 'tmp-reused-write-stream.txt'); - const fileHandle = await open(filePath, 'w'); - try { - const stream = fileHandle.createWriteStream({ autoClose: false }); - stream.end('a'); - await finished(stream); - assert.strictEqual(fileHandle.listenerCount('close'), 0); - } finally { - await fileHandle.close(); - } -} - Promise.all([ validateWrite(), validateRead(), - validateReusedCreateReadStream(), - validateReusedCreateWriteStream(), ]).then(common.mustCall()); From 97a33d994d4d8c38f9e0aeadc79174487b34c7c0 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 18 Jun 2026 17:38:03 +0200 Subject: [PATCH 4/5] src: keep global list of addon-provided cleanup hooks A recent change, 215027c8eded2e, introduced flakiness into our test suite that exposed an issue with the cleanup hook API design. Specifically, the signatures of `AddEnvironmentCleanupHook()` and `RemoveEnvironmentCleanupHook()` are problematic. Both functions take `Isolate*` arguments, as addons are not generally expected to have to care about the Node.js `Environment` as a first-class scope provider. However, this model made the incorrect assumption that in the situations in which `RemoveEnvironmentCleanupHook()` would be invoked an `Environment` would always be associated with the current `Isolate` (via the current V8 `Context`, if there is one). This occasionally breaks down when `RemoveEnvironmentCleanupHook()` is called during garbage collection -- which would be an expected use case of the functionality, but one that has not been covered through our tests before 215027c8eded2e. Since Node.js guarantees API and ABI stability within a major version, and this is a bug that is independent from the aforementioned change, this commit resolves it by adding global mutable state to keep track off cleanup hooks registered through the Node.js public API. Obviously, this solution does not represent a desirable long-term state, and a semver-minor follow up should add an API that does not require modifications to these data structures, likely based on the async cleanup hook API which already solves this issue properly. Refs: https://github.com/nodejs/node/pull/63642 Fixes: https://github.com/nodejs/node/issues/63923 Signed-off-by: Anna Henningsen PR-URL: https://github.com/nodejs/node/pull/63985 Reviewed-By: James M Snell Reviewed-By: Santiago Gimeno Reviewed-By: Matteo Collina (cherry picked from commit 68321eff80918e324e9fdaf1aa8cee6db14b84f7) --- src/api/hooks.cc | 59 +++++++++++++++++++++++++++-- test/addons/worker-addon/binding.cc | 19 +++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/api/hooks.cc b/src/api/hooks.cc index 86132d5eec29..07efb145a29e 100644 --- a/src/api/hooks.cc +++ b/src/api/hooks.cc @@ -127,20 +127,71 @@ struct ACHHandle final { // this. void DeleteACHHandle::operator ()(ACHHandle* handle) const { delete handle; } +// TODO(addaleax): Having this extra set of data structures is far from +// ideal, but unfortunately the public synchronous cleanup hook API was +// slightly mis-designed; in particular, RemoveEnvironmentCleanupHook() needs +// to keep working when the Isolate either has no active context (such as +// during GC) or that context is associated with another Node.js Environment. +// We should align this with the asynchronous API, which handles this properly +// through an explicit reference to the cleanup hook instead of requiring +// lookups in internal maps. +struct CleanupHookThunk final { + Isolate* isolate; + Environment* env; + CleanupHook fun; + void* arg; + + bool operator==(const CleanupHookThunk& other) const { + // `env` is intentionally not part of this comparison + return isolate == other.isolate && fun == other.fun && arg == other.arg; + } +}; +struct CleanupHookThunkHash { + size_t operator()(const CleanupHookThunk& thunk) const { + return std::hash()(thunk.arg); + } +}; +using CleanupHookRegistry = + std::unordered_set; +static ExclusiveAccess cleanup_hook_registry; + +static void CleanupHookThunkRun(void* arg) { + const CleanupHookThunk* thunk = static_cast(arg); + thunk->fun(thunk->arg); + RemoveEnvironmentCleanupHook(thunk->isolate, thunk->fun, thunk->arg); +} + void AddEnvironmentCleanupHook(Isolate* isolate, CleanupHook fun, void* arg) { Environment* env = Environment::GetCurrent(isolate); CHECK_NOT_NULL(env); - env->AddCleanupHook(fun, arg); + void* wrapped_arg; + { + ExclusiveAccess::Scoped registry( + &cleanup_hook_registry); + auto result = registry->insert({isolate, env, fun, arg}); + CHECK(result.second); + wrapped_arg = const_cast(&*result.first); + } + env->AddCleanupHook(CleanupHookThunkRun, wrapped_arg); } void RemoveEnvironmentCleanupHook(Isolate* isolate, CleanupHook fun, void* arg) { - Environment* env = Environment::GetCurrent(isolate); - CHECK_NOT_NULL(env); - env->RemoveCleanupHook(fun, arg); + CleanupHookThunk thunk; + void* wrapped_arg; + { + ExclusiveAccess::Scoped registry( + &cleanup_hook_registry); + auto result = registry->find({isolate, nullptr, fun, arg}); + if (result == registry->end()) return; + wrapped_arg = const_cast(&*result); + thunk = *result; + registry->erase(result); + } + thunk.env->RemoveCleanupHook(CleanupHookThunkRun, wrapped_arg); } static void FinishAsyncCleanupHook(void* arg) { diff --git a/test/addons/worker-addon/binding.cc b/test/addons/worker-addon/binding.cc index a5f9d8b3f835..1fdfcc3a13e2 100644 --- a/test/addons/worker-addon/binding.cc +++ b/test/addons/worker-addon/binding.cc @@ -55,8 +55,23 @@ void Initialize(Local exports, context->GetIsolate(), Cleanup, const_cast(static_cast("cleanup"))); - node::AddEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); - node::RemoveEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); + + // Test that adding and removing a cleanup hook works as expected + { + node::AddEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); + node::RemoveEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); + } + + // Test that adding and removing a cleanup hook also works if there + // is no active context during removal + { + node::AddEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); + { + context->Exit(); + node::RemoveEnvironmentCleanupHook(context->GetIsolate(), Dummy, nullptr); + context->Enter(); + } + } if (getenv("addExtraItemToEventLoop") != nullptr) { // Add an item to the event loop that we do not clean up in order to make From 5a9dbf955a651ca28c25d1a3f5ac23ab867576bf Mon Sep 17 00:00:00 2001 From: Caleb Everett Date: Fri, 4 Sep 2026 09:56:42 -0700 Subject: [PATCH 5/5] src: fix use-after-free in CleanupHookThunkRun CleanupHookThunkRun() read thunk->isolate/fun/arg from the CleanupHookThunk after invoking thunk->fun(). For every node::ObjectWrap alive at teardown, thunk->fun is ObjectWrap::CleanupHook, which deletes the wrap; ~ObjectWrap() calls RemoveEnvironmentCleanupHook() itself, erasing the CleanupHookThunk from the registry and freeing the node it lives in. The subsequent read of thunk->isolate/fun/arg to make the (now redundant) second RemoveEnvironmentCleanupHook() call was therefore a use-after-free. Cache the fields before running the hook so nothing is read from `thunk` once it may have been freed. Taken over from #65196, which has been inactive; the original change is unmodified apart from the added comment. This also unblocks #65042, the backport of the cleanup hook registry to v24.x. Without that registry ~ObjectWrap() asserts during garbage collection, so every 24.x runtime aborts for ObjectWrap addons (#65446), as do 26.x runtimes before 26.4.0 when used with newer headers (#65262). Fixes: https://github.com/nodejs/node/issues/65195 Refs: https://github.com/nodejs/node/pull/65196 Refs: https://github.com/nodejs/node/pull/65042 Refs: https://github.com/nodejs/node/issues/65446 Refs: https://github.com/nodejs/node/issues/65262 Assisted-by: a closed-source coding agent Co-authored-by: Sreehari Annam Signed-off-by: Caleb Everett PR-URL: https://github.com/nodejs/node/pull/65630 Reviewed-By: Trivikram Kamat Reviewed-By: Shelley Vohr (cherry picked from commit 03e2b9bc42ae9c7ed4fbab5509d70fa934863c44) --- src/api/hooks.cc | 11 +++++++-- test/cctest/test_environment.cc | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/api/hooks.cc b/src/api/hooks.cc index 07efb145a29e..3213a30a7bc7 100644 --- a/src/api/hooks.cc +++ b/src/api/hooks.cc @@ -157,8 +157,15 @@ static ExclusiveAccess cleanup_hook_registry; static void CleanupHookThunkRun(void* arg) { const CleanupHookThunk* thunk = static_cast(arg); - thunk->fun(thunk->arg); - RemoveEnvironmentCleanupHook(thunk->isolate, thunk->fun, thunk->arg); + // `thunk->fun` may itself remove and free this CleanupHookThunk (e.g. via + // ~ObjectWrap(), which calls RemoveEnvironmentCleanupHook()), so cache the + // fields we still need before invoking it rather than reading them from + // `thunk` afterwards. + Isolate* isolate = thunk->isolate; + CleanupHook fun = thunk->fun; + void* fun_arg = thunk->arg; + fun(fun_arg); + RemoveEnvironmentCleanupHook(isolate, fun, fun_arg); } void AddEnvironmentCleanupHook(Isolate* isolate, diff --git a/test/cctest/test_environment.cc b/test/cctest/test_environment.cc index a219d5125701..3ccd7845849b 100644 --- a/test/cctest/test_environment.cc +++ b/test/cctest/test_environment.cc @@ -27,6 +27,12 @@ static void at_exit_callback_ordered2(void* arg); static void at_exit_js(void* arg); static std::string cb_1_arg; // NOLINT(runtime/string) +struct SelfRemovingCleanupHookState { + v8::Isolate* isolate; + bool ran = false; +}; +static void self_removing_cleanup_hook(void* arg); + class EnvironmentTest : public EnvironmentTestFixture { private: void TearDown() override { @@ -309,6 +315,27 @@ TEST_F(EnvironmentTest, AtExitRunsJS) { EXPECT_TRUE(called_at_exit_js); } +// A cleanup hook that removes itself while the environment cleanup queue is +// being drained must not cause a use-after-free. This registers such a hook +// directly rather than through node::ObjectWrap, whose destructor removes +// its own hook and is what makes this reachable for addons since #63642. +// The use-after-free is silent in ordinary builds; it is caught by the +// ASan/Valgrind CI, which is also how the original assertion (#63923) +// surfaced. Regression test for https://github.com/nodejs/node/issues/65195. +TEST_F(EnvironmentTest, RemoveEnvironmentCleanupHookDuringCleanup) { + const v8::HandleScope handle_scope(isolate_); + const Argv argv; + SelfRemovingCleanupHookState state{isolate_}; + { + Env env{handle_scope, argv}; + node::AddEnvironmentCleanupHook( + isolate_, self_removing_cleanup_hook, &state); + // Destroying `env` runs FreeEnvironment() -> RunCleanup(), which drains + // the cleanup queue and invokes CleanupHookThunkRun() for the hook above. + } + EXPECT_TRUE(state.ran); +} + TEST_F(EnvironmentTest, MultipleEnvironmentsPerIsolate) { const v8::HandleScope handle_scope(isolate_); const Argv argv; @@ -392,6 +419,19 @@ static void at_exit_js(void* arg) { called_at_exit_js = true; } +// Reproduces the sequence node::ObjectWrap performs since +// https://github.com/nodejs/node/pull/63642, without using ObjectWrap +// itself: the hook removes its own environment cleanup hook. When that runs +// while the cleanup queue is being drained, CleanupHookThunkRun() must not +// read the CleanupHookThunk after invoking the hook -- the hook has already +// erased and freed it. See https://github.com/nodejs/node/issues/65195. +static void self_removing_cleanup_hook(void* arg) { + auto* state = static_cast(arg); + state->ran = true; + node::RemoveEnvironmentCleanupHook( + state->isolate, self_removing_cleanup_hook, state); +} + TEST_F(EnvironmentTest, SetImmediateCleanup) { int called = 0; int called_unref = 0;