Summary
When a client disconnects from a GraphQL subscription delivered over SSE, @graphql-tools/executor-http detaches the stream reader but never cancels the response body. The underlying HTTP response - and its TCP socket - is never torn down, so the connection stays ESTABLISHED until the process restarts.
Present in 3.3.0 (current latest at time of writing).
Affected code
packages/executors/http/src/handleEventStreamResponse.ts (line ~42):
stop
.then(() => {
subscriptionCtrl?.abort();
if (body.locked) {
reader.releaseLock(); // <-- detaches the reader; body is never cancelled
}
})
.catch((err) => {
reader.cancel(err); // <-- cancel() only on the rejection path
});
releaseLock() releases the reader's lock on the stream. It does not cancel the stream, so the response body remains active and the connection is retained. reader.cancel() propagates cancellation to the underlying source and closes it.
Note the asymmetry: the error path already calls cancel(). Only the normal-teardown path uses releaseLock().
Impact
A gateway or client that opens SSE subscriptions and later disposes them accumulates one leaked socket per disposed subscription. In our case a GraphQL gateway reached ~10,000 concurrent connections against fewer than 200 requests/hour, and the upstream service eventually OOM'd. Connection count dropped to zero only on process restart.
Reproduction
- Open a GraphQL subscription over SSE through an executor built with
buildHTTPExecutor.
- Kill the consuming client abruptly (or dispose the returned async iterable).
- Observe the TCP connection to the upstream (e.g.
ss -tan state established, or /proc/net/tcp).
The socket remains ESTABLISHED indefinitely.
Evidence
Measured with per-socket tracking (recording the exact local ports opened by the test client, then checking those specific ports). Builds were stamped with the package version and a per-run nonce so there was no ambiguity about which code executed.
| Build |
Sockets still open after client death |
1.1.2 |
0 of 11 closed |
3.3.0 (stock) |
0 of 12 closed |
3.3.0 with releaseLock() → cancel() |
12 of 12 closed within 5s |
A stock control run in the same session leaked 12/12, so the one-line change is the difference.
Suggested fix
stop
.then(() => {
subscriptionCtrl?.abort();
if (body.locked) {
- reader.releaseLock();
+ reader.cancel().catch(() => {});
}
})
.catch((err) => {
reader.cancel(err);
});
.catch(() => {}) because cancelling an already-errored stream can reject, and teardown should not surface that.
Happy to open a PR - I have the change plus a changeset ready, with check:format, check:lint, check:types, check:missing-peer-deps, build and bundle all passing locally on Node v26.
#2485
Related, possibly worth a separate issue
Older versions (1.1.2, published from ardatan/graphql-tools) had a different and worse failure in the same area. handleAsyncIterable was a bare async generator with no cleanup:
async function* handleAsyncIterable(asyncIterable) {
outer: for await (const chunk of asyncIterable) { ... } // entire function
}
Suspended at an await rather than a yield, a consumer's .return() is queued and never processed, so cleanup is unreachable while the generator waits for a chunk. Combined with graphql-yoga's SSE keep-alive - which does if (!controller.desiredSize) { clearInterval(pingInterval); return } and never re-arms, so pings stop permanently once the consumer stops draining - no chunk ever arrives and teardown deadlocks.
1.3.3 replaced that generator with a Repeater that has a real teardown path, which resolves the deadlock. But it introduced/retained the releaseLock() call above, so the socket still isn't released. The two issues are independent; only the second remains in 3.3.0.
Environment
@graphql-tools/executor-http 1.1.2 / 1.3.3 / 3.3.0
- Node 20.15 and 24.x (consumer side)
- Upstream server:
graphql-yoga 5.6.x (SSE result processor)
- Consumer: GraphQL Mesh gateway (
@graphql-mesh/* 0.99.x)
Summary
When a client disconnects from a GraphQL subscription delivered over SSE,
@graphql-tools/executor-httpdetaches the stream reader but never cancels the response body. The underlying HTTP response - and its TCP socket - is never torn down, so the connection staysESTABLISHEDuntil the process restarts.Present in
3.3.0(current latest at time of writing).Affected code
packages/executors/http/src/handleEventStreamResponse.ts(line ~42):releaseLock()releases the reader's lock on the stream. It does not cancel the stream, so the response body remains active and the connection is retained.reader.cancel()propagates cancellation to the underlying source and closes it.Note the asymmetry: the error path already calls
cancel(). Only the normal-teardown path usesreleaseLock().Impact
A gateway or client that opens SSE subscriptions and later disposes them accumulates one leaked socket per disposed subscription. In our case a GraphQL gateway reached ~10,000 concurrent connections against fewer than 200 requests/hour, and the upstream service eventually OOM'd. Connection count dropped to zero only on process restart.
Reproduction
buildHTTPExecutor.ss -tan state established, or/proc/net/tcp).The socket remains
ESTABLISHEDindefinitely.Evidence
Measured with per-socket tracking (recording the exact local ports opened by the test client, then checking those specific ports). Builds were stamped with the package version and a per-run nonce so there was no ambiguity about which code executed.
1.1.23.3.0(stock)3.3.0withreleaseLock()→cancel()A stock control run in the same session leaked 12/12, so the one-line change is the difference.
Suggested fix
stop .then(() => { subscriptionCtrl?.abort(); if (body.locked) { - reader.releaseLock(); + reader.cancel().catch(() => {}); } }) .catch((err) => { reader.cancel(err); });.catch(() => {})because cancelling an already-errored stream can reject, and teardown should not surface that.Happy to open a PR - I have the change plus a changeset ready, with
check:format,check:lint,check:types,check:missing-peer-deps,buildandbundleall passing locally on Node v26.#2485
Related, possibly worth a separate issue
Older versions (
1.1.2, published fromardatan/graphql-tools) had a different and worse failure in the same area.handleAsyncIterablewas a bare async generator with no cleanup:Suspended at an
awaitrather than ayield, a consumer's.return()is queued and never processed, so cleanup is unreachable while the generator waits for a chunk. Combined withgraphql-yoga's SSE keep-alive - which doesif (!controller.desiredSize) { clearInterval(pingInterval); return }and never re-arms, so pings stop permanently once the consumer stops draining - no chunk ever arrives and teardown deadlocks.1.3.3replaced that generator with aRepeaterthat has a real teardown path, which resolves the deadlock. But it introduced/retained thereleaseLock()call above, so the socket still isn't released. The two issues are independent; only the second remains in3.3.0.Environment
@graphql-tools/executor-http1.1.2 / 1.3.3 / 3.3.0graphql-yoga5.6.x (SSE result processor)@graphql-mesh/*0.99.x)