Bug 1886820 - Add experimental support for Error.captureStackTrace r=jandem

Differential Revision: https://phabricator.services.mozilla.com/D230327
This commit is contained in:
Matthew Gaudet
2025-01-16 15:31:28 +00:00
parent ab4b380f2d
commit 2c17d3b90e
11 changed files with 520 additions and 20 deletions

View File

@@ -19,7 +19,7 @@
#include "js/NativeStackLimits.h"
#include "js/Principals.h" // JSPrincipals, JS_HoldPrincipals, JS_DropPrincipals
#include "js/TypeDecls.h" // JSContext, Handle*, MutableHandle*
#include "js/RootingAPI.h"
/**
* Set the size of the native stack that should not be exceed. To disable
@@ -142,7 +142,8 @@ using StackCapture = mozilla::Variant<AllFrames, MaxFrames, FirstSubsumedFrame>;
*/
extern JS_PUBLIC_API bool CaptureCurrentStack(
JSContext* cx, MutableHandleObject stackp,
StackCapture&& capture = StackCapture(AllFrames()));
StackCapture&& capture = StackCapture(AllFrames()),
HandleObject startAfter = nullptr);
/**
* Returns true if capturing stack trace data to associate with an asynchronous

View File

@@ -0,0 +1,41 @@
// |jit-test| --setpref=experimental.error_capture_stack_trace; --no-threads; --fast-warmup;
load(libdir + "asserts.js");
function caller(f) {
return f();
}
function fill() {
let x = {}
Error.captureStackTrace(x, caller);
return x;
}
let iter = 1500
for (let i = 0; i < iter; i++) {
// Make sure caller is an IonFrame.
caller(fill);
}
function test_jit_elision() {
let x = caller(fill);
let { stack } = x;
print(stack);
assertEq(stack.includes("caller"), false);
assertEq(stack.includes("fill"), false);
}
function test_jit_elision2() {
({ stack } = caller(() => {
let x = caller(fill);
return x;
}));
assertEq(stack.includes("caller"), true); // Only elide the first caller!
assertEq(stack.includes("fill"), false);
}
test_jit_elision();
test_jit_elision2();

View File

@@ -0,0 +1,344 @@
// |jit-test| --setpref=experimental.error_capture_stack_trace;
load(libdir + "asserts.js");
assertEq('captureStackTrace' in Error, true);
assertEq(Error.captureStackTrace.length, 2);
let x = Error.captureStackTrace({});
assertEq(x, undefined);
assertThrowsInstanceOf(() => Error.captureStackTrace(), TypeError);
assertThrowsInstanceOf(() => Error.captureStackTrace(2), TypeError);
Error.captureStackTrace({}, 2);
Error.captureStackTrace({}, null);
Error.captureStackTrace({}, {});
function caller(f) {
return f();
}
function fill() {
let x = {}
Error.captureStackTrace(x, caller);
return x;
}
function test_elision() {
let x = caller(fill);
let { stack } = x;
assertEq(stack.includes("caller"), false);
assertEq(stack.includes("fill"), false);
({ stack } = caller(() => caller(fill)))
print(stack);
assertEq(stack.includes("caller"), true); // Only elide the first caller!
assertEq(stack.includes("fill"), false);
}
test_elision();
function nestedLambda(f) {
(() => {
(() => {
(() => {
(() => {
f();
})();
})();
})();
})();
}
// If we never see a matching frame when requesting a truncated
// stack we should return the empty string
function test_no_match() {
let obj = {};
// test_elision chosen arbitrarily as a function object which
// doesn't exist in the call stack here.
let capture = () => Error.captureStackTrace(obj, test_elision);
nestedLambda(capture);
assertEq(obj.stack, "");
}
test_no_match()
function count_frames(str) {
return str.split("\n").length
}
function test_nofilter() {
let obj = {};
let capture = () => Error.captureStackTrace(obj);
nestedLambda(capture);
assertEq(count_frames(obj.stack), 9);
}
test_nofilter();
function test_in_eval() {
let obj = eval(`
let obj = {};
let capture = () => Error.captureStackTrace(obj);
nestedLambda(capture);
obj
`)
// Same as above, with an eval frame added!
assertEq(count_frames(obj.stack), 10);
}
test_in_eval();
//
// [[ErrorData]]
//
const stackGetter = Object.getOwnPropertyDescriptor(Error.prototype, 'stack').get;
const getStack = function (obj) {
return stackGetter.call(obj);
};
function test_uncensored() {
let err = undefined;
function create_err() {
err = new Error;
Error.captureStackTrace(err, test_uncensored);
}
nestedLambda(create_err);
// Calling Error.captureStackTrace doesn't mess with the internal
// [[ErrorData]] slot
assertEq(count_frames(err.stack), 2);
assertEq(count_frames(getStack(err)), 9)
}
test_uncensored()
// In general, the stacks a non-caller version of Error.captureStackStrace
// should match what Error gives you
function compare_stacks() {
function censor_column(str) {
return str.replace(/:(\d+):\d+\n/g, ":$1:censored\n")
}
let obj = {};
let err = (Error.captureStackTrace(obj), new Error)
assertEq(censor_column(err.stack), censor_column(obj.stack));
}
compare_stacks();
nestedLambda(compare_stacks)
// New global
function test_in_global(global) {
global.evaluate(caller.toString());
global.evaluate(fill.toString());
global.evaluate(test_elision.toString());
global.evaluate("test_elision()");
global.evaluate(nestedLambda.toString())
global.evaluate(test_no_match.toString());
global.evaluate("test_no_match()");
global.evaluate(compare_stacks.toString());
global.evaluate(`
compare_stacks();
nestedLambda(compare_stacks)
`)
}
let global = newGlobal();
test_in_global(global);
let global2 = newGlobal({ principal: 0 });
test_in_global(global2)
let global3 = newGlobal({ principal: 0xfffff });
test_in_global(global3)
// What if the caller is a proxy?
const caller_proxy = new Proxy(caller, {
apply: function (target, thisArg, arguments) {
return target(...arguments);
}
});
function fill_proxy() {
let x = {}
Error.captureStackTrace(x, caller_proxy);
return x;
}
// Proxies don't count for elision.
function test_proxy_elision() {
let x = caller_proxy(fill_proxy);
let { stack } = x;
assertEq(stack.includes("caller"), true);
assertEq(stack.includes("fill_proxy"), true);
}
test_proxy_elision();
const trivial_proxy = new Proxy(caller, {});
function fill_trivial() {
let x = {}
Error.captureStackTrace(x, trivial_proxy);
return x;
}
// Elision doesn't work even on forwarding proxy
function test_trivial_elision() {
let x = caller(fill_trivial);
let { stack } = x;
assertEq(stack.includes("caller"), true);
assertEq(stack.includes("fill"), true);
}
test_trivial_elision();
// Elision happens through bind
function test_bind_elision() {
let b = caller.bind(undefined, fill);
let { stack } = b();
assertEq(stack.includes("caller"), false);
assertEq(stack.includes("fill"), false);
}
test_bind_elision();
// Cross Realm testing
let nr = newGlobal({ newCompartment: true })
nr.eval(`globalThis.x = {}`);
Error.captureStackTrace(nr.x);
// Test strict definition
function test_strict_definition() {
"use strict";
assertThrowsInstanceOf(() => Error.captureStackTrace(Object.freeze({ stack: null })), TypeError);
}
test_strict_definition();
function test_property_descriptor() {
let o = {};
Error.captureStackTrace(o);
let desc = Object.getOwnPropertyDescriptor(o, "stack");
assertEq(desc.configurable, true)
assertEq(desc.writable, true)
assertEq(desc.enumerable, false)
}
test_property_descriptor();
function test_delete() {
let o = {};
Error.captureStackTrace(o);
delete o.stack
assertEq("stack" in o, false)
}
test_delete();
// Principal testing: This is basic/shell-principals.js extended to support
// and compare Error.captureStackTrace.
//
// Reminder:
// > In the shell, a principal is simply a 32-bit mask: P subsumes Q if the
// > set bits in P are a superset of those in Q. Thus, the principal 0 is
// > subsumed by everything, and the principal ~0 subsumes everything.
// Given a string of letters |expected|, say "abc", assert that the stack
// contains calls to a series of functions named by the next letter from
// the string, say a, b, and then c. Younger frames appear earlier in
// |expected| than older frames.
let count = 0;
function check(expected, stack) {
print("check(" + JSON.stringify(expected) + ") against:\n" + stack);
count++;
// Extract only the function names from the stack trace. Omit the frames
// for the top-level evaluation, if it is present.
var split = stack.split(/(.)?@.*\n/).slice(0, -1);
if (split[split.length - 1] === undefined)
split = split.slice(0, -2);
print(JSON.stringify(split));
// Check the function names against the expected sequence.
assertEq(split.length, expected.length * 2);
for (var i = 0; i < expected.length; i++)
assertEq(split[i * 2 + 1], expected[i]);
}
var low = newGlobal({ principal: 0 });
var mid = newGlobal({ principal: 0xffff });
var high = newGlobal({ principal: 0xfffff });
eval('function a() { let o = {}; Error.captureStackTrace(o); check("a", o.stack); b(); }');
low.eval('function b() { let o = {}; Error.captureStackTrace(o); check("b", o.stack); c(); }');
mid.eval('function c() { let o = {}; Error.captureStackTrace(o); check("cba", o.stack); d(); }');
high.eval('function d() { let o = {}; Error.captureStackTrace(o); check("dcba", o.stack); e(); }');
// Globals created with no explicit principals get 0xffff.
eval('function e() { let o = {}; Error.captureStackTrace(o); check("ecba", o.stack); f(); }');
low.eval('function f() { let o = {}; Error.captureStackTrace(o); check("fb", o.stack); g(); }');
mid.eval('function g() { let o = {}; Error.captureStackTrace(o); check("gfecba", o.stack); h(); }');
high.eval('function h() { let o = {}; Error.captureStackTrace(o); check("hgfedcba", o.stack); }');
// Make everyone's functions visible to each other, as needed.
b = low.b;
low.c = mid.c;
mid.d = high.d;
high.e = e;
f = low.f;
low.g = mid.g;
mid.h = high.h;
low.check = mid.check = high.check = check;
// Kick the whole process off.
a();
assertEq(count, 8);
// Ensure filtering is based on caller realm not on target object.
low.eval("low_target = {}");
mid.eval("mid_target = {}");
high.eval("high_target = {}");
high.low_target = mid.low_target = low.low_target;
high.mid_target = low.mid_target = mid.mid_target;
mid.high_target = low.high_target = high.high_target;
high.low_cst = mid.low_cst = low.low_cst = low.Error.captureStackTrace;
high.mid_cst = low.mid_cst = mid.mid_cst = mid.Error.captureStackTrace;
mid.high_cst = low.high_cst = high.high_cst = high.Error.captureStackTrace;
for (let g of [low, mid, high]) {
assertEq("low_target" in g, true);
assertEq("mid_target" in g, true);
assertEq("high_target" in g, true);
assertEq("low_cst" in g, true);
assertEq("mid_cst" in g, true);
assertEq("high_cst" in g, true);
// install caller function z -- single letter name for
// check compat.
g.eval("function z(f) { f() }")
}
low.eval("function q() { Error.captureStackTrace(low_target); }")
high.q = low.q;
// Caller function z is from high, but using low Error.captureStackTrace, so
// z should be elided.
high.eval("z(q)");
check("q", low.low_target.stack);
low.eval("function r() { high_cst(low_target) }")
high.r = low.r;
// Can see everything here using high cst and low target.
high.eval("function t() { z(r) }");
high.t();
check("rzt", low.low_target.stack);

View File

@@ -4931,14 +4931,16 @@ JS::FirstSubsumedFrame::FirstSubsumedFrame(
JS_PUBLIC_API bool JS::CaptureCurrentStack(
JSContext* cx, JS::MutableHandleObject stackp,
JS::StackCapture&& capture /* = JS::StackCapture(JS::AllFrames()) */) {
JS::StackCapture&& capture /* = JS::StackCapture(JS::AllFrames()) */,
JS::HandleObject startAt /* = nullptr*/) {
AssertHeapIsIdle();
CHECK_THREAD(cx);
MOZ_RELEASE_ASSERT(cx->realm());
Realm* realm = cx->realm();
Rooted<SavedFrame*> frame(cx);
if (!realm->savedStacks().saveCurrentStack(cx, &frame, std::move(capture))) {
if (!realm->savedStacks().saveCurrentStack(cx, &frame, std::move(capture),
startAt)) {
return false;
}
stackp.set(frame.get());

View File

@@ -221,10 +221,6 @@ struct SuppressErrorsGuard {
~SuppressErrorsGuard() { JS::SetWarningReporter(cx, prevReporter); }
};
// Cut off the stack if it gets too deep (most commonly for infinite recursion
// errors).
static const size_t MAX_REPORTED_STACK_DEPTH = 1u << 7;
bool js::CaptureStack(JSContext* cx, MutableHandleObject stack) {
return CaptureCurrentStack(
cx, stack, JS::StackCapture(JS::MaxFrames(MAX_REPORTED_STACK_DEPTH)));

View File

@@ -36,6 +36,10 @@ UniquePtr<JSErrorNotes::Note> CopyErrorNote(JSContext* cx,
UniquePtr<JSErrorReport> CopyErrorReport(JSContext* cx, JSErrorReport* report);
// Cut off the stack if it gets too deep (most commonly for infinite recursion
// errors).
static const size_t MAX_REPORTED_STACK_DEPTH = 1u << 7;
bool CaptureStack(JSContext* cx, MutableHandleObject stack);
JSString* ComputeStackString(JSContext* cx);

View File

@@ -83,6 +83,7 @@
MACRO_(callee, "callee") \
MACRO_(caller, "caller") \
MACRO_(callFunction, "callFunction") \
MACRO_(captureStackTrace, "captureStackTrace") \
MACRO_(cancel, "cancel") \
MACRO_(case_, "case") \
MACRO_(caseFirst, "caseFirst") \

View File

@@ -55,6 +55,7 @@
#include "vm/JSContext-inl.h"
#include "vm/JSObject-inl.h"
#include "vm/ObjectOperations-inl.h"
#include "vm/Realm-inl.h"
#include "vm/SavedStacks-inl.h"
#include "vm/Shape-inl.h"
@@ -96,11 +97,13 @@ static const JSFunctionSpec error_methods[] = {
#ifdef NIGHTLY_BUILD
static bool exn_isError(JSContext* cx, unsigned argc, Value* vp);
static bool exn_captureStackTrace(JSContext* cx, unsigned argc, Value* vp);
#endif
static const JSFunctionSpec error_static_methods[] = {
#ifdef NIGHTLY_BUILD
JS_FN("isError", exn_isError, 1, 0),
JS_FN("captureStackTrace", exn_captureStackTrace, 2, 0),
#endif
JS_FS_END,
};
@@ -969,4 +972,85 @@ static bool exn_isError(JSContext* cx, unsigned argc, Value* vp) {
return true;
}
// The below is the "documentation" from https://v8.dev/docs/stack-trace-api
//
// ## Stack trace collection for custom exceptions
//
// The stack trace mechanism used for built-in errors is implemented using a
// general stack trace collection API that is also available to user scripts.
// The function
//
// Error.captureStackTrace(error, constructorOpt)
//
// adds a stack property to the given error object that yields the stack trace
// at the time captureStackTrace was called. Stack traces collected through
// Error.captureStackTrace are immediately collected, formatted, and attached
// to the given error object.
//
// The optional constructorOpt parameter allows you to pass in a function
// value. When collecting the stack trace all frames above the topmost call to
// this function, including that call, are left out of the stack trace. This
// can be useful to hide implementation details that wont be useful to the
// user. The usual way of defining a custom error that captures a stack trace
// would be:
//
// function MyError() {
// Error.captureStackTrace(this, MyError);
// // Any other initialization goes here.
// }
//
// Passing in MyError as a second argument means that the constructor call to
// MyError wont show up in the stack trace.
static bool exn_captureStackTrace(JSContext* cx, unsigned argc, Value* vp) {
CallArgs args = CallArgsFromVp(argc, vp);
const char* callerName = "Error.captureStackTrace";
if (!args.requireAtLeast(cx, callerName, 1)) {
return false;
}
Rooted<JSObject*> obj(cx,
RequireObjectArg(cx, "`target`", callerName, args[0]));
if (!obj) {
return false;
}
Rooted<JSObject*> caller(cx, nullptr);
if (args.length() > 1 && args[1].isObject() &&
args[1].toObject().isCallable()) {
caller = CheckedUnwrapStatic(&args[1].toObject());
if (!caller) {
ReportAccessDenied(cx);
return false;
}
}
RootedObject stack(cx);
if (!CaptureCurrentStack(
cx, &stack, JS::StackCapture(JS::MaxFrames(MAX_REPORTED_STACK_DEPTH)),
caller)) {
return false;
}
RootedString stackString(cx);
// Do frame filtering based on the current realm, to filter out any
// chrome frames which could exist on the stack.
JSPrincipals* principals = cx->realm()->principals();
if (!BuildStackString(cx, principals, stack, &stackString)) {
return false;
}
// V8 installs a non-enumerable, configurable getter-setter on the object.
// JSC installs a non-enumerable, configurable, writable value on the
// object. We are following JSC here, not V8.
RootedValue string(cx, StringValue(stackString));
if (!DefineDataProperty(cx, obj, cx->names().stack, string, 0)) {
return false;
}
args.rval().setUndefined();
return true;
}
#endif

View File

@@ -2345,6 +2345,12 @@ JS_PUBLIC_API bool js::ShouldIgnorePropertyDefinition(JSContext* cx,
}
#endif
if (key == JSProto_Function &&
!JS::Prefs::experimental_error_capture_stack_trace() &&
id == NameToId(cx->names().captureStackTrace)) {
return true;
}
if (key == JSProto_JSON &&
!JS::Prefs::experimental_json_parse_with_source() &&
(id == NameToId(cx->names().isRawJSON) ||

View File

@@ -1324,7 +1324,8 @@ bool SavedFrame::toStringMethod(JSContext* cx, unsigned argc, Value* vp) {
bool SavedStacks::saveCurrentStack(
JSContext* cx, MutableHandle<SavedFrame*> frame,
JS::StackCapture&& capture /* = JS::StackCapture(JS::AllFrames()) */) {
JS::StackCapture&& capture /* = JS::StackCapture(JS::AllFrames()) */,
HandleObject startAt /* nullptr */) {
MOZ_RELEASE_ASSERT(cx->realm());
MOZ_DIAGNOSTIC_ASSERT(&cx->realm()->savedStacks() == this);
@@ -1335,7 +1336,7 @@ bool SavedStacks::saveCurrentStack(
}
AutoGeckoProfilerEntry labelFrame(cx, "js::SavedStacks::saveCurrentStack");
return insertFrames(cx, frame, std::move(capture));
return insertFrames(cx, frame, std::move(capture), startAt);
}
bool SavedStacks::copyAsyncStack(JSContext* cx, HandleObject asyncStack,
@@ -1411,7 +1412,10 @@ static inline bool captureIsSatisfied(JSContext* cx, JSPrincipals* principals,
}
bool SavedStacks::insertFrames(JSContext* cx, MutableHandle<SavedFrame*> frame,
JS::StackCapture&& capture) {
JS::StackCapture&& capture,
HandleObject startAtObj) {
MOZ_ASSERT_IF(startAtObj, startAtObj->isCallable());
// In order to look up a cached SavedFrame object, we need to have its parent
// SavedFrame, which means we need to walk the stack from oldest frame to
// youngest. However, FrameIter walks the stack from youngest frame to
@@ -1465,6 +1469,11 @@ bool SavedStacks::insertFrames(JSContext* cx, MutableHandle<SavedFrame*> frame,
// targets and ensure that we don't stop before they have all been reached.
Vector<AbstractFramePtr, 4, TempAllocPolicy> unreachedEvalTargets(cx);
Rooted<JSFunction*> startAt(cx, startAtObj && startAtObj->is<JSFunction>()
? &startAtObj->as<JSFunction>()
: nullptr);
bool seenStartAt = !startAt;
while (!iter.done()) {
Activation& activation = *iter.activation();
Maybe<LiveSavedFrameCache::FramePtr> framePtr =
@@ -1541,19 +1550,29 @@ bool SavedStacks::insertFrames(JSContext* cx, MutableHandle<SavedFrame*> frame,
auto principals = iter.realm()->principals();
MOZ_ASSERT_IF(framePtr && !iter.isWasm(), iter.pc());
if (!stackChain.emplaceBack(location.source(), location.sourceId(),
location.line(), location.column(), displayAtom,
nullptr, // asyncCause
nullptr, // parent (not known yet)
principals, iter.mutedErrors(), framePtr,
iter.pc(), &activation)) {
return false;
// If we haven't yet seen the start, then don't add anything to the stack
// chain.
if (seenStartAt) {
if (!stackChain.emplaceBack(location.source(), location.sourceId(),
location.line(), location.column(),
displayAtom,
nullptr, // asyncCause
nullptr, // parent (not known yet)
principals, iter.mutedErrors(), framePtr,
iter.pc(), &activation)) {
return false;
}
}
if (captureIsSatisfied(cx, principals, location.source(), capture)) {
break;
}
if (!seenStartAt && iter.isFunctionFrame() &&
iter.matchCallee(cx, startAt)) {
seenStartAt = true;
}
++iter;
framePtr = LiveSavedFrameCache::FramePtr::create(iter);

View File

@@ -167,7 +167,8 @@ class SavedStacks {
[[nodiscard]] bool saveCurrentStack(
JSContext* cx, MutableHandle<SavedFrame*> frame,
JS::StackCapture&& capture = JS::StackCapture(JS::AllFrames()));
JS::StackCapture&& capture = JS::StackCapture(JS::AllFrames()),
HandleObject startAt = nullptr);
[[nodiscard]] bool copyAsyncStack(
JSContext* cx, HandleObject asyncStack, HandleString asyncCause,
MutableHandle<SavedFrame*> adoptedStack,
@@ -218,7 +219,8 @@ class SavedStacks {
[[nodiscard]] bool insertFrames(JSContext* cx,
MutableHandle<SavedFrame*> frame,
JS::StackCapture&& capture);
JS::StackCapture&& capture,
HandleObject startAt);
[[nodiscard]] bool adoptAsyncStack(
JSContext* cx, MutableHandle<SavedFrame*> asyncStack,
Handle<JSAtom*> asyncCause, const mozilla::Maybe<size_t>& maxFrameCount);