From 52615039df8a24276e6d88a5791d5eda959ead99 Mon Sep 17 00:00:00 2001 From: Andreas Lubbe Date: Mon, 16 Feb 2026 15:35:53 +0100 Subject: [PATCH 1/3] [react-server][dev] Clean up pendingOperations on request close to prevent dev memory leak --- .../react-server/src/ReactFlightServer.js | 22 ++++++++ .../src/ReactFlightServerConfigDebugNode.js | 52 ++++++++++++++++++- .../src/ReactFlightServerConfigDebugNoop.js | 1 + 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 3bafdcf40bc9..44bbb12dec37 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -92,6 +92,7 @@ import { markAsyncSequenceRootTask, getCurrentAsyncSequence, getAsyncSequenceFromPromise, + cleanupAsyncDebugInfo, parseStackTrace, parseStackTracePrivate, supportsComponentStorage, @@ -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; @@ -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.', { @@ -6086,6 +6094,9 @@ function flushCompletedChunks(request: Request): void { close(request.destination); request.destination = null; } + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } return; } } @@ -6109,6 +6120,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); + } } } @@ -6158,6 +6174,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; } @@ -6183,6 +6202,9 @@ export function startFlowingDebug( ): void { if (request.status === CLOSING) { request.status = CLOSED; + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } closeWithError(debugDestination, request.fatalError); return; } diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 9bb521be4065..ec5b774a081a 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -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'; @@ -37,6 +41,12 @@ const getAsyncId = AsyncResource.prototype.asyncId; const pendingOperations: Map = __DEV__ && enableAsyncDebugInfo ? new Map() : (null: any); +// Tracks which asyncIds belong to each request, for cleanup when the request closes. +const requestAsyncIds: WeakMap> = __DEV__ && +enableAsyncDebugInfo + ? new WeakMap() + : (null: any); + // Keep the last resolved await as a workaround for async functions missing data. let lastRanAwait: null | AwaitNode = null; @@ -201,6 +211,21 @@ export function initAsyncDebugInfo(): void { } } pendingOperations.set(asyncId, node); + const request = resolveRequest(); + if (request !== null) { + if (isRequestClosingOrClosed(request)) { + // The request is already closing/closed. Don't track this asyncId + // since no sweep will run for it. Remove it immediately. + pendingOperations.delete(asyncId); + } else { + let ids = requestAsyncIds.get(request); + if (ids === undefined) { + ids = new Set(); + requestAsyncIds.set(request, ids); + } + ids.add(asyncId); + } + } }, before(asyncId: number): void { const node = pendingOperations.get(asyncId); @@ -386,3 +411,28 @@ export function getAsyncSequenceFromPromise( } return node; } + +export function cleanupAsyncDebugInfo(request: any): void { + if (__DEV__ && enableAsyncDebugInfo) { + // Sweep all asyncIds tracked for this request from the global pendingOperations map. + // This prevents monotonic growth of pendingOperations across requests. + // + // Trade-off: if another request later chains .then() on a promise that originated + // in this (now-closed) request, the trigger lookup in init() will return undefined + // and lineage for that chain won't be tracked. This is the same behavior as for any + // async operation originating outside tracked scope, and is acceptable because RSC + // requests are independent renders. + // + // Note: the per-request Set may contain asyncIds already removed by the destroy hook. + // The extra deletes are harmless no-ops. We don't trim the Set from destroy because + // resolveRequest() is unreliable in the destroy context (it runs during GC, not + // necessarily within any request's AsyncLocalStorage scope). + const ids = requestAsyncIds.get(request); + if (ids !== undefined) { + ids.forEach(function (id) { + pendingOperations.delete(id); + }); + requestAsyncIds.delete(request); + } + } +} diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js index e435929114b5..90a6e9675f83 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNoop.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNoop.js @@ -20,3 +20,4 @@ export function getAsyncSequenceFromPromise( ): null | AsyncSequence { return null; } +export function cleanupAsyncDebugInfo(request: any): void {} From 5866937ba322a7e98bd42337c166b64c0f1920bb Mon Sep 17 00:00:00 2001 From: Andreas Lubbe Date: Tue, 17 Feb 2026 10:48:49 +0100 Subject: [PATCH 2/3] Optimize peak mem usage --- .../src/ReactFlightServerConfigDebugNode.js | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index ec5b774a081a..08454a1930a5 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -210,21 +210,22 @@ export function initAsyncDebugInfo(): void { node = trigger; } } - pendingOperations.set(asyncId, node); + // Only track this asyncId if there is an active, non-closing request. + // If there is no request context then no cleanupAsyncDebugInfo sweep + // will ever remove the entry, and if the request is already + // closing/closed the sweep already ran or won't run. In both cases we + // skip the insert entirely to avoid unbounded growth of + // pendingOperations and a wasteful set-then-delete cycle. The destroy + // hook is unreliable for cleanup since it depends on GC timing. const request = resolveRequest(); - if (request !== null) { - if (isRequestClosingOrClosed(request)) { - // The request is already closing/closed. Don't track this asyncId - // since no sweep will run for it. Remove it immediately. - pendingOperations.delete(asyncId); - } else { - let ids = requestAsyncIds.get(request); - if (ids === undefined) { - ids = new Set(); - requestAsyncIds.set(request, ids); - } - ids.add(asyncId); + if (request !== null && !isRequestClosingOrClosed(request)) { + pendingOperations.set(asyncId, node); + let ids = requestAsyncIds.get(request); + if (ids === undefined) { + ids = new Set(); + requestAsyncIds.set(request, ids); } + ids.add(asyncId); } }, before(asyncId: number): void { @@ -429,9 +430,7 @@ export function cleanupAsyncDebugInfo(request: any): void { // necessarily within any request's AsyncLocalStorage scope). const ids = requestAsyncIds.get(request); if (ids !== undefined) { - ids.forEach(function (id) { - pendingOperations.delete(id); - }); + ids.forEach(id => pendingOperations.delete(id)); requestAsyncIds.delete(request); } } From 3785572147f3148a53baa7a15f3fcca3a3f8076a Mon Sep 17 00:00:00 2001 From: Andreas Lubbe Date: Tue, 17 Feb 2026 15:49:23 +0100 Subject: [PATCH 3/3] Improve on previous design a bit more --- .../react-server/src/ReactFlightServer.js | 16 +- .../src/ReactFlightServerConfigDebugNode.js | 137 ++++++++++++++---- 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 44bbb12dec37..58275380d58b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -6086,11 +6086,12 @@ 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; } @@ -6234,6 +6235,9 @@ function finishHalt(request: Request, abortedTasks: Set): void { const onAllReady = request.onAllReady; onAllReady(); flushCompletedChunks(request); + if (__DEV__) { + cleanupAsyncDebugInfo(request); + } } catch (error) { logRecoverableError(request, error, null); fatalError(request, error); @@ -6250,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); @@ -6304,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); diff --git a/packages/react-server/src/ReactFlightServerConfigDebugNode.js b/packages/react-server/src/ReactFlightServerConfigDebugNode.js index 08454a1930a5..23f1bc4e1d48 100644 --- a/packages/react-server/src/ReactFlightServerConfigDebugNode.js +++ b/packages/react-server/src/ReactFlightServerConfigDebugNode.js @@ -42,11 +42,24 @@ const pendingOperations: Map = __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> = __DEV__ && enableAsyncDebugInfo ? new WeakMap() : (null: any); +// Tracks request ownership for each asyncId. +const asyncIdToRequests: Map> = __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 = __DEV__ && enableAsyncDebugInfo + ? new WeakSet() + : (null: any); + // Keep the last resolved await as a workaround for async functions missing data. let lastRanAwait: null | AwaitNode = null; @@ -64,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. @@ -78,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') { @@ -110,7 +181,6 @@ export function initAsyncDebugInfo(): void { } } else { promiseRef = new WeakRef((resource: Promise)); - const request = resolveRequest(); if (request === null) { // We don't collect stacks for awaits that weren't in the scope of a specific render. } else { @@ -210,22 +280,28 @@ export function initAsyncDebugInfo(): void { node = trigger; } } - // Only track this asyncId if there is an active, non-closing request. - // If there is no request context then no cleanupAsyncDebugInfo sweep - // will ever remove the entry, and if the request is already - // closing/closed the sweep already ran or won't run. In both cases we - // skip the insert entirely to avoid unbounded growth of - // pendingOperations and a wasteful set-then-delete cycle. The destroy - // hook is unreliable for cleanup since it depends on GC timing. - const request = resolveRequest(); - if (request !== null && !isRequestClosingOrClosed(request)) { - pendingOperations.set(asyncId, node); - let ids = requestAsyncIds.get(request); - if (ids === undefined) { - ids = new Set(); - requestAsyncIds.set(request, ids); + // 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); + } + }); } - ids.add(asyncId); } }, before(asyncId: number): void { @@ -361,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(); } @@ -371,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); } } @@ -415,22 +494,20 @@ export function getAsyncSequenceFromPromise( 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. - // This prevents monotonic growth of pendingOperations across requests. - // - // Trade-off: if another request later chains .then() on a promise that originated - // in this (now-closed) request, the trigger lookup in init() will return undefined - // and lineage for that chain won't be tracked. This is the same behavior as for any - // async operation originating outside tracked scope, and is acceptable because RSC - // requests are independent renders. - // - // Note: the per-request Set may contain asyncIds already removed by the destroy hook. - // The extra deletes are harmless no-ops. We don't trim the Set from destroy because - // resolveRequest() is unreliable in the destroy context (it runs during GC, not - // necessarily within any request's AsyncLocalStorage scope). + // 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 => pendingOperations.delete(id)); + ids.forEach(id => { + if (removeRequestOwnership(request, id)) { + pendingOperations.delete(id); + } + }); requestAsyncIds.delete(request); } }