Commit 70d8850c090 for nodejs
commit 70d8850c0903c5d21148c31f5b2435695d904434
Author: Shelley Vohr <shelley.vohr@gmail.com>
Date: Tue Sep 22 13:48:46 2026 +0000
test: cover Environments sharing an embedder-owned isolate
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66239
Refs: https://github.com/nodejs/node/pull/65977
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
diff --git a/test/cctest/test_environment_shared_isolate.cc b/test/cctest/test_environment_shared_isolate.cc
new file mode 100644
index 00000000000..e8787d45c10
--- /dev/null
+++ b/test/cctest/test_environment_shared_isolate.cc
@@ -0,0 +1,458 @@
+// Several node::Environments on one v8::Isolate that the embedder created,
+// registered with the platform and gave a CppHeap, each in its own
+// embedder-created v8::Context, driven by one event loop and freed one at a
+// time while the others keep running. doc/api/embedding.md allows this and
+// embedders that host Node.js inside an existing JS runtime depend on it.
+
+#include "cppgc/allocation.h"
+#include "cppgc/garbage-collected.h"
+#include "node_test_fixture.h"
+#include "v8-cppgc.h"
+
+#include <string>
+#include <vector>
+
+using node::Environment;
+using node::IsolateData;
+using v8::Context;
+using v8::HandleScope;
+using v8::Isolate;
+using v8::Local;
+using v8::Value;
+namespace EnvironmentFlags = node::EnvironmentFlags;
+
+namespace {
+
+class EmbedderObject final : public v8::Object::Wrappable {
+ public:
+ static int alive;
+ EmbedderObject() { alive++; }
+ ~EmbedderObject() override { alive--; }
+};
+int EmbedderObject::alive = 0;
+
+void SetFlag(void* flag) {
+ *static_cast<bool*>(flag) = true;
+}
+
+int cleanup_hook_runs = 0;
+void CountCleanupHook(void* arg) {
+ (*static_cast<int*>(arg))++;
+}
+
+enum class IsolateDataMode {
+ // One IsolateData created with the platform, shared by every Environment.
+ kShared,
+ // One IsolateData per Environment, created without a platform and freed
+ // together with its Environment.
+ kPerEnvironmentWithoutPlatform,
+};
+
+struct Instance {
+ int id;
+ v8::Global<Context> context;
+ IsolateData* isolate_data = nullptr;
+ Environment* env = nullptr;
+ bool exit_handler_called = false;
+ int exit_code = -1;
+ node::StopFlags::Flags stop_flags = node::StopFlags::kDoNotTerminateIsolate;
+};
+
+} // namespace
+
+class SharedIsolateTest
+ : public NodeZeroIsolateTestFixture,
+ public ::testing::WithParamInterface<IsolateDataMode> {
+ protected:
+ Isolate* isolate_ = nullptr;
+ v8::CppHeap* cpp_heap_ = nullptr;
+ IsolateData* shared_isolate_data_ = nullptr;
+
+ void SetUp() override {
+ NodeZeroIsolateTestFixture::SetUp();
+
+ Isolate::CreateParams params;
+ params.array_buffer_allocator = allocator.get();
+ params.cpp_heap =
+ v8::CppHeap::Create(platform.get(), v8::CppHeapCreateParams{{}})
+ .release();
+ cpp_heap_ = params.cpp_heap;
+
+ isolate_ = Isolate::Allocate();
+ CHECK_NOT_NULL(isolate_);
+ platform->RegisterIsolate(isolate_, ¤t_loop);
+ Isolate::Initialize(isolate_, params);
+ node::IsolateSettings settings;
+ settings.flags |=
+ node::IsolateSettingsFlags::SHOULD_NOT_SET_PREPARE_STACK_TRACE_CALLBACK;
+ node::SetIsolateUpForNode(isolate_, settings);
+ isolate_->Enter();
+
+ if (GetParam() == IsolateDataMode::kShared) {
+ HandleScope handle_scope(isolate_);
+ shared_isolate_data_ =
+ node::CreateIsolateData(isolate_, ¤t_loop, platform.get());
+ CHECK_NOT_NULL(shared_isolate_data_);
+ }
+ }
+
+ void TearDown() override {
+ if (shared_isolate_data_ != nullptr) {
+ node::FreeIsolateData(shared_isolate_data_);
+ shared_isolate_data_ = nullptr;
+ }
+ EXPECT_EQ(isolate_->GetCppHeap(), cpp_heap_);
+ platform->DrainTasks(isolate_);
+ isolate_->Exit();
+ bool platform_finished = false;
+ platform->AddIsolateFinishedCallback(isolate_, SetFlag, &platform_finished);
+ isolate_->Dispose();
+ platform->UnregisterIsolate(isolate_);
+ while (!platform_finished) uv_run(¤t_loop, UV_RUN_ONCE);
+ isolate_ = nullptr;
+ }
+
+ std::unique_ptr<Instance> CreateInstance(int id,
+ EnvironmentFlags::Flags flags,
+ bool throw_after_bootstrap = false) {
+ auto instance = std::make_unique<Instance>();
+ instance->id = id;
+ HandleScope handle_scope(isolate_);
+ Local<Context> context = Context::New(isolate_);
+ CHECK(node::InitializeContext(context).FromJust());
+ instance->context.Reset(isolate_, context);
+ Context::Scope context_scope(context);
+
+ if (GetParam() == IsolateDataMode::kShared) {
+ instance->isolate_data = shared_isolate_data_;
+ } else {
+ instance->isolate_data =
+ node::CreateIsolateData(isolate_, ¤t_loop, nullptr);
+ CHECK_NOT_NULL(instance->isolate_data);
+ }
+
+ std::vector<std::string> args{"node"};
+ if (throw_after_bootstrap) args.push_back("--throw");
+ std::vector<std::string> exec_args;
+ instance->env = node::CreateEnvironment(
+ instance->isolate_data, context, args, exec_args, flags);
+ CHECK_NOT_NULL(instance->env);
+
+ Instance* raw = instance.get();
+ if (throw_after_bootstrap) instance->stop_flags = node::StopFlags::kNoFlags;
+ node::SetProcessExitHandler(instance->env,
+ [raw](Environment* env, int exit_code) {
+ raw->exit_handler_called = true;
+ raw->exit_code = exit_code;
+ node::Stop(env, raw->stop_flags);
+ });
+
+ std::string script =
+ "const id = " + std::to_string(id) +
+ ";\n"
+ "const vm = require('vm');\n"
+ "const { setInterval, clearInterval, setImmediate } ="
+ " require('timers');\n"
+ "require('net');\n"
+ "globalThis.state = { id, ticks: 0, immediates: 0, events: [] };\n"
+ "const interval = setInterval(() => {\n"
+ " state.ticks++;\n"
+ " new vm.Script('1 + 1');\n"
+ "}, 1);\n"
+ "(function again() {\n"
+ " state.immediates++;\n"
+ " setImmediate(again);\n"
+ "})();\n"
+ "process.on('beforeExit', () => state.events.push('beforeExit'));\n"
+ "process.on('exit', () => {\n"
+ " state.events.push('exit');\n"
+ " clearInterval(interval);\n"
+ "});\n"
+ "if (process.argv.includes('--throw')) throw new Error('uncaught');\n"
+ "return id;\n";
+ v8::MaybeLocal<Value> result =
+ node::LoadEnvironment(instance->env, script.c_str());
+ if (throw_after_bootstrap) {
+ CHECK(result.IsEmpty());
+ CHECK(instance->exit_handler_called);
+ } else {
+ CHECK_EQ(result.ToLocalChecked()->Int32Value(context).FromJust(), id);
+ }
+
+ node::AddEnvironmentCleanupHook(
+ isolate_, CountCleanupHook, &cleanup_hook_runs);
+ return instance;
+ }
+
+ void FreeInstance(std::unique_ptr<Instance> instance) {
+ node::FreeEnvironment(instance->env);
+ if (GetParam() == IsolateDataMode::kPerEnvironmentWithoutPlatform) {
+ node::FreeIsolateData(instance->isolate_data);
+ }
+ instance->context.Reset();
+ }
+
+ Local<Value> Evaluate(Instance* instance, const char* source) {
+ v8::EscapableHandleScope handle_scope(isolate_);
+ Local<Context> context = instance->context.Get(isolate_);
+ Context::Scope context_scope(context);
+ Local<v8::Script> script =
+ v8::Script::Compile(
+ context, v8::String::NewFromUtf8(isolate_, source).ToLocalChecked())
+ .ToLocalChecked();
+ return handle_scope.Escape(script->Run(context).ToLocalChecked());
+ }
+
+ bool TryEvaluate(Instance* instance, const char* source) {
+ HandleScope handle_scope(isolate_);
+ Local<Context> context = instance->context.Get(isolate_);
+ Context::Scope context_scope(context);
+ v8::TryCatch try_catch(isolate_);
+ Local<v8::Script> script =
+ v8::Script::Compile(
+ context, v8::String::NewFromUtf8(isolate_, source).ToLocalChecked())
+ .ToLocalChecked();
+ return !script->Run(context).IsEmpty() && !try_catch.HasTerminated();
+ }
+
+ int EvaluateInt(Instance* instance, const char* source) {
+ HandleScope handle_scope(isolate_);
+ Local<Context> context = instance->context.Get(isolate_);
+ return Evaluate(instance, source)->Int32Value(context).FromJust();
+ }
+
+ std::string EvaluateString(Instance* instance, const char* source) {
+ HandleScope handle_scope(isolate_);
+ v8::String::Utf8Value utf8(isolate_, Evaluate(instance, source));
+ return *utf8;
+ }
+
+ // What an embedder's message pump does: a bounded number of loop turns and
+ // foreground task flushes, never SpinEventLoop() on one Environment.
+ void PumpLoop(int turns) {
+ for (int i = 0; i < turns; i++) {
+ uv_run(¤t_loop, UV_RUN_NOWAIT);
+ platform->DrainTasks(isolate_);
+ }
+ }
+
+ // Runs the loop until every instance's interval timer and immediate chain
+ // have both run again; a callback lost to a stray termination or exception
+ // breaks its chain and shows up here.
+ void PumpUntilAllTicked(const std::vector<Instance*>& instances) {
+ std::vector<int> ticks, immediates;
+ for (Instance* instance : instances) {
+ ticks.push_back(EvaluateInt(instance, "state.ticks"));
+ immediates.push_back(EvaluateInt(instance, "state.immediates"));
+ }
+ for (int turn = 0; turn < 10000; turn++) {
+ uv_run(¤t_loop, UV_RUN_ONCE);
+ platform->DrainTasks(isolate_);
+ bool all_progressed = true;
+ for (size_t i = 0; i < instances.size(); i++) {
+ if (EvaluateInt(instances[i], "state.ticks") <= ticks[i] ||
+ EvaluateInt(instances[i], "state.immediates") <= immediates[i]) {
+ all_progressed = false;
+ }
+ }
+ if (all_progressed) return;
+ }
+ FAIL() << "an Environment stopped running its timers or immediates";
+ }
+};
+
+TEST_P(SharedIsolateTest, EnvironmentsComeAndGoWhileSiblingsRun) {
+ const HandleScope handle_scope(isolate_);
+ cleanup_hook_runs = 0;
+
+ const auto sibling_flags = static_cast<EnvironmentFlags::Flags>(
+ EnvironmentFlags::kNoCreateInspector |
+ EnvironmentFlags::kNoBrowserGlobals |
+ EnvironmentFlags::kNoRegisterESMLoader |
+ EnvironmentFlags::kNoGlobalSearchPaths);
+
+ std::unique_ptr<Instance> owner =
+ CreateInstance(0, EnvironmentFlags::kDefaultFlags);
+ std::unique_ptr<Instance> second = CreateInstance(1, sibling_flags);
+ std::unique_ptr<Instance> third = CreateInstance(2, sibling_flags);
+ EXPECT_EQ(isolate_->GetCppHeap(), cpp_heap_);
+
+ PumpUntilAllTicked({owner.get(), second.get(), third.get()});
+
+ // The embedder's own cppgc objects live on the same heap as Node's.
+ v8::Global<v8::Object> embedder_holder;
+ {
+ HandleScope inner(isolate_);
+ Local<Context> context = second->context.Get(isolate_);
+ Context::Scope context_scope(context);
+ Local<v8::Object> holder = v8::FunctionTemplate::New(isolate_)
+ ->InstanceTemplate()
+ ->NewInstance(context)
+ .ToLocalChecked();
+ v8::Object::Wrap<v8::CppHeapPointerTag::kDefaultTag>(
+ isolate_,
+ holder,
+ cppgc::MakeGarbageCollected<EmbedderObject>(
+ isolate_->GetCppHeap()->GetAllocationHandle()));
+ embedder_holder.Reset(isolate_, holder);
+ }
+ isolate_->LowMemoryNotification();
+ EXPECT_EQ(EmbedderObject::alive, 1);
+
+#if HAVE_INSPECTOR
+ EXPECT_EQ(EvaluateInt(
+ owner.get(),
+ "(() => {\n"
+ " const { Session } = process.getBuiltinModule('inspector');\n"
+ " const session = new Session();\n"
+ " session.connect();\n"
+ " let value = -1;\n"
+ " session.post('Runtime.evaluate', { expression: 'state.id + "
+ "42' },\n"
+ " (err, res) => { value = err ? -2 : "
+ "res.result.value; });\n"
+ " console.time('session'); console.timeEnd('session');\n"
+ " session.disconnect();\n"
+ " return value;\n"
+ "})()"),
+ 42);
+ // A sibling without an inspector of its own gets an exception, not an abort.
+ EXPECT_EQ(
+ EvaluateString(
+ second.get(),
+ "(() => {\n"
+ " try {\n"
+ " const { Session } = process.getBuiltinModule('inspector');\n"
+ " const session = new Session();\n"
+ " session.connect();\n"
+ " session.disconnect();\n"
+ " return 'connected';\n"
+ " } catch (e) { return String(e.code); }\n"
+ "})()"),
+ "ERR_INSPECTOR_NOT_AVAILABLE");
+#endif // HAVE_INSPECTOR
+
+ if (GetParam() == IsolateDataMode::kShared) {
+ // Workers need a platform; their exit must not disturb the siblings.
+ EXPECT_EQ(
+ EvaluateInt(third.get(),
+ "(() => {\n"
+ " const { Worker } ="
+ " process.getBuiltinModule('worker_threads');\n"
+ " state.workerExit = -1;\n"
+ " new Worker('process.exit(7)', { eval: true })\n"
+ " .on('exit', (code) => { state.workerExit = code; });\n"
+ " return 0;\n"
+ "})()"),
+ 0);
+ for (int turn = 0; turn < 10000; turn++) {
+ PumpLoop(1);
+ if (EvaluateInt(third.get(), "state.workerExit") == 7) break;
+ }
+ EXPECT_EQ(EvaluateInt(third.get(), "state.workerExit"), 7);
+ }
+
+ PumpUntilAllTicked({owner.get(), second.get(), third.get()});
+
+ // Free one Environment while its siblings have timers and immediates due.
+ const int owner_ticks = EvaluateInt(owner.get(), "state.ticks");
+ FreeInstance(std::move(second));
+ EXPECT_EQ(cleanup_hook_runs, 1);
+ EXPECT_FALSE(owner->exit_handler_called);
+ EXPECT_FALSE(third->exit_handler_called);
+ PumpUntilAllTicked({owner.get(), third.get()});
+ EXPECT_GT(EvaluateInt(owner.get(), "state.ticks"), owner_ticks);
+
+ // The embedder's object outlives the Environment whose context wrapped it.
+ isolate_->LowMemoryNotification();
+ EXPECT_EQ(EmbedderObject::alive, 1);
+ embedder_holder.Reset();
+
+ // An uncaught exception whose exit handler calls Stop() without
+ // kDoNotTerminateIsolate, then free: the termination it requests must not
+ // leak into the sibling's next script.
+ third->stop_flags = node::StopFlags::kNoFlags;
+ EXPECT_EQ(EvaluateInt(third.get(),
+ "process.getBuiltinModule('timers').setImmediate("
+ "() => { throw new Error('uncaught'); }), 0"),
+ 0);
+ for (int turn = 0; turn < 100 && !third->exit_handler_called; turn++) {
+ PumpLoop(1);
+ }
+ EXPECT_TRUE(third->exit_handler_called);
+ EXPECT_EQ(third->exit_code, 1);
+ FreeInstance(std::move(third));
+ EXPECT_EQ(cleanup_hook_runs, 2);
+ EXPECT_TRUE(TryEvaluate(owner.get(), "state.ticks"));
+ PumpUntilAllTicked({owner.get()});
+ EXPECT_FALSE(owner->exit_handler_called);
+
+ // The same when the exception is thrown by the bootstrap script itself and
+ // no JavaScript of that Environment runs after its exit handler.
+ std::unique_ptr<Instance> failed = CreateInstance(4, sibling_flags, true);
+ EXPECT_EQ(failed->exit_code, 1);
+ FreeInstance(std::move(failed));
+ EXPECT_EQ(cleanup_hook_runs, 3);
+ EXPECT_TRUE(TryEvaluate(owner.get(), "state.ticks"));
+ PumpUntilAllTicked({owner.get()});
+
+ // A new sibling can join after others left, here a second one that owns an
+ // inspector and debug signal handler of its own.
+ std::unique_ptr<Instance> late =
+ CreateInstance(3, EnvironmentFlags::kDefaultFlags);
+ PumpUntilAllTicked({owner.get(), late.get()});
+
+ // beforeExit / exit are per Environment.
+ {
+ HandleScope inner(isolate_);
+ Context::Scope context_scope(late->context.Get(isolate_));
+ node::EmitProcessBeforeExit(late->env).Check();
+ }
+ EXPECT_EQ(EvaluateString(late.get(), "state.events.join()"), "beforeExit");
+ EXPECT_EQ(EvaluateString(owner.get(), "state.events.join()"), "");
+ {
+ HandleScope inner(isolate_);
+ Context::Scope context_scope(late->context.Get(isolate_));
+ EXPECT_EQ(node::EmitProcessExit(late->env).FromJust(), 0);
+ }
+ EXPECT_EQ(EvaluateString(late.get(), "state.events.join()"),
+ "beforeExit,exit");
+ EXPECT_EQ(EvaluateString(owner.get(), "state.events.join()"), "");
+ node::Stop(late->env, node::StopFlags::kDoNotTerminateIsolate);
+ PumpUntilAllTicked({owner.get()});
+ EXPECT_EQ(EvaluateInt(late.get(), "state.events.length"), 2);
+ FreeInstance(std::move(late));
+ EXPECT_EQ(cleanup_hook_runs, 4);
+
+ PumpUntilAllTicked({owner.get()});
+ node::Stop(owner->env, node::StopFlags::kDoNotTerminateIsolate);
+ FreeInstance(std::move(owner));
+ EXPECT_EQ(cleanup_hook_runs, 5);
+
+ // Nothing the Environments left behind keeps the shared loop busy.
+ PumpLoop(2);
+ EXPECT_EQ(uv_loop_alive(¤t_loop), 0);
+}
+
+TEST_P(SharedIsolateTest, FreeIsolateDataBeforeItsEnvironmentAsserts) {
+ GTEST_FLAG_SET(death_test_style, "threadsafe");
+ const HandleScope handle_scope(isolate_);
+ std::unique_ptr<Instance> instance =
+ CreateInstance(0, EnvironmentFlags::kNoCreateInspector);
+ IsolateData* isolate_data = instance->isolate_data;
+ EXPECT_DEATH_IF_SUPPORTED(node::FreeIsolateData(isolate_data),
+ "environment_count_");
+ node::Stop(instance->env, node::StopFlags::kDoNotTerminateIsolate);
+ FreeInstance(std::move(instance));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+ EnvironmentTest,
+ SharedIsolateTest,
+ ::testing::Values(IsolateDataMode::kShared,
+ IsolateDataMode::kPerEnvironmentWithoutPlatform),
+ [](const ::testing::TestParamInfo<IsolateDataMode>& info) {
+ return info.param == IsolateDataMode::kShared
+ ? "SharedIsolateData"
+ : "IsolateDataPerEnvironmentWithoutPlatform";
+ });