Files
electron/shell/renderer/web_worker_observer.cc
Shelley Vohr a39108c5a4 fix: nodeIntegrationInWorker not working in AudioWorklet (#47244)
* fix: nodeIntegrationInWorker not working in AudioWorklet

* fix: deadlock on Windows when destroying non-AudioWorklet worker contexts

The previous change kept the WebWorkerObserver alive across
ContextWillDestroy so the worker thread could be reused for the next
context (AudioWorklet thread pooling, Chromium CL:5270028). This is
correct for AudioWorklet but wrong for PaintWorklet and other worker
types, which Blink does not pool — each teardown destroys the thread.

For those worker types, ~NodeBindings was deferred to the thread-exit
TLS callback. By that point set_uv_env(nullptr) had already run, so on
Windows the embed thread was parked in GetQueuedCompletionStatus with a
stale async_sent latch that swallowed the eventual WakeupEmbedThread()
from ~NodeBindings. uv_thread_join then blocked forever, deadlocking
renderer navigation. The worker-multiple-destroy crash case timed out
on win-x64/x86/arm64 as a result. macOS/Linux (epoll/kqueue) don't have
the latch and were unaffected.

Plumb is_audio_worklet from WillDestroyWorkerContextOnWorkerThread into
ContextWillDestroy. For non-AudioWorklet contexts, restore the
pre-existing behavior of calling lazy_tls->Set(nullptr) at the end of
the last-context cleanup so ~NodeBindings runs while the worker thread
is still healthy. AudioWorklet continues to keep the observer alive so
the next pooled context can share NodeBindings.

* chore: address review feedback

* fix: stop embed thread before destroying environments in worker teardown

FreeEnvironment (called via environments_.clear()) runs uv_run to drain
handle close callbacks. On Windows, both that uv_run and the embed
thread's PollEvents call GetQueuedCompletionStatus on the same IOCP
handle. IOCP completions are consumed by exactly one waiter, so the
embed thread can steal completions that FreeEnvironment needs, causing
uv_run to block indefinitely. On Linux/Mac epoll_wait/kevent can wake
multiple waiters for the same event so the race doesn't manifest.

Add NodeBindings::StopPolling() which cleanly joins the embed thread
without destroying handles or the loop, and allows PrepareEmbedThread +
StartPolling to restart it later. Call StopPolling() in
WebWorkerObserver::ContextWillDestroy before environments_.clear() so
FreeEnvironment's uv_run is the only thread touching the IOCP.

Split PrepareEmbedThread's handle initialization (uv_async_init,
uv_sem_init) from thread creation via a new embed_thread_prepared_ flag
so the handles survive across stop/restart cycles for pooled worklets
while the embed thread itself can be recreated.

* chore: address outstanding feedback
2026-04-13 10:27:30 +02:00

330 lines
13 KiB
C++

