fix(webapp): eliminate SSE abort-signal memory leak (#3430)

## Summary

Fixes a server-side memory leak in the webapp's SSE helper. Every
aborted SSE connection (client tab close, navigation, timeout) was
pinning its full request/response graph indefinitely on Node 20, so any
long-running webapp process accumulated retained memory proportional to
streaming-request churn.

## Root cause

`apps/webapp/app/utils/sse.ts` combined four abort signals via
`AbortSignal.any([requestAbortSignal, timeoutSignal,
internalController.signal])`. The composite signal tracks its source
signals in an internal `Set<WeakRef>` registered against a
`FinalizationRegistry`; under sustained traffic those entries accumulate
faster than they're cleaned up, pinning every source signal (and its
listeners, and anything those listeners close over) until the parent
signal itself is GC'd or aborts.

This is a long-standing Node issue with multiple open reports:

- [nodejs/node#54614](https://github.com/nodejs/node/issues/54614) —
original report, still open. A [follow-up from
ChainSafe](https://github.com/nodejs/node/issues/54614#issuecomment-4055656572)
describes the exact same shape in a Lodestar production workload (req +
timeout signals composed per request accumulating in long-running
worker) and the same mitigation: drop `AbortSignal.any`, compose
manually.
- [nodejs/node#55351](https://github.com/nodejs/node/issues/55351) —
mechanism confirmed by Node member @jasnell: *"the set of dependent
signals known to the AbortSignal are kept in an internal Set using
WeakRefs. The AbortSignals are being properly gc'd but the Set is never
cleaned out of the WeakRefs making those leak."* Partially fixed by [PR
#55354](https://github.com/nodejs/node/pull/55354), shipped in Node
22.12.0 — but only covers the tight-loop case, not long-lived parent
signals.
- [nodejs/node#57584](https://github.com/nodejs/node/issues/57584) —
circular-dependency variant, still open.
- [nodejs/node#62363](https://github.com/nodejs/node/issues/62363) —
regression in Node 24/25 from an unrelated V8 change ("Don't pretenure
WeakCells"). Different root cause, same symptom.

A separate issue in `apps/webapp/app/entry.server.tsx` —
`setTimeout(abort, ABORT_DELAY)` with no `clearTimeout` on success paths
— kept the React render tree + `remixContext` alive for 30s per
successful HTML request. Same pattern fixed upstream in React Router
templates
([react-router#14200](https://github.com/remix-run/react-router/pull/14200)),
never backported to Remix v2.

## What changed

- **`apps/webapp/app/utils/sse.ts`** — single-signal abort chain.
`AbortSignal.any` removed; `AbortSignal.timeout` replaced by a plain
`setTimeout` cleared when the controller aborts; named sentinel
constants used as stackless abort reasons; request-abort handler
explicitly removed on cleanup.
- **`apps/webapp/app/entry.server.tsx`** — clears the `setTimeout(abort,
ABORT_DELAY)` timer in `onShellReady` / `onAllReady` / `onShellError`.
- **`apps/webapp/app/v3/tracer.server.ts` + `env.server.ts`** — gates
OpenTelemetry `HttpInstrumentation` and `ExpressInstrumentation` behind
`DISABLE_HTTP_INSTRUMENTATION=true` as an escape hatch for future
OTel-listener retention patterns. Defaults to enabled.
- **`apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts`** —
uses the shared `ABORT_REASON_SEND_ERROR` sentinel.

## Verification

### Full-app reproduction (memlab)

Isolated local harness, 500 abrupt SSE disconnects against a
dev-presence route, GC between passes, heap snapshot diff with
[memlab](https://facebook.github.io/memlab/):

| Run | Heap delta after 500 conns + GC | memlab retained leaks |
| --- | --- | --- |
| Before | +16.0 MB (linear with request count) | 158 clusters; 250
`ServerResponse`, 1000 `AbortController`, 250 `SpanImpl` retained |
| After | **+3.3 MB (noise)** | **0 app-code leaks** |

### Standalone mechanism isolation

To confirm *which* axis of the change is load-bearing, a separate
standalone Node script (`/tmp/abort-leak-test.mjs`) ran 2000 requests ×
200 KB payload per variant:

| Variant | Heap delta after GC |
| --- | --- |
| baseline (no signal machinery) | 0 MB |
| V1: `AbortSignal.any` + string abort reason | **+9.1 MB** |
| V2: `AbortSignal.any` only (no reason) | **+10.8 MB** |
| V3: string reason only (no `AbortSignal.any`) | 0 MB |
| V4: neither (the fix) | 0 MB |
| V5: `AbortSignal.any` with no listener on the composite | **+10.2 MB**
|

This proves `AbortSignal.any` is the sole mechanism. The reason type
(`.abort()` vs `.abort("string")`) is irrelevant for retention — V3 is
clean, V5 leaks even without a listener on the composite.

## Risk

- `sse.ts` is used by the dev-presence routes. Behaviour is equivalent —
timeouts and client disconnects still abort the stream. `signal.reason`
is now a named string sentinel (`"timeout"`, `"request_aborted"`, etc.)
instead of the previous string arg or default `AbortError`. No in-tree
reader of `signal.reason` exists.
- `entry.server.tsx` change is a standard cleanup of an abort timer,
matches upstream React Router guidance.
- `tracer.server.ts` change is env-gated and defaults to current
behaviour.
- Three other webapp `AbortSignal.timeout()` callsites (alert delivery,
remote-build status) are fire-and-forget passed directly to `fetch` —
not composed with anything long-lived, no retention risk, untouched.

## Test plan

- [ ] Existing SSE integration tests pass
- [ ] Dev-presence SSE behaves normally across tab open/close cycles
- [ ] No heap growth under sustained aborted-connection traffic (heap
snapshot diff)

## Follow-up

The same `AbortSignal.any([userSignal, internalSignal])` pattern exists
in several SDK/core callsites that ship to customers
(`packages/core/src/v3/realtimeStreams/manager.ts`,
`packages/trigger-sdk/src/v3/{ai,chat,chat-client,sessions}.ts`,
`packages/core/src/v3/workers/warmStartClient.ts`). Whether those leak
in practice depends on the user passing a long-lived signal. Tracked
separately.
This commit is contained in:
Eric Allam
2026-04-23 14:54:05 +01:00
committed by GitHub
parent 87b6716535
commit 486f49791d
6 changed files with 93 additions and 44 deletions
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Fix memory leak where every aborted SSE connection pinned the full request/response graph on Node 20, caused by `AbortSignal.any()` in `sse.ts` retaining its source signals indefinitely (see nodejs/node#54614, nodejs/node#55351). Also clear the `setTimeout(abort)` timer in `entry.server.tsx` so successful HTML renders don't pin the React tree for 30s per request.
+14 -2
View File
@@ -83,6 +83,10 @@ function handleBotRequest(
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
// Timer handle is cleared in every terminal callback so the abort closure
// (which captures the full React render tree + remixContext) doesn't pin
// memory for 30s per successful request. See react-router PR #14200.
let abortTimer: NodeJS.Timeout | undefined;
const { pipe, abort } = renderToPipeableStream(
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
@@ -105,8 +109,10 @@ function handleBotRequest(
);
pipe(body);
clearTimeout(abortTimer);
},
onShellError(error: unknown) {
clearTimeout(abortTimer);
reject(error);
},
onError(error: unknown) {
@@ -121,7 +127,7 @@ function handleBotRequest(
}
);
setTimeout(abort, ABORT_DELAY);
abortTimer = setTimeout(abort, ABORT_DELAY);
});
}
@@ -135,6 +141,10 @@ function handleBrowserRequest(
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
// Timer handle is cleared in every terminal callback so the abort closure
// (which captures the full React render tree + remixContext) doesn't pin
// memory for 30s per successful request. See react-router PR #14200.
let abortTimer: NodeJS.Timeout | undefined;
const { pipe, abort } = renderToPipeableStream(
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
@@ -157,8 +167,10 @@ function handleBrowserRequest(
);
pipe(body);
clearTimeout(abortTimer);
},
onShellError(error: unknown) {
clearTimeout(abortTimer);
reject(error);
},
onError(error: unknown) {
@@ -173,7 +185,7 @@ function handleBrowserRequest(
}
);
setTimeout(abort, ABORT_DELAY);
abortTimer = setTimeout(abort, ABORT_DELAY);
});
}
+1
View File
@@ -438,6 +438,7 @@ const EnvironmentSchema = z
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
DISABLE_HTTP_INSTRUMENTATION: BoolEnv.default(false),
INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),
INTERNAL_OTEL_METRIC_EXPORTER_URL: z.string().optional(),
@@ -1,7 +1,7 @@
import { type PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { createSSELoader, SendFunction } from "~/utils/sse";
import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse";
import { throttle } from "~/utils/throttle";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
@@ -66,8 +66,10 @@ export class RunStreamPresenter {
});
}
}
// Abort the stream on send error
context.controller.abort("Send error");
// Abort the stream on send error. Uses a stackless string sentinel
// from sse.ts — a no-arg abort() would create a DOMException with a
// stack trace, which is unnecessary retention on the signal.reason.
context.controller.abort(ABORT_REASON_SEND_ERROR);
}
},
1000
+63 -37
View File
@@ -38,6 +38,20 @@ type SSEOptions = {
// This is used to track the open connections, for debugging
const connections: Set<string> = new Set();
// Stackless sentinel reasons passed to AbortController#abort. Calling .abort()
// with no argument produces a DOMException that captures a ~500-byte stack
// trace; a string reason is stored verbatim with no stack. The choice of
// reason type does not cause the retention we saw in prod (that was the
// AbortSignal.any composite — see comment near the timeoutTimer below for the
// Node issue refs), but naming the sentinels keeps call sites readable and
// lets future signal.reason consumers branch on the cause.
export const ABORT_REASON_REQUEST = "request_aborted";
export const ABORT_REASON_TIMEOUT = "timeout";
export const ABORT_REASON_SEND_ERROR = "send_error";
export const ABORT_REASON_INIT_STOP = "init_requested_stop";
export const ABORT_REASON_ITERATOR_STOP = "iterator_requested_stop";
export const ABORT_REASON_ITERATOR_ERROR = "iterator_error";
export function createSSELoader(options: SSEOptions) {
const { timeout, interval = 500, debug = false, handler } = options;
@@ -45,7 +59,6 @@ export function createSSELoader(options: SSEOptions) {
const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8);
const internalController = new AbortController();
const timeoutSignal = AbortSignal.timeout(timeout);
const log = (message: string) => {
if (debug)
@@ -60,16 +73,20 @@ export function createSSELoader(options: SSEOptions) {
if (!internalController.signal.aborted) {
originalSend(event);
}
// If controller is aborted, silently ignore the send attempt
} catch (error) {
if (error instanceof Error) {
if (error.message?.includes("Controller is already closed")) {
// Silently handle controller closed errors
return;
}
log(`Error sending event: ${error.message}`);
}
throw error; // Re-throw other errors
// Abort before rethrowing so timer + request-abort listener are cleaned
// up immediately. Otherwise a send-failure in initStream leaves them
// alive until `timeout` fires.
if (!internalController.signal.aborted) {
internalController.abort(ABORT_REASON_SEND_ERROR);
}
throw error;
}
};
};
@@ -92,51 +109,57 @@ export function createSSELoader(options: SSEOptions) {
const requestAbortSignal = getRequestAbortSignal();
const combinedSignal = AbortSignal.any([
requestAbortSignal,
timeoutSignal,
internalController.signal,
]);
log("Start");
requestAbortSignal.addEventListener(
// Single-signal abort chain: everything rolls up into internalController.
// Timeout is a plain setTimeout cleared on abort rather than an
// AbortSignal.timeout() combined via AbortSignal.any() — AbortSignal.any
// keeps its source signals in an internal Set<WeakRef> managed by a
// FinalizationRegistry, and under sustained request traffic those entries
// accumulate faster than they get cleaned up, pinning every source signal
// (and its listeners, and anything those listeners close over) until the
// parent signal is GC'd or aborts. Reproduced locally in isolation; shape
// matches the ChainSafe Lodestar production case described in
// nodejs/node#54614. See also nodejs/node#55351 (mechanism confirmed by
// @jasnell, narrow fix in 22.12.0 via #55354) and nodejs/node#57584
// (circular-dep variant, still open).
const timeoutTimer = setTimeout(() => {
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_TIMEOUT);
}, timeout);
const onRequestAbort = () => {
log("request signal aborted");
if (!internalController.signal.aborted) internalController.abort(ABORT_REASON_REQUEST);
};
internalController.signal.addEventListener(
"abort",
() => {
log(`request signal aborted`);
internalController.abort("Request aborted");
clearTimeout(timeoutTimer);
requestAbortSignal.removeEventListener("abort", onRequestAbort);
},
{ once: true, signal: internalController.signal }
{ once: true }
);
combinedSignal.addEventListener(
"abort",
() => {
log(`combinedSignal aborted: ${combinedSignal.reason}`);
},
{ once: true, signal: internalController.signal }
);
timeoutSignal.addEventListener(
"abort",
() => {
if (internalController.signal.aborted) return;
log(`timeoutSignal aborted: ${timeoutSignal.reason}`);
internalController.abort("Timeout");
},
{ once: true, signal: internalController.signal }
);
// The request could have been aborted during `await handler(context)` above.
// AbortSignal listeners added after the signal is already aborted never fire,
// so invoke cleanup synchronously in that case instead of waiting for `timeout`.
if (requestAbortSignal.aborted) {
onRequestAbort();
} else {
requestAbortSignal.addEventListener("abort", onRequestAbort, { once: true });
}
if (handlers.beforeStream) {
const shouldContinue = await handlers.beforeStream();
if (shouldContinue === false) {
log("beforeStream returned false, so we'll exit before creating the stream");
internalController.abort("Init requested stop");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
return eventStream(combinedSignal, function setup(send) {
return eventStream(internalController.signal, function setup(send) {
connections.add(id);
const safeSend = createSafeSend(send);
@@ -147,14 +170,14 @@ export function createSSELoader(options: SSEOptions) {
const shouldContinue = await handlers.initStream({ send: safeSend });
if (shouldContinue === false) {
log("initStream returned false, so we'll stop the stream");
internalController.abort("Init requested stop");
internalController.abort(ABORT_REASON_INIT_STOP);
return;
}
}
log("Starting interval");
for await (const _ of setInterval(interval, null, {
signal: combinedSignal,
signal: internalController.signal,
})) {
log("PING");
@@ -165,13 +188,16 @@ export function createSSELoader(options: SSEOptions) {
const shouldContinue = await handlers.iterator({ date, send: safeSend });
if (shouldContinue === false) {
log("iterator return false, so we'll stop the stream");
internalController.abort("Iterator requested stop");
internalController.abort(ABORT_REASON_ITERATOR_STOP);
break;
}
} catch (error) {
log("iterator threw an error, aborting stream");
// Immediately abort to trigger cleanup
internalController.abort(error instanceof Error ? error.message : "Iterator error");
if (error instanceof Error && error.name !== "AbortError") {
log(`iterator error: ${error.message}`);
}
internalController.abort(ABORT_REASON_ITERATOR_ERROR);
// No need to re-throw as we're handling it by aborting
return; // Exit the run function immediately
}
+4 -2
View File
@@ -302,13 +302,15 @@ function setupTelemetry() {
provider.register();
let instrumentations: Instrumentation[] = [
new HttpInstrumentation(),
new ExpressInstrumentation(),
new AwsSdkInstrumentation({
suppressInternalInstrumentation: true,
}),
];
if (!env.DISABLE_HTTP_INSTRUMENTATION) {
instrumentations.unshift(new HttpInstrumentation(), new ExpressInstrumentation());
}
if (env.INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED === "1") {
instrumentations.push(new PrismaInstrumentation());
}