From 9d797e4e6b2d567c7e916b348f1518c339196ead Mon Sep 17 00:00:00 2001 From: "trop[bot]" <37223003+trop[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:44:00 -0400 Subject: [PATCH] feat: capture JS stack trace on renderer OOM (#50911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: capture JS stack trace on renderer OOM When a renderer process approaches its V8 heap limit, capture the JavaScript stack trace and write it to both a Crashpad crash key ("js-oom-stack") and stderr. The stack trace is captured via RequestInterrupt rather than directly inside the NearHeapLimitCallback because CurrentStackTrace is unsafe to call during OOM — V8 FATALs on optimized (TurboFan) frames that have had their deoptimization data garbage-collected. RequestInterrupt defers the capture to the next V8 safe point, where all frames are guaranteed to have deopt data available. This matches Node.js's approach of never capturing JS stacks inside the heap limit callback. The callback is registered once per isolate via an atomic guard in RendererClientBase::DidCreateScriptContext, preventing the CHECK failure V8 raises on duplicate AddNearHeapLimitCallback registrations (which would otherwise occur on page navigations or multiple contexts). Refs: #46078 Made-with: Cursor Co-authored-by: Alexey Kozy * Update shell/renderer/oom_stack_trace.cc Co-authored-by: Niklas Wenzel Co-authored-by: Alexey Kozy * Update shell/renderer/oom_stack_trace.cc Co-authored-by: Niklas Wenzel Co-authored-by: Alexey Kozy * test: add crash reporter test for OOM JS stack trace Add a test that verifies the `electron.v8-oom.stack` crash key contains the JS stack trace (including function names) when a renderer process runs out of memory. Also deduplicate the heap info formatting in oom_stack_trace.cc. Refs: #46078 Made-with: Cursor Co-authored-by: Alexey Kozy * fix: lint formatting in oom_stack_trace.cc Made-with: Cursor Co-authored-by: Alexey Kozy * fix: use proper logger API instead of cstdio Co-authored-by: Alexey Kozy * fix: check heap headroom before capturing OOM stack trace deepak1556: "Should there be check for available heap size [for] CurrentStackTrace and formatting" CurrentStackTrace allocates StackTraceInfo + StackFrameInfo on the V8 heap. If the 20 MB bump is partially consumed by the time the interrupt fires, these allocations trigger a secondary OOM. Guard with a 2 MB headroom check. Made-with: Cursor Co-authored-by: Alexey Kozy * fix: handle V8 cage limit when bumping heap for OOM stack capture deepak1556: "Does this bumping work when we are at the cage limit of 4GB" V8's pointer compression cage caps the heap at ~4 GB. When current_heap_limit is already near the ceiling, our 20 MB bump gets clamped to zero and the interrupt never fires. Detect this and record heap info as the final crash key instead of waiting for a stack trace that won't arrive. Made-with: Cursor Co-authored-by: Alexey Kozy * feat: add V8 heap statistics as OOM crash keys deepak1556: "V8 seems to capture heap stats as crash keys but it gets missed today due to the OOM callback override... wonder if we can include that to get some more heuristics in the dump." Record heap used/total/limit/available, per-space stats for old_space and large_object_space, native/detached context counts, and utilization percentage as crash keys. Also add heap stats in the V8OOMErrorCallback in node_bindings.cc for the final OOM crash report. Made-with: Cursor Co-authored-by: Alexey Kozy * feat: support worker thread isolates for OOM stack trace deepak1556: "You need a separate registration for worker threads via WorkerScriptReadyForEvaluationOnWorkerThread but that also means the process global g_registered_isolate would break." Chromium has one V8 isolate per thread (main + one per web worker), so thread_local is equivalent to per-isolate storage. Replace the global atomic + mutex/set with a constinit thread_local OomState* that holds the isolate pointer and per-isolate is_in_oom flag. The void* data parameter on AddNearHeapLimitCallback delivers OomState* directly into callbacks, so the hot path needs no TLS lookup. Add WorkerScriptReadyForEvaluationOnWorkerThread and WillDestroyWorkerContextOnWorkerThread overrides to RendererClientBase so both ElectronRendererClient and ElectronSandboxedRendererClient get worker OOM registration. Update ElectronRendererClient to call the base class in both worker lifecycle methods. Add a web worker OOM test that spawns a dedicated Worker with a memory leak and verifies the stack trace captures the worker function name. Made-with: Cursor Co-authored-by: Alexey Kozy * fix: register OOM callback for all script contexts When context isolation is enabled, ShouldNotifyClient skips DidCreateScriptContext for the main world, but user JS still runs there and can OOM. Register in DidInstallConditionalFeatures which fires for every script context. The TLS dedup guard prevents double-registration on the same isolate. Made-with: Cursor Co-authored-by: Alexey Kozy * fix: guard against division by zero and cage size changes in OOM handler Add a zero-guard on heap_size_limit before computing utilization percentage — maximizes robustness in an OOM code path. Add static_assert on kPtrComprCageReservationSize to catch any upstream V8 change to the cage size at compile time. Made-with: Cursor Co-authored-by: Alexey Kozy * fix: address review feedback on OOM stack trace PR - Remove redundant RegisterOomStackTraceCallback from electron_render_frame_observer.cc; DidCreateScriptContext is sufficient since main world and isolated world share the same isolate - Replace thread_local OomState* with base::ThreadLocalOwnedPointer wrapped in base::NoDestructor per Chromium style for non-trivially destructible types - Change heap-headroom and cage-limit logs from ERROR to INFO since users cannot act on these diagnostics - Add comment explaining why base class is called last in WillDestroyWorkerContextOnWorkerThread (OOM deregistration ordering) Made-with: Cursor Co-authored-by: Alexey Kozy * fix: skip OOM stack trace registration for worklet contexts Worklets can share a thread and isolate via WorkletThreadHolder's per-process singleton pattern. With per-thread OOM state, the first worklet to be destroyed would prematurely remove the callback for any remaining worklets on the same thread. Skip worklets entirely to avoid this; can be revisited with ref-counting if needed. Made-with: Cursor Co-authored-by: Alexey Kozy * fix: prevent dangling raw_ptr in OOM state The OomState held a raw_ptr that outlived the isolate on the main thread: gin::IsolateHolder destroyed the isolate during shutdown, but the OomState (stored in thread-local storage) was only released later in JavascriptEnvironment::~JavascriptEnvironment. This triggers a dangling pointer check when building with enable_dangling_raw_ptr_checks. Register OomState as a gin::PerIsolateData::DisposeObserver so it clears the raw_ptr and removes the NearHeapLimitCallback before the isolate is destroyed, regardless of destructor ordering. Suggested-by: Deepak Mohan Made-with: Cursor Co-authored-by: Alexey Kozy * test: verify OOM crash keys end-to-end via crash reporter Replace stderr-based OOM tests with end-to-end crash dump validation. Instead of parsing log output, start a crash reporter server, trigger renderer OOM, and verify the uploaded crash dump contains the expected `electron.v8-oom.*` annotations — the same code path production crash reports take. Consolidate all OOM test scenarios (basic heap leak, JSON.stringify, web worker) into a single `describe('OOM crash keys')` block inside api-crash-reporter-spec using the existing crash fixture app with new renderer-oom-json and renderer-oom-worker crash types. The web worker test verifies that OOM crash keys are present but does not assert on the JS function name: the 20 MB heap bump may be exhausted before V8 reaches a safe point to fire the stack-capture interrupt, leaving the crash key at "(stack pending)". Increasing the bump or switching to a synchronous capture strategy would fix this but is left for a follow-up. Remove the standalone oom-stack-trace-spec.ts and its fixture app. Made-with: Cursor Co-authored-by: Alexey Kozy * chore: make linter happy --------- Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com> Co-authored-by: Alexey Kozy Co-authored-by: Charles Kerr --- filenames.gni | 2 + shell/common/node_bindings.cc | 20 ++ shell/renderer/electron_renderer_client.cc | 18 +- .../electron_sandboxed_renderer_client.cc | 2 + shell/renderer/oom_stack_trace.cc | 265 ++++++++++++++++++ shell/renderer/oom_stack_trace.h | 17 ++ shell/renderer/renderer_client_base.cc | 26 ++ shell/renderer/renderer_client_base.h | 6 +- spec/api-crash-reporter-spec.ts | 44 +++ spec/fixtures/apps/crash/main.js | 47 ++++ 10 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 shell/renderer/oom_stack_trace.cc create mode 100644 shell/renderer/oom_stack_trace.h diff --git a/filenames.gni b/filenames.gni index 8522de131b..c4bff0487a 100644 --- a/filenames.gni +++ b/filenames.gni @@ -742,6 +742,8 @@ filenames = { "shell/renderer/electron_sandboxed_renderer_client.h", "shell/renderer/electron_smooth_round_rect.cc", "shell/renderer/electron_smooth_round_rect.h", + "shell/renderer/oom_stack_trace.cc", + "shell/renderer/oom_stack_trace.h", "shell/renderer/preload_realm_context.cc", "shell/renderer/preload_realm_context.h", "shell/renderer/preload_utils.cc", diff --git a/shell/common/node_bindings.cc b/shell/common/node_bindings.cc index 16f6b2fc5f..8fe41237bd 100644 --- a/shell/common/node_bindings.cc +++ b/shell/common/node_bindings.cc @@ -51,6 +51,7 @@ #include "third_party/blink/renderer/bindings/core/v8/v8_initializer.h" // nogncheck #include "third_party/electron_node/src/debug_utils.h" #include "third_party/electron_node/src/module_wrap.h" +#include "v8/include/v8-statistics.h" #if !IS_MAS_BUILD() #include "shell/common/crash_keys.h" @@ -200,6 +201,25 @@ void V8OOMErrorCallback(const char* location, const v8::OOMDetails& details) { if (details.detail) { electron::crash_keys::SetCrashKey("electron.v8-oom.detail", details.detail); } + + // TryGetCurrent() instead of GetCurrent() to avoid FATAL if no isolate. + v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); + if (isolate) { + v8::HeapStatistics stats; + isolate->GetHeapStatistics(&stats); + electron::crash_keys::SetCrashKey( + "electron.v8-oom.heap.used", + base::NumberToString(stats.used_heap_size())); + electron::crash_keys::SetCrashKey( + "electron.v8-oom.heap.total", + base::NumberToString(stats.total_heap_size())); + electron::crash_keys::SetCrashKey( + "electron.v8-oom.heap.limit", + base::NumberToString(stats.heap_size_limit())); + electron::crash_keys::SetCrashKey( + "electron.v8-oom.heap.total_available", + base::NumberToString(stats.total_available_size())); + } #endif OOM_CRASH(0); diff --git a/shell/renderer/electron_renderer_client.cc b/shell/renderer/electron_renderer_client.cc index 77f1244a11..e438cdddca 100644 --- a/shell/renderer/electron_renderer_client.cc +++ b/shell/renderer/electron_renderer_client.cc @@ -86,6 +86,9 @@ void ElectronRendererClient::DidCreateScriptContext( v8::Isolate* const isolate, v8::Local renderer_context, content::RenderFrame* render_frame) { + RendererClientBase::DidCreateScriptContext(isolate, renderer_context, + render_frame); + // TODO(zcbenz): Do not create Node environment if node integration is not // enabled. @@ -238,6 +241,8 @@ bool WorkerHasNodeIntegration(blink::ExecutionContext* ec) { void ElectronRendererClient::WorkerScriptReadyForEvaluationOnWorkerThread( v8::Local context) { + RendererClientBase::WorkerScriptReadyForEvaluationOnWorkerThread(context); + auto* ec = blink::ExecutionContext::From(context); if (!WorkerHasNodeIntegration(ec)) return; @@ -251,12 +256,15 @@ void ElectronRendererClient::WorkerScriptReadyForEvaluationOnWorkerThread( void ElectronRendererClient::WillDestroyWorkerContextOnWorkerThread( v8::Local context) { auto* ec = blink::ExecutionContext::From(context); - if (!WorkerHasNodeIntegration(ec)) - return; + if (WorkerHasNodeIntegration(ec)) { + auto* current = WebWorkerObserver::GetCurrent(); + if (current) + current->ContextWillDestroy(context); + } - auto* current = WebWorkerObserver::GetCurrent(); - if (current) - current->ContextWillDestroy(context); + // Call base class last: OOM callback deregistration must happen after + // all other cleanup that might still trigger V8 heap operations. + RendererClientBase::WillDestroyWorkerContextOnWorkerThread(context); } void ElectronRendererClient::SetUpWebAssemblyTrapHandler() { diff --git a/shell/renderer/electron_sandboxed_renderer_client.cc b/shell/renderer/electron_sandboxed_renderer_client.cc index 3eb0810d7f..57f3f5f8cc 100644 --- a/shell/renderer/electron_sandboxed_renderer_client.cc +++ b/shell/renderer/electron_sandboxed_renderer_client.cc @@ -111,6 +111,8 @@ void ElectronSandboxedRendererClient::DidCreateScriptContext( v8::Isolate* const isolate, v8::Local context, content::RenderFrame* render_frame) { + RendererClientBase::DidCreateScriptContext(isolate, context, render_frame); + // Only allow preload for the main frame or // For devtools we still want to run the preload_bundle script // Or when nodeSupport is explicitly enabled in sub frames diff --git a/shell/renderer/oom_stack_trace.cc b/shell/renderer/oom_stack_trace.cc new file mode 100644 index 0000000000..bcd386c008 --- /dev/null +++ b/shell/renderer/oom_stack_trace.cc @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Anysphere, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/renderer/oom_stack_trace.h" + +#include +#include +#include +#include + +#include "base/logging.h" +#include "base/memory/raw_ptr.h" +#include "base/no_destructor.h" +#include "base/strings/string_number_conversions.h" +#include "base/threading/thread_local.h" +#include "electron/mas.h" +#include "gin/per_isolate_data.h" +#include "shell/common/crash_keys.h" +#include "third_party/abseil-cpp/absl/strings/str_format.h" +#include "v8/include/v8-exception.h" +#include "v8/include/v8-internal.h" +#include "v8/include/v8-isolate.h" +#include "v8/include/v8-local-handle.h" +#include "v8/include/v8-primitive.h" +#include "v8/include/v8-statistics.h" + +namespace electron { + +namespace { + +// Forward-declare so OomState can reference it. +size_t NearHeapLimitCallback(void* data, + size_t current_heap_limit, + size_t initial_heap_limit); + +struct OomState : public gin::PerIsolateData::DisposeObserver { + raw_ptr isolate; + std::atomic is_in_oom{false}; + + // gin::PerIsolateData::DisposeObserver: + void OnBeforeDispose(v8::Isolate* disposing_isolate) override { + if (!is_in_oom.load()) { + disposing_isolate->RemoveNearHeapLimitCallback(NearHeapLimitCallback, 0); + } + isolate = nullptr; + } + void OnBeforeMicrotasksRunnerDispose(v8::Isolate* /*disposing*/) override {} + void OnDisposed() override {} +}; + +base::ThreadLocalOwnedPointer& GetOomState() { + static base::NoDestructor> instance; + return *instance; +} + +std::string FormatStackTrace(v8::Isolate* isolate, + v8::Local stack) { + std::string result; + int frame_count = stack->GetFrameCount(); + for (int i = 0; i < frame_count; i++) { + v8::Local frame = stack->GetFrame(isolate, i); + + v8::Local function_name = frame->GetFunctionName(); + v8::Local script_name = frame->GetScriptName(); + int line = frame->GetLineNumber(); + int col = frame->GetColumn(); + + std::string func_str = "(anonymous)"; + if (!function_name.IsEmpty()) { + v8::String::Utf8Value utf8(isolate, function_name); + if (*utf8) { + func_str = *utf8; + } + } + + std::string script_str = ""; + if (!script_name.IsEmpty()) { + v8::String::Utf8Value utf8(isolate, script_name); + if (*utf8) { + script_str = *utf8; + } + } + + absl::StrAppendFormat(&result, " #%d %s (%s:%d:%d)\n", i, func_str, + script_str, line, col); + } + return result; +} + +// Runs at the next V8 safe point after the heap limit was hit. +// At a safe point, all frames have deoptimization data available, +// so CurrentStackTrace won't FATAL on optimized frames. +void CaptureStackOnInterrupt(v8::Isolate* isolate, void* data) { + if (!isolate->InContext()) { + return; + } + + v8::HeapStatistics stats; + isolate->GetHeapStatistics(&stats); + // CurrentStackTrace allocates StackTraceInfo + StackFrameInfo objects on the + // V8 managed heap. If the 20 MB bump from NearHeapLimitCallback has been + // partially consumed by the time this interrupt fires, these allocations + // could trigger a secondary OOM. Check headroom before proceeding. + // Note: use addition form to avoid unsigned underflow -- in OOM scenarios, + // used_heap_size can transiently exceed heap_size_limit. + constexpr size_t kMinHeadroom = 2 * 1024 * 1024; // 2 MB + if (stats.used_heap_size() + kMinHeadroom > stats.heap_size_limit()) { + LOG(INFO) << "Skipping JS stack capture: insufficient heap headroom"; + return; + } + + v8::HandleScope handle_scope(isolate); + v8::Local stack = + v8::StackTrace::CurrentStackTrace(isolate, 10); + if (stack.IsEmpty() || stack->GetFrameCount() == 0) { + return; + } + + std::string js_stack = FormatStackTrace(isolate, stack); + if (!js_stack.empty()) { +#if !IS_MAS_BUILD() + crash_keys::SetCrashKey("electron.v8-oom.stack", js_stack); +#endif + LOG(ERROR) << "\n<--- JS stacktrace (captured at safe point) --->\n" + << js_stack; + } +} + +// V8's pointer compression cage limits the heap to kPtrComprCageReservationSize +// (~4 GB). After this callback returns a new limit, V8 clamps it via +// Heap::AllocatorLimitOnMaxOldGenerationSize() to at most that cage size. +// If current_heap_limit is already near the ceiling the bump is effectively +// zero, the interrupt never gets enough headroom to fire, and we never capture +// a stack trace. When that happens we fall back to recording heap info only. +size_t NearHeapLimitCallback(void* data, + size_t current_heap_limit, + size_t initial_heap_limit) { + auto* state = static_cast(data); + v8::Isolate* isolate = state->isolate; + + if (state->is_in_oom.exchange(true)) { + return current_heap_limit; + } + + v8::HeapStatistics stats; + isolate->GetHeapStatistics(&stats); + std::string heap_info = absl::StrFormat("Heap: used=%.1fMB limit=%.1fMB", + stats.used_heap_size() / 1048576.0, + stats.heap_size_limit() / 1048576.0); + LOG(ERROR) << "\n<--- Near heap limit --->\n" << heap_info; + +#if !IS_MAS_BUILD() + crash_keys::SetCrashKey("electron.v8-oom.stack", + heap_info + " (stack pending)"); + + crash_keys::SetCrashKey("electron.v8-oom.heap.used", + base::NumberToString(stats.used_heap_size())); + crash_keys::SetCrashKey("electron.v8-oom.heap.total", + base::NumberToString(stats.total_heap_size())); + crash_keys::SetCrashKey("electron.v8-oom.heap.limit", + base::NumberToString(stats.heap_size_limit())); + crash_keys::SetCrashKey("electron.v8-oom.heap.total_available", + base::NumberToString(stats.total_available_size())); + crash_keys::SetCrashKey("electron.v8-oom.heap.total_physical", + base::NumberToString(stats.total_physical_size())); + crash_keys::SetCrashKey("electron.v8-oom.heap.malloced_memory", + base::NumberToString(stats.malloced_memory())); + crash_keys::SetCrashKey("electron.v8-oom.heap.external_memory", + base::NumberToString(stats.external_memory())); + crash_keys::SetCrashKey( + "electron.v8-oom.heap.native_contexts", + base::NumberToString(stats.number_of_native_contexts())); + crash_keys::SetCrashKey( + "electron.v8-oom.heap.detached_contexts", + base::NumberToString(stats.number_of_detached_contexts())); + + double utilization = stats.heap_size_limit() > 0 + ? static_cast(stats.used_heap_size()) / + stats.heap_size_limit() * 100.0 + : 100.0; + crash_keys::SetCrashKey("electron.v8-oom.heap.utilization_pct", + absl::StrFormat("%.1f", utilization)); + + v8::HeapSpaceStatistics space_stats; + for (size_t i = 0; i < isolate->NumberOfHeapSpaces(); i++) { + isolate->GetHeapSpaceStatistics(&space_stats, i); + if (std::string_view(space_stats.space_name()) == "old_space") { + crash_keys::SetCrashKey( + "electron.v8-oom.old_space.used", + base::NumberToString(space_stats.space_used_size())); + crash_keys::SetCrashKey("electron.v8-oom.old_space.size", + base::NumberToString(space_stats.space_size())); + } else if (std::string_view(space_stats.space_name()) == + "large_object_space") { + crash_keys::SetCrashKey( + "electron.v8-oom.lo_space.used", + base::NumberToString(space_stats.space_used_size())); + crash_keys::SetCrashKey("electron.v8-oom.lo_space.size", + base::NumberToString(space_stats.space_size())); + } + } +#endif + + // Request an interrupt to capture the JS stack at the next safe point, + // where optimized frames have deoptimization data available. + // CurrentStackTrace is unsafe to call directly here because V8 may + // FATAL on optimized frames missing deopt info during OOM. + isolate->RequestInterrupt(CaptureStackOnInterrupt, state); + + // Remove ourselves and bump the limit to give V8 room to reach a safe + // point where the interrupt can fire and capture the stack trace. + isolate->RemoveNearHeapLimitCallback(NearHeapLimitCallback, 0); + + constexpr size_t kHeapBump = 20 * 1024 * 1024; + size_t new_limit = current_heap_limit + kHeapBump; + +#ifdef V8_COMPRESS_POINTERS + constexpr size_t kCageLimit = v8::internal::kPtrComprCageReservationSize; + static_assert(kCageLimit == size_t{1} << 32, + "Cage size changed; review heap bump logic"); +#else + constexpr size_t kCageLimit = std::numeric_limits::max(); +#endif + + if (current_heap_limit >= kCageLimit - kHeapBump) { + // The bump will be clamped by V8 to the cage ceiling, leaving no + // headroom for the interrupt to fire. Record what we can now. +#if !IS_MAS_BUILD() + crash_keys::SetCrashKey("electron.v8-oom.stack", + heap_info + " (at cage limit, stack unavailable)"); +#endif + LOG(INFO) << "Near V8 cage limit; stack trace capture may not succeed"; + } + + return new_limit; +} + +} // namespace + +void RegisterOomStackTraceCallback(v8::Isolate* isolate) { + auto& tls = GetOomState(); + if (tls.Get()) + return; + auto state = std::make_unique(); + state->isolate = isolate; + OomState* raw = state.get(); + gin::PerIsolateData::From(isolate)->AddDisposeObserver(raw); + tls.Set(std::move(state)); + isolate->AddNearHeapLimitCallback(NearHeapLimitCallback, raw); +} + +void UnregisterOomStackTraceCallback(v8::Isolate* isolate) { + auto& tls = GetOomState(); + OomState* state = tls.Get(); + if (!state || state->isolate != isolate) + return; + if (!state->is_in_oom.load()) { + isolate->RemoveNearHeapLimitCallback(NearHeapLimitCallback, 0); + } + gin::PerIsolateData::From(isolate)->RemoveDisposeObserver(state); + tls.Set(nullptr); +} + +} // namespace electron diff --git a/shell/renderer/oom_stack_trace.h b/shell/renderer/oom_stack_trace.h new file mode 100644 index 0000000000..12a5575d4b --- /dev/null +++ b/shell/renderer/oom_stack_trace.h @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Anysphere, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_RENDERER_OOM_STACK_TRACE_H_ +#define ELECTRON_SHELL_RENDERER_OOM_STACK_TRACE_H_ + +#include "v8/include/v8-isolate.h" + +namespace electron { + +void RegisterOomStackTraceCallback(v8::Isolate* isolate); +void UnregisterOomStackTraceCallback(v8::Isolate* isolate); + +} // namespace electron + +#endif // ELECTRON_SHELL_RENDERER_OOM_STACK_TRACE_H_ diff --git a/shell/renderer/renderer_client_base.cc b/shell/renderer/renderer_client_base.cc index 5f4dad2cb1..c300f7fd22 100644 --- a/shell/renderer/renderer_client_base.cc +++ b/shell/renderer/renderer_client_base.cc @@ -36,6 +36,7 @@ #include "shell/renderer/content_settings_observer.h" #include "shell/renderer/electron_api_service_impl.h" #include "shell/renderer/electron_autofill_agent.h" +#include "shell/renderer/oom_stack_trace.h" #include "third_party/abseil-cpp/absl/strings/str_format.h" #include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h" #include "third_party/blink/public/common/associated_interfaces/associated_interface_registry.h" @@ -49,6 +50,7 @@ #include "third_party/blink/public/web/web_script_source.h" #include "third_party/blink/public/web/web_security_policy.h" #include "third_party/blink/public/web/web_view.h" +#include "third_party/blink/renderer/core/execution_context/execution_context.h" // nogncheck #include "third_party/blink/renderer/platform/media/multi_buffer_data_source.h" // nogncheck #include "third_party/blink/renderer/platform/weborigin/scheme_registry.h" // nogncheck #include "third_party/widevine/cdm/buildflags.h" @@ -361,6 +363,13 @@ void RendererClientBase::GetInterface( } #endif +void RendererClientBase::DidCreateScriptContext( + v8::Isolate* isolate, + v8::Local context, + content::RenderFrame* render_frame) { + RegisterOomStackTraceCallback(isolate); +} + void RendererClientBase::DidClearWindowObject( content::RenderFrame* render_frame) { // Make sure every page will get a script context created. @@ -536,6 +545,23 @@ void RendererClientBase::WillDestroyServiceWorkerContextOnWorkerThread( #endif } +void RendererClientBase::WorkerScriptReadyForEvaluationOnWorkerThread( + v8::Local context) { + // Worklets can share a thread and isolate (via WorkletThreadHolder), so the + // per-thread OOM state would be prematurely removed when the first worklet + // is destroyed. Skip worklets for now; can be revisited with ref-counting. + if (blink::ExecutionContext::From(context)->IsWorkletGlobalScope()) + return; + RegisterOomStackTraceCallback(v8::Isolate::GetCurrent()); +} + +void RendererClientBase::WillDestroyWorkerContextOnWorkerThread( + v8::Local context) { + if (blink::ExecutionContext::From(context)->IsWorkletGlobalScope()) + return; + UnregisterOomStackTraceCallback(v8::Isolate::GetCurrent()); +} + void RendererClientBase::WebViewCreated(blink::WebView* web_view, bool was_created_by_renderer, const url::Origin* outermost_origin) { diff --git a/shell/renderer/renderer_client_base.h b/shell/renderer/renderer_client_base.h index dd8fa7cd3b..2731aca9ea 100644 --- a/shell/renderer/renderer_client_base.h +++ b/shell/renderer/renderer_client_base.h @@ -58,7 +58,7 @@ class RendererClientBase : public content::ContentRendererClient virtual void DidCreateScriptContext(v8::Isolate* isolate, v8::Local context, - content::RenderFrame* render_frame) = 0; + content::RenderFrame* render_frame); virtual void WillReleaseScriptContext(v8::Isolate* isolate, v8::Local context, content::RenderFrame* render_frame) = 0; @@ -141,6 +141,10 @@ class RendererClientBase : public content::ContentRendererClient const GURL& service_worker_scope, const GURL& script_url, const blink::ServiceWorkerToken& service_worker_token) override; + void WorkerScriptReadyForEvaluationOnWorkerThread( + v8::Local context) override; + void WillDestroyWorkerContextOnWorkerThread( + v8::Local context) override; void WebViewCreated(blink::WebView* web_view, bool was_created_by_renderer, const url::Origin* outermost_origin) override; diff --git a/spec/api-crash-reporter-spec.ts b/spec/api-crash-reporter-spec.ts index 683acf1b4c..63201a5bcf 100644 --- a/spec/api-crash-reporter-spec.ts +++ b/spec/api-crash-reporter-spec.ts @@ -33,6 +33,13 @@ type CrashInfo = { longParam: string | undefined 'electron.v8-fatal.location': string | undefined 'electron.v8-fatal.message': string | undefined + 'electron.v8-oom.stack': string | undefined + 'electron.v8-oom.heap.used': string | undefined + 'electron.v8-oom.heap.total': string | undefined + 'electron.v8-oom.heap.limit': string | undefined + 'electron.v8-oom.heap.utilization_pct': string | undefined + 'electron.v8-oom.heap.native_contexts': string | undefined + 'electron.v8-oom.heap.detached_contexts': string | undefined } function checkCrash (expectedProcessType: string, fields: CrashInfo) { @@ -305,6 +312,43 @@ ifdescribe(!isLinuxOnArm && !process.mas && !process.env.DISABLE_CRASH_REPORTER_ expect(crash['electron.v8-fatal.message']).to.equal('Circular extension dependency'); }); }); + + describe('OOM crash keys', () => { + it('reports OOM stack trace and heap statistics when renderer runs out of memory', async function () { + this.timeout(120000); + const { port, waitForCrash } = await startServer(); + runCrashApp('renderer-oom', port, ['--js-flags=--max-old-space-size=128']); + const crash = await waitForCrash(); + expect(crash.process_type).to.equal('renderer'); + expect(crash['electron.v8-oom.stack']).to.be.a('string'); + expect(crash['electron.v8-oom.stack']).to.include('oomTrigger'); + expect(crash['electron.v8-oom.heap.used']).to.be.a('string'); + expect(crash['electron.v8-oom.heap.limit']).to.be.a('string'); + }); + + it('captures the calling function on JSON.stringify OOM', async function () { + this.timeout(120000); + const { port, waitForCrash } = await startServer(); + runCrashApp('renderer-oom-json', port, ['--js-flags=--max-old-space-size=128']); + const crash = await waitForCrash(); + expect(crash.process_type).to.equal('renderer'); + expect(crash['electron.v8-oom.stack']).to.be.a('string'); + expect(crash['electron.v8-oom.stack']).to.include('serializeData'); + }); + + it('captures OOM crash keys inside a web worker', async function () { + this.timeout(120000); + const { port, waitForCrash } = await startServer(); + runCrashApp('renderer-oom-worker', port, ['--js-flags=--max-old-space-size=128']); + const crash = await waitForCrash(); + expect(crash.process_type).to.equal('renderer'); + const oomStack = crash['electron.v8-oom.stack']; + expect(oomStack).to.be.a('string'); + expect(oomStack!.length).to.be.greaterThan(0); + expect(crash['electron.v8-oom.heap.used']).to.be.a('string'); + expect(crash['electron.v8-oom.heap.limit']).to.be.a('string'); + }); + }); }); ifdescribe(!isLinuxOnArm)('extra parameter limits', () => { diff --git a/spec/fixtures/apps/crash/main.js b/spec/fixtures/apps/crash/main.js index bcb1bea2bb..0329e1319e 100644 --- a/spec/fixtures/apps/crash/main.js +++ b/spec/fixtures/apps/crash/main.js @@ -64,6 +64,53 @@ app.whenReady().then(() => { `); }); w.loadURL('about:blank'); + } else if (crashType === 'renderer-oom') { + const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } }); + w.webContents.on('render-process-gone', () => process.exit(0)); + w.webContents.on('did-finish-load', () => { + w.webContents.executeJavaScript(` + function oomTrigger() { + const arr = []; + while (true) arr.push(new Array(10000).fill('x'.repeat(100))); + } + oomTrigger(); + `); + }); + w.loadURL('about:blank'); + } else if (crashType === 'renderer-oom-json') { + const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } }); + w.webContents.on('render-process-gone', () => process.exit(0)); + w.webContents.on('did-finish-load', () => { + w.webContents.executeJavaScript(` + function serializeData() { + const results = []; + while (true) { + const chunk = {}; + for (let i = 0; i < 1000; i++) chunk['k' + i] = 'x'.repeat(500); + results.push(JSON.stringify(chunk)); + } + } + serializeData(); + `); + }); + w.loadURL('about:blank'); + } else if (crashType === 'renderer-oom-worker') { + const w = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: true, contextIsolation: false } }); + w.webContents.on('render-process-gone', () => process.exit(0)); + w.webContents.on('did-finish-load', () => { + w.webContents.executeJavaScript(` + const blob = new Blob([\` + function workerLeakMemory() { + const arr = []; + while (true) { arr.push(new Array(1000).fill("x".repeat(1000))); } + } + function triggerWorkerOom() { workerLeakMemory(); } + triggerWorkerOom(); + \`], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + `); + }); + w.loadURL('about:blank'); } else if (crashType === 'node') { const crashPath = path.join(__dirname, 'node-crash.js'); const child = childProcess.fork(crashPath, { silent: true });