commit 902433d0875dfe09025b6db82accbd2c358735d1
parent 041f7b579046f2b608f553b70886b74ad5970e85
Author: Matthew Gaudet <mgaudet@mozilla.com>
Date: Fri, 19 Dec 2025 16:59:48 +0000
Bug 1989115 - Implement OOM stack trace capture r=jandem,iain
As well as a "get OOM stack trace" test function.
CheckForInterruptNoCallbacks in the ErrorReturnContinuation case means we can
capture OOM stack traces before the bottom-most frames are popped.
CheckForInterruptCallbacks in HandleException means we can capture OOM stack
traces for JITs before the bottom-most frames are popped.
Differential Revision: https://phabricator.services.mozilla.com/D265373
Diffstat:
9 files changed, 193 insertions(+), 5 deletions(-)
diff --git a/js/src/builtin/TestingFunctions.cpp b/js/src/builtin/TestingFunctions.cpp
@@ -10043,6 +10043,30 @@ static bool TestingFunc_SupportDifferentialTesting(JSContext* cx, unsigned argc,
return true;
}
+static bool GetLastOOMStackTrace(JSContext* cx, unsigned argc, Value* vp) {
+ CallArgs args = CallArgsFromVp(argc, vp);
+
+ if (!cx->hasOOMStackTrace()) {
+ args.rval().setNull();
+ return true;
+ }
+
+ const char* stackTrace = cx->getOOMStackTrace();
+ MOZ_ASSERT(stackTrace);
+
+ // The stackTrace persists, so we can clear the flag here in case copying the
+ // string fails
+ cx->unsetOOMStackTrace();
+
+ JSString* str = JS_NewStringCopyZ(cx, stackTrace);
+ if (!str) {
+ return false;
+ }
+
+ args.rval().setString(str);
+ return true;
+}
+
// clang-format off
static const JSFunctionSpecWithHelp TestingFunctions[] = {
JS_FN_HELP("gc", ::GC, 0, 0,
@@ -11161,6 +11185,12 @@ JS_FN_HELP("isSmallFunction", IsSmallFunction, 1, 0,
"popAllFusesInRealm()",
" Pops all the fuses in the current realm"),
+ JS_FN_HELP("getLastOOMStackTrace", GetLastOOMStackTrace, 0, 0,
+"getLastOOMStackTrace()",
+" Returns the stack trace captured from the most recent out-of-memory exception,\n"
+" or null if no OOM stack trace is available. The stack trace shows the JavaScript\n"
+" call stack at the time the out-of-memory condition occurred."),
+
JS_FN_HELP("popAllFusesInRuntime", PopAllFusesInRuntime, 0, 0,
"popAllFusesInRuntime()",
" Pops all the fuses in the runtime"),
diff --git a/js/src/jit-test/tests/test_oom_comprehensive.js b/js/src/jit-test/tests/test_oom_comprehensive.js
@@ -0,0 +1,55 @@
+// |jit-test| --setpref=experimental.capture_oom_stack_trace=true; skip-if: !this.hasOwnProperty("getLastOOMStackTrace")
+
+function assertCapturesOOMStackTrace(f, lineNo) {
+ // Clear any existing trace
+ var initialTrace = getLastOOMStackTrace();
+ assertEq(initialTrace, null);
+
+ try {
+ f();
+ assertEq(true, false, "Expected an OOM exception");
+ } catch (e) {
+ print("✓ Exception caught: " + e);
+
+ // Check for captured stack trace
+ var finalTrace = getLastOOMStackTrace();
+ assertEq(finalTrace !== null, true, "Expected a stack trace after OOM");
+
+ print(finalTrace);
+
+ // Detailed analysis
+ var lines = finalTrace.split('\n');
+ let re = new RegExp("#0.*test_oom_comprehensive\.js:" + lineNo)
+ assertEq(re.test(lines[0]), true, "Missing innermost frame");
+ }
+}
+
+// Interpreter captures innermost frame
+assertCapturesOOMStackTrace(function () {
+ function deepFunction() {
+ function evenDeeper() {
+ throwOutOfMemory();
+ }
+ evenDeeper();
+ }
+ deepFunction();
+}, 31);
+
+if (getJitCompilerOptions()['baseline.enable']){
+ // JITs capture innermost frame
+ assertCapturesOOMStackTrace(function () {
+ function deepFunction(shouldOOM) {
+ function evenDeeper() {
+ if (shouldOOM) {
+ assertEq(inJit(), true, "Should be JIT-compiled");
+ throwOutOfMemory();
+ }
+ }
+ evenDeeper();
+ }
+ const MAX = 150;
+ for (let i = 0; i < MAX; i++) {
+ deepFunction(i >= MAX - 1);
+ }
+ }, 45);
+}
diff --git a/js/src/jit/JitFrames.cpp b/js/src/jit/JitFrames.cpp
@@ -691,6 +691,10 @@ static JitFrameLayout* GetLastProfilingFrame(ResumeFromException* rfe) {
void HandleException(ResumeFromException* rfe) {
JSContext* cx = TlsContext.get();
+ if (!CheckForOOMStackTraceInterrupt(cx)) {
+ return;
+ }
+
cx->realm()->localAllocSite = nullptr;
#ifdef DEBUG
if (!IsPortableBaselineInterpreterEnabled()) {
diff --git a/js/src/vm/Interpreter.cpp b/js/src/vm/Interpreter.cpp
@@ -4472,6 +4472,7 @@ error:
goto successful_return_continuation;
case ErrorReturnContinuation:
+ CheckForOOMStackTraceInterrupt(cx);
interpReturnOK = false;
goto return_continuation;
diff --git a/js/src/vm/JSContext-inl.h b/js/src/vm/JSContext-inl.h
@@ -258,6 +258,17 @@ MOZ_ALWAYS_INLINE bool CallNativeImpl(JSContext* cx, NativeImpl impl,
return ok;
}
+// OOM interrupts don't call the interrupt callbacks, so we don't need
+// to worry about if there is an exception pending. We do want to handle
+// the interrupt sooner than for other interrupts so we capture a precise
+// stack trace.
+MOZ_ALWAYS_INLINE bool CheckForOOMStackTraceInterrupt(JSContext* cx) {
+ if (MOZ_UNLIKELY(cx->hasPendingInterrupt(InterruptReason::OOMStackTrace))) {
+ return cx->handleInterruptNoCallbacks();
+ }
+ return true;
+}
+
MOZ_ALWAYS_INLINE bool CheckForInterrupt(JSContext* cx) {
MOZ_ASSERT(!cx->isExceptionPending());
// Add an inline fast-path since we have to check for interrupts in some hot
diff --git a/js/src/vm/JSContext.cpp b/js/src/vm/JSContext.cpp
@@ -56,7 +56,9 @@
#include "util/NativeStack.h"
#include "util/Text.h"
#include "util/WindowsWrapper.h"
-#include "vm/BytecodeUtil.h" // JSDVG_IGNORE_STACK
+#include "js/friend/DumpFunctions.h" // for stack trace utilities
+#include "js/Printer.h" // for FixedBufferPrinter
+#include "vm/BytecodeUtil.h" // JSDVG_IGNORE_STACK
#include "vm/ErrorObject.h"
#include "vm/ErrorReporting.h"
#include "vm/FrameIter.h"
@@ -278,6 +280,8 @@ void JSContext::onOutOfMemory() {
runtime()->hadOutOfMemory = true;
gc::AutoSuppressGC suppressGC(this);
+ requestInterrupt(js::InterruptReason::OOMStackTrace);
+
/* Report the oom. */
if (JS::OutOfMemoryCallback oomCallback = runtime()->oomCallback) {
oomCallback(this, runtime()->oomCallbackData);
@@ -1290,11 +1294,19 @@ JSContext::JSContext(JSRuntime* runtime, const JS::ContextOptions& options)
canSkipEnqueuingJobs(this, false),
promiseRejectionTrackerCallback(this, nullptr),
promiseRejectionTrackerCallbackData(this, nullptr),
+ oomStackTraceBuffer_(this, nullptr),
+ oomStackTraceBufferValid_(this, false),
bypassCSPForDebugger(this, false),
insideExclusiveDebuggerOnEval(this, nullptr),
microTaskQueues(this) {
MOZ_ASSERT(static_cast<JS::RootingContext*>(this) ==
JS::RootingContext::get(this));
+
+ if (JS::Prefs::experimental_capture_oom_stack_trace()) {
+ // Allocate pre-allocated buffer for OOM stack traces
+ oomStackTraceBuffer_ =
+ static_cast<char*>(js_calloc(OOMStackTraceBufferSize));
+ }
}
JSContext::~JSContext() {
@@ -1324,9 +1336,41 @@ JSContext::~JSContext() {
irregexp::DestroyIsolate(isolate.ref());
}
+ // Free the pre-allocated OOM stack trace buffer
+ if (oomStackTraceBuffer_) {
+ js_free(oomStackTraceBuffer_);
+ }
+
TlsContext.set(nullptr);
}
+void JSContext::unsetOOMStackTrace() { oomStackTraceBufferValid_ = false; }
+
+const char* JSContext::getOOMStackTrace() const {
+ if (!oomStackTraceBufferValid_ || !oomStackTraceBuffer_) {
+ return nullptr;
+ }
+ return oomStackTraceBuffer_;
+}
+
+bool JSContext::hasOOMStackTrace() const { return oomStackTraceBufferValid_; }
+
+void JSContext::captureOOMStackTrace() {
+ // Clear any existing stack trace
+ oomStackTraceBufferValid_ = false;
+
+ if (!oomStackTraceBuffer_) {
+ return; // Buffer not available
+ }
+
+ // Write directly to pre-allocated buffer to avoid any memory allocation
+ FixedBufferPrinter fbp(oomStackTraceBuffer_, OOMStackTraceBufferSize);
+ js::DumpBacktrace(this, fbp);
+ MOZ_ASSERT(strlen(oomStackTraceBuffer_) < OOMStackTraceBufferSize);
+
+ oomStackTraceBufferValid_ = true;
+}
+
void JSContext::setRuntime(JSRuntime* rt) {
MOZ_ASSERT(!resolvingList);
MOZ_ASSERT(!compartment());
diff --git a/js/src/vm/JSContext.h b/js/src/vm/JSContext.h
@@ -151,6 +151,7 @@ enum class InterruptReason : uint32_t {
AttachOffThreadCompilations = 1 << 2,
CallbackUrgent = 1 << 3,
CallbackCanWait = 1 << 4,
+ OOMStackTrace = 1 << 5,
};
enum class ShouldCaptureStack { Maybe, Always };
@@ -709,6 +710,12 @@ struct JS_PUBLIC_API JSContext : public JS::RootingContext,
#endif
}
+ // OOM stack trace buffer management
+ void unsetOOMStackTrace();
+ const char* getOOMStackTrace() const;
+ bool hasOOMStackTrace() const;
+ void captureOOMStackTrace();
+
js::ContextData<int32_t> reportGranularity; /* see vm/Probes.h */
js::ContextData<js::AutoResolving*> resolvingList;
@@ -927,6 +934,7 @@ struct JS_PUBLIC_API JSContext : public JS::RootingContext,
// that's fine.
void requestInterrupt(js::InterruptReason reason);
bool handleInterrupt();
+ bool handleInterruptNoCallbacks();
MOZ_ALWAYS_INLINE bool hasAnyPendingInterrupt() const {
static_assert(sizeof(interruptBits_) == sizeof(uint32_t),
@@ -981,6 +989,14 @@ struct JS_PUBLIC_API JSContext : public JS::RootingContext,
promiseRejectionTrackerCallback;
js::ContextData<void*> promiseRejectionTrackerCallbackData;
+ // Pre-allocated buffer for storing out-of-memory stack traces.
+ // This buffer is allocated during context initialization to avoid
+ // allocation during OOM conditions. The buffer stores a formatted
+ // stack trace string that can be retrieved by privileged JavaScript.
+ static constexpr size_t OOMStackTraceBufferSize = 4096;
+ js::ContextData<char*> oomStackTraceBuffer_;
+ js::ContextData<bool> oomStackTraceBufferValid_;
+
JSObject* getIncumbentGlobal(JSContext* cx);
bool enqueuePromiseJob(JSContext* cx, js::HandleFunction job,
js::HandleObject promise,
diff --git a/js/src/vm/Runtime.cpp b/js/src/vm/Runtime.cpp
@@ -394,7 +394,7 @@ static bool InvokeInterruptCallbacks(JSContext* cx) {
return stop;
}
-static bool HandleInterrupt(JSContext* cx, bool invokeCallback) {
+static bool HandleInterrupt(JSContext* cx, bool invokeCallback, bool oomStackTrace) {
MOZ_ASSERT(!cx->zone()->isAtomsZone());
cx->runtime()->gc.gcIfRequested();
@@ -403,7 +403,13 @@ static bool HandleInterrupt(JSContext* cx, bool invokeCallback) {
// offthread compilation.
jit::AttachFinishedCompilations(cx);
- // Don't call the interrupt callback if we only interrupted for GC or Ion.
+ if (oomStackTrace) {
+ // Capture OOM stack trace this way because we don't have memory to handle
+ // it the way ComputeStackString does.
+ cx->captureOOMStackTrace();
+ }
+
+ // Don't call the interrupt callback if we only interrupted for GC, Ion, or OOM.
if (!invokeCallback) {
return true;
}
@@ -482,9 +488,23 @@ bool JSContext::handleInterrupt() {
bool invokeCallback =
hasPendingInterrupt(InterruptReason::CallbackUrgent) ||
hasPendingInterrupt(InterruptReason::CallbackCanWait);
+ bool oomStackTrace = hasPendingInterrupt(InterruptReason::OOMStackTrace);
interruptBits_ = 0;
resetJitStackLimit();
- return HandleInterrupt(this, invokeCallback);
+ return HandleInterrupt(this, invokeCallback, oomStackTrace);
+ }
+ return true;
+}
+
+bool JSContext::handleInterruptNoCallbacks() {
+ MOZ_ASSERT(CurrentThreadCanAccessRuntime(runtime()));
+ if (hasAnyPendingInterrupt() || jitStackLimit == JS::NativeStackLimitMin) {
+ bool oomStackTrace = hasPendingInterrupt(InterruptReason::OOMStackTrace);
+ clearPendingInterrupt(js::InterruptReason::OOMStackTrace);
+ if (!hasAnyPendingInterrupt()) {
+ resetJitStackLimit();
+ }
+ return HandleInterrupt(this, false, oomStackTrace);
}
return true;
}
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
@@ -9075,7 +9075,7 @@
mirror: always
set_spidermonkey_pref: startup
- # Experimental support for Iterator Chunking in JavaScript.
+ # Experimental support for Iterator Chunking in JavaScript.
- name: javascript.options.experimental.iterator_chunking
type: bool
value: false
@@ -9097,6 +9097,13 @@
set_spidermonkey_pref: startup
#endif // NIGHTLY_BUILD
+ # Capture stack traces for OOM
+- name: javascript.options.experimental.capture_oom_stack_trace
+ type: bool
+ value: @IS_NIGHTLY_BUILD@
+ mirror: always
+ set_spidermonkey_pref: startup
+
# Whether to Baseline-compile self-hosted functions the first time they are
# used and cache the result.
- name: javascript.options.experimental.self_hosted_cache