Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
markAsyncSequenceRootTask,
getCurrentAsyncSequence,
getAsyncSequenceFromPromise,
cleanupAsyncDebugInfo,
parseStackTrace,
parseStackTracePrivate,
supportsComponentStorage,
Expand Down Expand Up @@ -863,6 +864,10 @@ export function resolveRequest(): null | Request {
return null;
}

export function isRequestClosingOrClosed(request: Request): boolean {
return request.status >= CLOSING;
}

function isTypedArray(value: any): boolean {
if (value instanceof ArrayBuffer) {
return true;
Expand Down Expand Up @@ -4140,6 +4145,9 @@ function fatalError(request: Request, error: mixed): void {
request.status = CLOSING;
request.fatalError = error;
}
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
const abortReason = new Error(
'The render was aborted due to a fatal error.',
{
Expand Down Expand Up @@ -6078,14 +6086,18 @@ function flushCompletedChunks(request: Request): void {
// We'll continue writing on this stream so nothing closes.
return;
} else {
// We'll close the main stream but keep the debug stream open.
// TODO: If this destination is not currently flowing we'll not close it when it resumes flowing.
// We'll mark the main stream as closed while keeping the debug stream open.
// If the main stream is currently flowing, close it immediately.
// TODO: If this destination is not currently flowing we won't close it when flow resumes.
// We should keep a separate status for this.
request.status = CLOSED;
if (request.destination !== null) {
request.status = CLOSED;
close(request.destination);
request.destination = null;
}
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
return;
}
}
Expand All @@ -6109,6 +6121,11 @@ function flushCompletedChunks(request: Request): void {
close(request.debugDestination);
request.debugDestination = null;
}
if (__DEV__) {
// Clean up async debug tracking after all close/abort side effects,
// so any async work spawned during abort doesn't re-populate tracking.
cleanupAsyncDebugInfo(request);
}
}
}

Expand Down Expand Up @@ -6158,6 +6175,9 @@ function callOnAllReadyIfReady(request: Request): void {
export function startFlowing(request: Request, destination: Destination): void {
if (request.status === CLOSING) {
request.status = CLOSED;
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
closeWithError(destination, request.fatalError);
return;
}
Expand All @@ -6183,6 +6203,9 @@ export function startFlowingDebug(
): void {
if (request.status === CLOSING) {
request.status = CLOSED;
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
closeWithError(debugDestination, request.fatalError);
return;
}
Expand Down Expand Up @@ -6212,6 +6235,9 @@ function finishHalt(request: Request, abortedTasks: Set<Task>): void {
const onAllReady = request.onAllReady;
onAllReady();
flushCompletedChunks(request);
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
} catch (error) {
logRecoverableError(request, error, null);
fatalError(request, error);
Expand All @@ -6228,6 +6254,9 @@ function finishAbort(
const onAllReady = request.onAllReady;
onAllReady();
flushCompletedChunks(request);
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
} catch (error) {
logRecoverableError(request, error, null);
fatalError(request, error);
Expand Down Expand Up @@ -6282,6 +6311,9 @@ export function abort(request: Request, reason: mixed): void {
const onAllReady = request.onAllReady;
onAllReady();
flushCompletedChunks(request);
if (__DEV__) {
cleanupAsyncDebugInfo(request);
}
}
} catch (error) {
logRecoverableError(request, error, null);
Expand Down
132 changes: 129 additions & 3 deletions packages/react-server/src/ReactFlightServerConfigDebugNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
UNRESOLVED_AWAIT_NODE,
} from './ReactFlightAsyncSequence';
import {resolveOwner} from './flight/ReactFlightCurrentOwner';
import {resolveRequest, isAwaitInUserspace} from './ReactFlightServer';
import {
resolveRequest,
isAwaitInUserspace,
isRequestClosingOrClosed,
} from './ReactFlightServer';
import {createHook, executionAsyncId, AsyncResource} from 'async_hooks';
import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
import {parseStackTracePrivate} from './ReactFlightServerConfig';
Expand All @@ -37,6 +41,25 @@ const getAsyncId = AsyncResource.prototype.asyncId;
const pendingOperations: Map<number, AsyncSequence> =
__DEV__ && enableAsyncDebugInfo ? new Map() : (null: any);

// Tracks which asyncIds belong to each request, for cleanup when the request closes.
// Explicit cleanup is required because asyncIdToRequests holds strong references
// to request owners until ownership is removed.
const requestAsyncIds: WeakMap<any, Set<number>> = __DEV__ &&
enableAsyncDebugInfo
? new WeakMap()
: (null: any);

// Tracks request ownership for each asyncId.
const asyncIdToRequests: Map<number, Set<any>> = __DEV__ && enableAsyncDebugInfo
? new Map()
: (null: any);

// Tracks requests that have already been cleaned so we can avoid re-adding
// ownership in windows where status has not yet transitioned to CLOSING/CLOSED.
const cleanedRequests: WeakSet<any> = __DEV__ && enableAsyncDebugInfo
? new WeakSet()
: (null: any);

// Keep the last resolved await as a workaround for async functions missing data.
let lastRanAwait: null | AwaitNode = null;

Expand All @@ -54,6 +77,55 @@ function resolvePromiseOrAwaitNode(

const emptyStack: ReactStackTrace = [];

function addRequestOwnership(request: any, asyncId: number): void {
let ids = requestAsyncIds.get(request);
if (ids === undefined) {
ids = new Set();
requestAsyncIds.set(request, ids);
}
ids.add(asyncId);

let owners = asyncIdToRequests.get(asyncId);
if (owners === undefined) {
owners = new Set();
asyncIdToRequests.set(asyncId, owners);
}
owners.add(request);
}

function removeAsyncIdFromRequest(request: any, asyncId: number): void {
const ids = requestAsyncIds.get(request);
if (ids !== undefined) {
ids.delete(asyncId);
if (ids.size === 0) {
requestAsyncIds.delete(request);
}
}
}

function removeAllRequestOwnership(asyncId: number): void {
const owners = asyncIdToRequests.get(asyncId);
if (owners !== undefined) {
owners.forEach(request => {
removeAsyncIdFromRequest(request, asyncId);
});
asyncIdToRequests.delete(asyncId);
}
}

function removeRequestOwnership(request: any, asyncId: number): boolean {
const owners = asyncIdToRequests.get(asyncId);
if (owners === undefined) {
return false;
}
owners.delete(request);
if (owners.size === 0) {
asyncIdToRequests.delete(asyncId);
return true;
}
return false;
}

// 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.
Expand All @@ -68,6 +140,15 @@ export function initAsyncDebugInfo(): void {
triggerAsyncId: number,
resource: any,
): void {
const request = resolveRequest();
if (
request !== null &&
(isRequestClosingOrClosed(request) || cleanedRequests.has(request))
) {
// Avoid creating any new tracking nodes once cleanup has run for this request.
return;
}

const trigger = pendingOperations.get(triggerAsyncId);
let node: AsyncSequence;
if (type === 'PROMISE') {
Expand Down Expand Up @@ -100,7 +181,6 @@ export function initAsyncDebugInfo(): void {
}
} else {
promiseRef = new WeakRef((resource: Promise<any>));
const request = resolveRequest();
if (request === null) {
// We don't collect stacks for awaits that weren't in the scope of a specific render.
} else {
Expand Down Expand Up @@ -200,7 +280,29 @@ export function initAsyncDebugInfo(): void {
node = trigger;
}
}
// Keep operation tracking for both request-scoped and external async work.
pendingOperations.set(asyncId, node);

if (request !== null) {
// Keep ownership request-local here. We intentionally don't claim
// unowned trigger nodes so async debug info from external promises can
// still be reused by later requests.
addRequestOwnership(request, asyncId);
} else {
// If this async resource was spawned from request-owned work, preserve all
// owners so cleanup only deletes entries when the last owner closes.
const triggerOwners = asyncIdToRequests.get(triggerAsyncId);
if (triggerOwners !== undefined) {
triggerOwners.forEach(triggerRequest => {
if (
!isRequestClosingOrClosed(triggerRequest) &&
!cleanedRequests.has(triggerRequest)
) {
addRequestOwnership(triggerRequest, asyncId);
}
});
}
}
},
before(asyncId: number): void {
const node = pendingOperations.get(asyncId);
Expand Down Expand Up @@ -335,6 +437,7 @@ export function initAsyncDebugInfo(): void {
// If we needed the meta data from this operation we should have already
// extracted it or it should be part of a chain of triggers.
pendingOperations.delete(asyncId);
removeAllRequestOwnership(asyncId);
},
}).enable();
}
Expand All @@ -345,7 +448,9 @@ export function markAsyncSequenceRootTask(): void {
// Whatever Task we're running now is spawned by React itself to perform render work.
// Don't track any cause beyond this task. We may still track I/O that was started outside
// React but just not the cause of entering the render.
pendingOperations.delete(executionAsyncId());
const asyncId = executionAsyncId();
pendingOperations.delete(asyncId);
removeAllRequestOwnership(asyncId);
}
}

Expand Down Expand Up @@ -386,3 +491,24 @@ export function getAsyncSequenceFromPromise(
}
return node;
}

export function cleanupAsyncDebugInfo(request: any): void {
if (__DEV__ && enableAsyncDebugInfo) {
cleanedRequests.add(request);
// Ensure we don't retain chains through the process-global fallback pointer.
lastRanAwait = null;

// Sweep all asyncIds tracked for this request from the global pendingOperations map.
// Delete an asyncId only when this was its final request owner so sharing across
// active requests does not lose lineage.
const ids = requestAsyncIds.get(request);
if (ids !== undefined) {
ids.forEach(id => {
if (removeRequestOwnership(request, id)) {
pendingOperations.delete(id);
}
});
requestAsyncIds.delete(request);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export function getAsyncSequenceFromPromise(
): null | AsyncSequence {
return null;
}
export function cleanupAsyncDebugInfo(request: any): void {}