@samitouri / QOS-React / commits / d70ee32b88

[Flight] Eagerly parse stack traces in DebugNode (#33589)

There's a memory leak in DebugNode where the `Error` objects that we instantiate retains their callstacks which can have Promises on them. In fact, it's very likely since the current callsite has the "resource" on it which is the Promise itself. If those Promises are retained then their `destroy` async hook is never fired which doesn't clean up our map which can contains the `Error` object. Creating a cycle that can't be cleaned up. This fix is just eagerly reifying and parsing the stacks. I totally expect this to be crazy slow since there's so many Promises that we end up not needing to visit otherwise. We'll need to optimize it somehow. Perhaps by being smarter about which ones we might need stacks for. However, at least it doesn't leak indefinitely.

Sebastian Markbåge committed Jun 22, 2025 at 10:40 UTC d70ee32b8867f6cf99b1787f8adb4f3705756805
3 files changed +18 -19
packages/react-server/src/ReactFlightAsyncSequence.js
+10 -6
@@ -7,7 +7,11 @@
7 * @flow
8 */
9
10 -import type {ReactDebugInfo, ReactComponentInfo} from 'shared/ReactTypes';
10 +import type {
11 + ReactDebugInfo,
12 + ReactComponentInfo,
13 + ReactStackTrace,
14 +} from 'shared/ReactTypes';
15
16 export const IO_NODE = 0;
17 export const PROMISE_NODE = 1;
@@ -22,7 +26,7 @@ type PromiseWithDebugInfo = interface extends Promise<any> {
26 export type IONode = {
27 tag: 0,
28 owner: null | ReactComponentInfo,
25 - stack: Error, // callsite that spawned the I/O
29 + stack: ReactStackTrace, // callsite that spawned the I/O
30 debugInfo: null, // not used on I/O
31 start: number, // start time when the first part of the I/O sequence started
32 end: number, // we typically don't use this. only when there's no promise intermediate.
@@ -34,7 +38,7 @@ export type PromiseNode = {
38 tag: 1,
39 owner: null | ReactComponentInfo,
40 debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise
37 - stack: Error, // callsite that created the Promise
41 + stack: ReactStackTrace, // callsite that created the Promise
42 start: number, // start time when the Promise was created
43 end: number, // end time when the Promise was resolved.
44 awaited: null | AsyncSequence, // the thing that ended up resolving this promise
@@ -45,7 +49,7 @@ export type AwaitNode = {
49 tag: 2,
50 owner: null | ReactComponentInfo,
51 debugInfo: null | ReactDebugInfo, // forwarded debugInfo from the Promise
48 - stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...)
52 + stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
53 start: number, // when we started blocking. This might be later than the I/O started.
54 end: number, // when we unblocked. This might be later than the I/O resolved if there's CPU time.
55 awaited: null | AsyncSequence, // the promise we were waiting on
@@ -56,7 +60,7 @@ export type UnresolvedPromiseNode = {
60 tag: 3,
61 owner: null | ReactComponentInfo,
62 debugInfo: WeakRef<PromiseWithDebugInfo>, // holds onto the Promise until we can extract debugInfo when it resolves
59 - stack: Error, // callsite that created the Promise
63 + stack: ReactStackTrace, // callsite that created the Promise
64 start: number, // start time when the Promise was created
65 end: -1.1, // set when we resolve.
66 awaited: null | AsyncSequence, // the thing that ended up resolving this promise
@@ -67,7 +71,7 @@ export type UnresolvedAwaitNode = {
71 tag: 4,
72 owner: null | ReactComponentInfo,
73 debugInfo: WeakRef<PromiseWithDebugInfo>, // holds onto the Promise until we can extract debugInfo when it resolves
70 - stack: Error, // callsite that awaited (using await, .then(), Promise.all(), ...)
74 + stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
75 start: number, // when we started blocking. This might be later than the I/O started.
76 end: -1.1, // set when we resolve.
77 awaited: null | AsyncSequence, // the promise we were waiting on
packages/react-server/src/ReactFlightServer.js
+3 -9
@@ -1940,10 +1940,7 @@ function visitAsyncNode(
1940 // If the ioNode was a Promise, then that means we found one in user space since otherwise
1941 // we would've returned an IO node. We assume this has the best stack.
1942 match = ioNode;
1943 - } else if (
1944 - filterStackTrace(request, parseStackTrace(node.stack, 1)).length ===
1945 - 0
1946 - ) {
1943 + } else if (filterStackTrace(request, node.stack).length === 0) {
1944 // If this Promise was created inside only third party code, then try to use
1945 // the inner I/O node instead. This could happen if third party calls into first
1946 // party to perform some I/O.
@@ -1986,10 +1983,7 @@ function visitAsyncNode(
1983 // just part of a previous component's rendering.
1984 match = ioNode;
1985 } else {
1989 - const stack = filterStackTrace(
1990 - request,
1991 - parseStackTrace(node.stack, 1),
1992 - );
1986 + const stack = filterStackTrace(request, node.stack);
1987 if (stack.length === 0) {
1988 // If this await was fully filtered out, then it was inside third party code
1989 // such as in an external library. We return the I/O node and try another await.
@@ -3711,7 +3705,7 @@ function serializeIONode(
3705 let stack = null;
3706 let name = '';
3707 if (ioNode.stack !== null) {
3714 - const fullStack = parseStackTrace(ioNode.stack, 1);
3708 + const fullStack = ioNode.stack;
3709 stack = filterStackTrace(request, fullStack);
3710 name = findCalledFunctionNameFromStackTrace(request, fullStack);
3711 // The name can include the object that this was called on but sometimes that's
packages/react-server/src/ReactFlightServerConfigDebugNode.js
+5 -4
@@ -26,6 +26,7 @@ import {
26 import {resolveOwner} from './flight/ReactFlightCurrentOwner';
27 import {createHook, executionAsyncId, AsyncResource} from 'async_hooks';
28 import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
29 +import {parseStackTrace} from './ReactFlightServerConfig';
30
31 // $FlowFixMe[method-unbinding]
32 const getAsyncId = AsyncResource.prototype.asyncId;
@@ -86,7 +87,7 @@ export function initAsyncDebugInfo(): void {
87 tag: UNRESOLVED_AWAIT_NODE,
88 owner: resolveOwner(),
89 debugInfo: new WeakRef((resource: Promise<any>)),
89 - stack: new Error(),
90 + stack: parseStackTrace(new Error(), 1),
91 start: performance.now(),
92 end: -1.1, // set when resolved.
93 awaited: trigger, // The thing we're awaiting on. Might get overrriden when we resolve.
@@ -97,7 +98,7 @@ export function initAsyncDebugInfo(): void {
98 tag: UNRESOLVED_PROMISE_NODE,
99 owner: resolveOwner(),
100 debugInfo: new WeakRef((resource: Promise<any>)),
100 - stack: new Error(),
101 + stack: parseStackTrace(new Error(), 1),
102 start: performance.now(),
103 end: -1.1, // Set when we resolve.
104 awaited:
@@ -118,7 +119,7 @@ export function initAsyncDebugInfo(): void {
119 tag: IO_NODE,
120 owner: resolveOwner(),
121 debugInfo: null,
121 - stack: new Error(), // This is only used if no native promises are used.
122 + stack: parseStackTrace(new Error(), 1), // This is only used if no native promises are used.
123 start: performance.now(),
124 end: -1.1, // Only set when pinged.
125 awaited: null,
@@ -133,7 +134,7 @@ export function initAsyncDebugInfo(): void {
134 tag: IO_NODE,
135 owner: resolveOwner(),
136 debugInfo: null,
136 - stack: new Error(),
137 + stack: parseStackTrace(new Error(), 1),
138 start: performance.now(),
139 end: -1.1, // Only set when pinged.
140 awaited: null,