You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
🐛 Bug Report — Runtime APIs: Socket closed/stream-EOF promises never settle at IoContext teardown, permanently leaking AsyncLocalStorage frames via pending reactions #7202
When an invocation ends, workerd tears down its cloudflare:sockets sockets without settling socket.closed or the readable stream's EOF promise. Any reaction registered on those promises stays pending forever. Because promise reactions capture the active AsyncContextFrame, a reaction registered inside an AsyncLocalStorage.run(...) scope permanently retains the whole stored context graph.
With a database driver that subscribes to these promises per connection (postgres.js's Cloudflare build does: closed.then(...), reader acquisition, a read loop with an always-pending reader.read()), every invocation leaks its full context — in our production-shaped worker, ~145 KB per scheduled invocation, monotonic through GC, until the 128 MB isolate limit.
Environment
workerd via wrangler 4.84.1, local dev (reproduced)
Production Workers (observed, identical slope)
nodejs_compat
Compatibility date 2026-04-20
Sockets opened through a Hyperdrive binding's connection string, using postgres.js 3.4.9
Retainer chain
From V8 heap snapshots, with a forced GC between samples; every hop verified:
(Global handles)
-> pending PromiseReaction
-> socket `closed` promise (held by its workerd Resolver / the stream's eofResolverPair)
-> captured AsyncContextFrame
-> StorageEntry
-> the AsyncLocalStorage value (our per-request context object)
-> everything it references
Reproduction sketch
In a worker with nodejs_compat:
import{AsyncLocalStorage}from'node:async_hooks';import{connect}from'cloudflare:sockets';constals=newAsyncLocalStorage();exportdefault{asyncscheduled(){awaitals.run({big: newUint8Array(100_000)},async()=>{constsocket=connect({hostname: 'example.com',port: 5432});socket.closed.then(()=>{});// invocation ends without closing the socket explicitly});},};
Let the invocation end without closing the socket explicitly (or even after close() in some teardown orders). Drive N invocations, force GC, take a snapshot: one pinned AsyncContextFrame per invocation, held by the pending reaction.
Registering the same handler through an AsyncLocalStorage.snapshot() captured at module scope (an empty frame) eliminates the growth — that is the workaround we ship today, as a patch to the driver.
Expected
Sockets destroyed at IoContext teardown settle their closed and EOF promises (rejecting with a teardown error, matching the behavior documented for I/O outliving a request), so reactions run and release their frames.
Impact
Any long-lived isolate — cron-driven Workers especially — using AsyncLocalStorage together with a socket-based driver leaks until OOM.
Possibly related: #5665 (unresolved promises retained in streaming paths) may be a sibling of the same teardown-settlement gap, though the growth here is monotonic across forced GCs and across invocations, rather than a matter of promise volume within a single request.
I’m going to investigate this and work on a fix. I’ll trace the socket/stream teardown paths and add focused regression coverage for promise settlement and context release. Please let me know if someone is already working on it.
Update: I've opened #7258 with a fix and regression tests.
Short version of what I found while reproducing this with --expose-gc + WeakRefs on main:
The minimal repro as described (reactions on socket.closed / reader.read() inside als.run() on a socket that is never closed) does not leak: once the request's IoContext is torn down those pending promises are ordinary garbage and the ALS stores are collected.
What does leak is socket.close() called without awaiting while buffered writes keep the internal flush pending past teardown. In that case the Socket, its promises and every AsyncContextFrame reachable from them are retained for the lifetime of the isolate (12/12 stores retained in the test, across GC cycles).
Root cause: the close() continuations in sockets.c++ capture self = JSG_THIS in plain lambdas (since 67f730b). An untraced jsg::Ref is a strong v8::Global, so the cycle Socket → writable → flush promise → continuation → Socket roots itself. The PR wraps those captures in JSG_VISITABLE_LAMBDA so GC can trace them; the UAF fix from 67f730b is preserved (its regression test still passes, including @gc-stress).
The PR deliberately does not change IoContext cancellation semantics, i.e. it does not settle pending promises at teardown. That would be a separate behavioural change; the retention itself is fixed without it.
Activity
Byte-Naut commentedon Sep 6, 2026
I’m going to investigate this and work on a fix. I’ll trace the socket/stream teardown paths and add focused regression coverage for promise settlement and context release. Please let me know if someone is already working on it.
Byte-Naut commentedon Sep 7, 2026
Update: I've opened #7258 with a fix and regression tests.
Short version of what I found while reproducing this with
--expose-gc+WeakRefs onmain:socket.closed/reader.read()insideals.run()on a socket that is never closed) does not leak: once the request'sIoContextis torn down those pending promises are ordinary garbage and the ALS stores are collected.socket.close()called without awaiting while buffered writes keep the internal flush pending past teardown. In that case theSocket, its promises and everyAsyncContextFramereachable from them are retained for the lifetime of the isolate (12/12 stores retained in the test, across GC cycles).close()continuations insockets.c++captureself = JSG_THISin plain lambdas (since 67f730b). An untracedjsg::Refis a strongv8::Global, so the cycle Socket → writable → flush promise → continuation → Socket roots itself. The PR wraps those captures inJSG_VISITABLE_LAMBDAso GC can trace them; the UAF fix from 67f730b is preserved (its regression test still passes, including@gc-stress).The PR deliberately does not change
IoContextcancellation semantics, i.e. it does not settle pending promises at teardown. That would be a separate behavioural change; the retention itself is fixed without it.api: trace Socket::close() continuation captures so pending closes ca…
api: trace Socket::close() continuation captures so pending closes ca…
api: trace Socket::close() continuation captures so pending closes ca…
api: trace Socket::close() continuation captures so pending closes ca…