// Copyright (c) 2017 GitHub, Inc.
// Use of this source code is governed by the MIT license that can be
// found in the LICENSE file.
#include "shell/renderer/web_worker_observer.h"
#include <string_view>
#include <utility>
#include "base/no_destructor.h"
#include "base/strings/strcat.h"
#include "base/threading/thread_local.h"
#include "gin/converter.h"
#include "shell/common/api/electron_bindings.h"
#include "shell/common/node_bindings.h"
#include "shell/common/node_includes.h"
#include "shell/common/node_util.h"
#include "third_party/blink/renderer/core/execution_context/execution_context.h" // nogncheck
namespace electron {
namespace {
static base::NoDestructor<base::ThreadLocalOwnedPointer<WebWorkerObserver>>
lazy_tls;
// Returns true if `context` belongs to a worklet that runs on a thread
// pooled by Blink's WorkletThreadHolder, where the worker thread can be
// reused for multiple worklet contexts. For these scopes the
// WebWorkerObserver and its NodeBindings must outlive the v8::Context so
// the next pooled context can reuse them — Node.js cannot be re-initialized
// on the same thread (the allocator shim only loads once). See callers of
// blink::WorkletThreadHolder in third_party/blink for the authoritative
// list.
bool IsPooledWorkletContext(v8::Local<v8::Context> context) {
auto* ec = blink::ExecutionContext::From(context);
if (!ec)
return false;
return ec->IsAudioWorkletGlobalScope() || ec->IsPaintWorkletGlobalScope() ||
ec->IsAnimationWorkletGlobalScope() ||
ec->IsSharedStorageWorkletGlobalScope();
}
} // namespace
// static
WebWorkerObserver* WebWorkerObserver::GetCurrent() {
return lazy_tls->Get();
}
// static
WebWorkerObserver* WebWorkerObserver::Create() {
auto obs = std::make_unique<WebWorkerObserver>();
auto* obs_raw = obs.get();
lazy_tls->Set(std::move(obs));
return obs_raw;
}
WebWorkerObserver::WebWorkerObserver()
: node_bindings_(
NodeBindings::Create(NodeBindings::BrowserEnvironment::kWorker)),
electron_bindings_(
std::make_unique<ElectronBindings>(node_bindings_->uv_loop())) {}
WebWorkerObserver::~WebWorkerObserver() = default;
void WebWorkerObserver::WorkerScriptReadyForEvaluation(
v8::Local<v8::Context> worker_context) {
active_context_count_++;
if (environments_.empty()) {
// First context on this thread - do full Node.js initialization.
InitializeNewEnvironment(worker_context);
} else {
// Thread is being reused (AudioWorklet thread pooling). Share the
// existing Node.js environment with the new context instead of
// reinitializing, which would break existing contexts on this thread.
ShareEnvironmentWithContext(worker_context);
}
}
void WebWorkerObserver::InitializeNewEnvironment(
v8::Local<v8::Context> worker_context) {
v8::Context::Scope context_scope(worker_context);
v8::Isolate* const isolate = v8::Isolate::GetCurrent();
v8::MicrotasksScope microtasks_scope(
worker_context, v8::MicrotasksScope::kDoNotRunMicrotasks);
// Start the embed thread.
node_bindings_->PrepareEmbedThread();
// Setup node tracing controller.
if (!node::tracing::TraceEventHelper::GetAgent()) {
auto* tracing_agent = new node::tracing::Agent();
node::tracing::TraceEventHelper::SetAgent(tracing_agent);
}
// Setup node environment for each window.
v8::Maybe<bool> initialized = node::InitializeContext(worker_context);
CHECK(!initialized.IsNothing() && initialized.FromJust());
std::shared_ptr<node::Environment> env =
node_bindings_->CreateEnvironment(isolate, worker_context, nullptr);
// We need to use the Blink implementation of fetch in web workers
// Node.js deletes the global fetch function when their fetch implementation
// is disabled, so we need to save and re-add it after the Node.js environment
// is loaded. See corresponding change in node/init.ts.
v8::Local<v8::Object> global = worker_context->Global();
for (const std::string_view key :
{"fetch", "Response", "FormData", "Request", "Headers", "EventSource"}) {
v8::MaybeLocal<v8::Value> value =
global->Get(worker_context, gin::StringToV8(isolate, key));
if (!value.IsEmpty()) {
std::string blink_key = base::StrCat({"blink", key});
global
->Set(worker_context, gin::StringToV8(isolate, blink_key),
value.ToLocalChecked())
.Check();
}
}
// We do not want to crash Web Workers on unhandled rejections.
env->options()->unhandled_rejections = "warn-with-error-code";
// Add Electron extended APIs.
electron_bindings_->BindTo(env->isolate(), env->process_object());
// Load everything.
node_bindings_->LoadEnvironment(env.get());
// Make uv loop being wrapped by window context.
node_bindings_->set_uv_env(env.get());
// Give the node loop a run to make sure everything is ready.
node_bindings_->StartPolling();
// Keep the environment alive until we free it in ContextWillDestroy()
environments_.insert(std::move(env));
}
void WebWorkerObserver::ShareEnvironmentWithContext(
v8::Local<v8::Context> worker_context) {
v8::Context::Scope context_scope(worker_context);
v8::Isolate* const isolate = v8::Isolate::GetCurrent();
v8::MicrotasksScope microtasks_scope(
worker_context, v8::MicrotasksScope::kDoNotRunMicrotasks);
// Get the existing environment from the first context on this thread.
DCHECK(!environments_.empty());
node::Environment* env = environments_.begin()->get();
// Initialize the V8 context for Node.js use.
v8::Maybe<bool> initialized = node::InitializeContext(worker_context);
CHECK(!initialized.IsNothing() && initialized.FromJust());
// Assign the existing Node.js environment to this new context so that
// node::Environment::GetCurrent(context) returns the shared environment.
env->AssignToContext(worker_context, env->principal_realm(),
node::ContextInfo("electron_worker"));
// Get process and require from the original context to make Node.js
// APIs available in the new context.
v8::Local<v8::Context> original_context = env->context();
v8::Local<v8::Object> original_global = original_context->Global();
v8::Local<v8::Object> new_global = worker_context->Global();
v8::Local<v8::Value> process_value;
CHECK(original_global
->Get(original_context, gin::StringToV8(isolate, "process"))
.ToLocal(&process_value));
v8::Local<v8::Value> require_value;
CHECK(original_global
->Get(original_context, gin::StringToV8(isolate, "require"))
.ToLocal(&require_value));
// Set up 'global' as an alias for globalThis. Node.js bootstrapping normally
// does this during LoadEnvironment, but we skip full bootstrap for shared
// contexts.
new_global
->Set(worker_context, gin::StringToV8(isolate, "global"), new_global)
.Check();
new_global
->Set(worker_context, gin::StringToV8(isolate, "process"), process_value)
.Check();
new_global
->Set(worker_context, gin::StringToV8(isolate, "require"), require_value)
.Check();
// Copy Buffer from the original context if it exists.
v8::Local<v8::Value> buffer_value;
if (original_global->Get(original_context, gin::StringToV8(isolate, "Buffer"))
.ToLocal(&buffer_value) &&
!buffer_value->IsUndefined()) {
new_global
->Set(worker_context, gin::StringToV8(isolate, "Buffer"), buffer_value)
.Check();
}
// Restore the Blink implementations of web APIs that Node.js may
// have deleted. For first-context init this is done by the node_init script
// but we can't run that for shared contexts (it calls internalBinding).
// Instead, copy the blink-prefixed values set during first init.
for (const std::string_view key :
{"fetch", "Response", "FormData", "Request", "Headers", "EventSource"}) {
// First, check if the new context has a working Blink version.
v8::MaybeLocal<v8::Value> blink_value =
new_global->Get(worker_context, gin::StringToV8(isolate, key));
if (!blink_value.IsEmpty() && !blink_value.ToLocalChecked()->IsUndefined())
continue;
// If not, copy from the original context.
std::string blink_key = base::StrCat({"blink", key});
v8::Local<v8::Value> orig_value;
if (original_global->Get(original_context, gin::StringToV8(isolate, key))
.ToLocal(&orig_value) &&
!orig_value->IsUndefined()) {
new_global->Set(worker_context, gin::StringToV8(isolate, key), orig_value)
.Check();
}
}
}
void WebWorkerObserver::ContextWillDestroy(v8::Local<v8::Context> context) {
node::Environment* env = node::Environment::GetCurrent(context);
if (!env)
return;
const bool is_pooled_worklet = IsPooledWorkletContext(context);
active_context_count_--;
if (active_context_count_ == 0) {
// Last context on this thread — full cleanup.
{
v8::Context::Scope context_scope(env->context());
// Emit the "exit" event on the process object. We avoid using
// gin_helper::EmitEvent here because it goes through
// CallMethodWithArgs, which creates a node::CallbackScope. During
// worker shutdown (PrepareForShutdownOnWorkerThread), the
// CallbackScope destructor's InternalCallbackScope::Close() tries to
// process ticks and microtask checkpoints, which can SEGV because the
// worker context is being torn down by Blink.
v8::Isolate* isolate = env->isolate();
v8::HandleScope handle_scope(isolate);
v8::Local<v8::Context> ctx = env->context();
v8::Local<v8::Value> emit_v;
if (env->process_object()
->Get(ctx, gin::StringToV8(isolate, "emit"))
.ToLocal(&emit_v) &&
emit_v->IsFunction()) {
v8::Local<v8::Value> args[] = {gin::StringToV8(isolate, "exit")};
v8::TryCatch try_catch(isolate);
emit_v.As<v8::Function>()
->Call(ctx, env->process_object(), 1, args)
.FromMaybe(v8::Local<v8::Value>());
// We are mid-teardown and about to destroy the worker's
// node::Environment, so we cannot let an exception thrown by an
// 'exit' listener propagate back into Blink (it would assert in
// V8::FromJustIsNothing on the next call into V8). Log it and
// explicitly reset the TryCatch so the destructor doesn't rethrow.
if (try_catch.HasCaught()) {
if (auto message = try_catch.Message(); !message.IsEmpty()) {
std::string str;
if (gin::ConvertFromV8(isolate, message->Get(), &str))
LOG(ERROR) << "Exception thrown from worker 'exit' handler: "
<< str;
}
try_catch.Reset();
}
}
}
// Prevent UvRunOnce from using the environment after it's destroyed.
node_bindings_->set_uv_env(nullptr);
// Stop the embed thread before destroying environments. The embed
// thread's PollEvents and FreeEnvironment's uv_run both compete for
// completions on the same libuv event loop; on Windows (IOCP) this
// race can deadlock. Joining the embed thread first eliminates the
// contention so FreeEnvironment's uv_run can drain handles cleanly.
// For pooled worklets the thread is restarted in
// InitializeNewEnvironment via PrepareEmbedThread + StartPolling.
node_bindings_->StopPolling();
// Destroying the node environment will also run the uv loop.
{
util::ExplicitMicrotasksScope microtasks_scope(
context->GetMicrotaskQueue());
environments_.clear();
}
// ElectronBindings is tracking node environments.
electron_bindings_->EnvironmentDestroyed(env);
// For non-pooled worker contexts (e.g., dedicated workers) Blink does
// not reuse the worker thread, so tear down the observer completely.
//
// For pooled worklet contexts (AudioWorklet, PaintWorklet,
// AnimationWorklet, SharedStorageWorklet — see
// blink::WorkletThreadHolder) the same NodeBindings must be reused
// for the next context on the thread because Node.js cannot be
// re-initialized on the same thread. Keep the observer alive and let
// the next WorkerScriptReadyForEvaluation call
// InitializeNewEnvironment, which restarts the embed thread via
// PrepareEmbedThread + StartPolling.
if (!is_pooled_worklet) {
lazy_tls->Set(nullptr); // destroys *this; do not access members below
return;
}
} else {
// Other contexts still use the shared environment. Just unassign
// this context from the environment if it's not the primary context
// (the primary context must stay assigned because env->context()
// references it, and UvRunOnce enters that context scope).
if (context != env->context()) {
env->UnassignFromContext(context);
}
// If the destroyed context IS the primary context, we leave the env
// assigned to it. The env's PrincipalRealm holds a Global<Context>
// reference that keeps the V8 context alive even though Blink has
// torn down its side. This is safe because UvRunOnce only needs
// the V8 context scope, not Blink-side objects.
}
}
} // namespace electron