[Flight] Optimize Async Stack Collection (#33727)

We need to optimize the collection of debug info for dev mode. This is
an incredibly hot path since it instruments all I/O and Promises in the
app.

These optimizations focus primarily on the collection of stack traces.
They are expensive to collect because we need to eagerly collect the
stacks since they can otherwise cause memory leaks. We also need to do
some of the processing of them up front. We also end up only using a few
of them in the end but we don't know which ones we'll use.

The first compromise here is that I now only collect the stacks of
"awaits" if they were in a specific request's render. In some cases it's
useful to collect them even outside of this if they're part of a
sequence that started early. I still collect stacks for the created
Promises outside of this though which can still provide some context.

The other optimization to awaits, is that since we'll only use the inner
most one that had an await directly in userspace, we can stop collecting
stacks on a chain of awaits after we find one. This requires a quick
filter on a single callsite to determine. Since we now only collect
stacks from awaits that belongs to a specific Request we can use that
request's specific filter option. Technically this might not be quite
correct if that same thing ends up deduped across Requests but that's an
edge case.

Additionally, I now stop collecting stack for I/O nodes. They're almost
always superseded by the Promise that wraps them anyway. Even if you
write mostly Promise free code, you'll likely end up with a Promise at
the root of the component eventually anyway and then you end up using
its stack anyway. You have to really contort the code to end up with
zero Promises at which point it's not very useful anyway. At best it's
maybe mostly useful for giving a name to the I/O when the rest is just
stuff like `new Promise`.

However, a possible alternative optimization could be to *only* collect
the stack of spawned I/O and not the stack of Promises. The issue with
Promises (not awaits) is that we never know what will end up resolving
them in the end when they're created so we have to always eagerly
collect stacks. This could be an issue when you have a lot of
abstractions that end up not actually be related to I/O at all. The
issue with collecting stacks only for I/O is that the actual I/O can be
pooled or batched so you end up not having the stack when the conceptual
start of each operation within the batch started. Which is why I decided
to keep the Promise stack.
This commit is contained in:
Sebastian Markbåge 2025-07-08 10:49:08 -04:00 committed by GitHub
parent b44a99bf58
commit f1ecf82bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 463 additions and 421 deletions

View File

@ -26,7 +26,7 @@ type PromiseWithDebugInfo = interface extends Promise<any> {
export type IONode = {
tag: 0,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that spawned the I/O
stack: null, // callsite that spawned the I/O
start: number, // start time when the first part of the I/O sequence started
end: number, // we typically don't use this. only when there's no promise intermediate.
promise: null, // not used on I/O
@ -37,7 +37,7 @@ export type IONode = {
export type PromiseNode = {
tag: 1,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that created the Promise
stack: null | ReactStackTrace, // callsite that created the Promise
start: number, // start time when the Promise was created
end: number, // end time when the Promise was resolved.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@ -48,7 +48,7 @@ export type PromiseNode = {
export type AwaitNode = {
tag: 2,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
stack: null | ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
start: number, // when we started blocking. This might be later than the I/O started.
end: number, // when we unblocked. This might be later than the I/O resolved if there's CPU time.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@ -59,7 +59,7 @@ export type AwaitNode = {
export type UnresolvedPromiseNode = {
tag: 3,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that created the Promise
stack: null | ReactStackTrace, // callsite that created the Promise
start: number, // start time when the Promise was created
end: -1.1, // set when we resolve.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@ -70,7 +70,7 @@ export type UnresolvedPromiseNode = {
export type UnresolvedAwaitNode = {
tag: 4,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
stack: null | ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
start: number, // when we started blocking. This might be later than the I/O started.
end: -1.1, // set when we resolve.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced

View File

@ -251,6 +251,46 @@ function filterStackTrace(
return filteredStack;
}
function hasUnfilteredFrame(request: Request, stack: ReactStackTrace): boolean {
const filterStackFrame = request.filterStackFrame;
for (let i = 0; i < stack.length; i++) {
const callsite = stack[i];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
if (filterStackFrame(url, functionName, lineNumber, columnNumber)) {
return true;
}
}
return false;
}
export function isAwaitInUserspace(
request: Request,
stack: ReactStackTrace,
): boolean {
let firstFrame = 0;
while (stack.length > firstFrame && stack[firstFrame][0] === 'Promise.then') {
// Skip Promise.then frame itself.
firstFrame++;
}
if (stack.length > firstFrame) {
// Check if the very first stack frame that awaited this Promise was in user space.
// TODO: This doesn't take into account wrapper functions such as our fake .then()
// in FlightClient which will always be considered third party awaits if you call
// .then directly.
const filterStackFrame = request.filterStackFrame;
const callsite = stack[firstFrame];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
return filterStackFrame(url, functionName, lineNumber, columnNumber);
}
return false;
}
initAsyncDebugInfo();
function patchConsole(consoleInst: typeof console, methodName: string) {
@ -2088,7 +2128,10 @@ function visitAsyncNode(
// If the ioNode was a Promise, then that means we found one in user space since otherwise
// we would've returned an IO node. We assume this has the best stack.
match = ioNode;
} else if (filterStackTrace(request, node.stack).length === 0) {
} else if (
node.stack === null ||
!hasUnfilteredFrame(request, node.stack)
) {
// If this Promise was created inside only third party code, then try to use
// the inner I/O node instead. This could happen if third party calls into first
// party to perform some I/O.
@ -2101,7 +2144,10 @@ function visitAsyncNode(
// We aborted this render. If this Promise spanned the abort time it was probably the
// Promise that was aborted. This won't necessarily have I/O associated with it but
// it's a point of interest.
if (filterStackTrace(request, node.stack).length > 0) {
if (
node.stack !== null &&
hasUnfilteredFrame(request, node.stack)
) {
match = node;
}
}
@ -2147,35 +2193,10 @@ function visitAsyncNode(
// just part of a previous component's rendering.
match = ioNode;
} else {
let isAwaitInUserspace = false;
const fullStack = node.stack;
let firstFrame = 0;
while (
fullStack.length > firstFrame &&
fullStack[firstFrame][0] === 'Promise.then'
if (
node.stack === null ||
!isAwaitInUserspace(request, node.stack)
) {
// Skip Promise.then frame itself.
firstFrame++;
}
if (fullStack.length > firstFrame) {
// Check if the very first stack frame that awaited this Promise was in user space.
// TODO: This doesn't take into account wrapper functions such as our fake .then()
// in FlightClient which will always be considered third party awaits if you call
// .then directly.
const filterStackFrame = request.filterStackFrame;
const callsite = fullStack[firstFrame];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
isAwaitInUserspace = filterStackFrame(
url,
functionName,
lineNumber,
columnNumber,
);
}
if (!isAwaitInUserspace) {
// If this await was fully filtered out, then it was inside third party code
// such as in an external library. We return the I/O node and try another await.
match = ioNode;
@ -2204,7 +2225,10 @@ function visitAsyncNode(
awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference.
env: env,
owner: node.owner,
stack: filterStackTrace(request, node.stack),
stack:
node.stack === null
? null
: filterStackTrace(request, node.stack),
});
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.

View File

@ -7,6 +7,8 @@
* @flow
*/
import type {ReactStackTrace} from 'shared/ReactTypes';
import type {
AsyncSequence,
IONode,
@ -24,6 +26,7 @@ import {
UNRESOLVED_AWAIT_NODE,
} from './ReactFlightAsyncSequence';
import {resolveOwner} from './flight/ReactFlightCurrentOwner';
import {resolveRequest, isAwaitInUserspace} from './ReactFlightServer';
import {createHook, executionAsyncId, AsyncResource} from 'async_hooks';
import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
import {parseStackTrace} from './ReactFlightServerConfig';
@ -66,6 +69,8 @@ function resolvePromiseOrAwaitNode(
return resolvedNode;
}
const emptyStack: ReactStackTrace = [];
// Initialize the tracing of async operations.
// We do this globally since the async work can potentially eagerly
// start before the first request and once requests start they can interleave.
@ -110,10 +115,33 @@ export function initAsyncDebugInfo(): void {
}
// If the thing we're waiting on is another Await we still track that sequence
// so that we can later pick the best stack trace in user space.
let stack = null;
if (
trigger.stack !== null &&
(trigger.tag === AWAIT_NODE ||
trigger.tag === UNRESOLVED_AWAIT_NODE)
) {
// We already had a stack for an await. In a chain of awaits we'll only need one good stack.
// We mark it with an empty stack to signal to any await on this await that we have a stack.
stack = emptyStack;
} else {
const request = resolveRequest();
if (request === null) {
// We don't collect stacks for awaits that weren't in the scope of a specific render.
} else {
stack = parseStackTrace(new Error(), 5);
if (!isAwaitInUserspace(request, stack)) {
// If this await was not done directly in user space, then clear the stack. We won't use it
// anyway. This lets future awaits on this await know that we still need to get their stacks
// until we find one in user space.
stack = null;
}
}
}
node = ({
tag: UNRESOLVED_AWAIT_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 5),
stack: stack,
start: performance.now(),
end: -1.1, // set when resolved.
promise: new WeakRef((resource: Promise<any>)),
@ -145,7 +173,7 @@ export function initAsyncDebugInfo(): void {
node = ({
tag: IO_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 3), // This is only used if no native promises are used.
stack: null,
start: performance.now(),
end: -1.1, // Only set when pinged.
promise: null,
@ -160,7 +188,7 @@ export function initAsyncDebugInfo(): void {
node = ({
tag: IO_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 3),
stack: null,
start: performance.now(),
end: -1.1, // Only set when pinged.
promise: null,

File diff suppressed because it is too large Load Diff