// 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