Skip to content

api: trace Socket::close() continuation captures so pending closes can be collected - #7258

Open
Byte-Naut wants to merge 2 commits into
cloudflare:mainfrom
Byte-Naut:fix/socket-close-traced-captures
Open

Byte-Naut wants to merge 2 commits into
cloudflare:mainfrom
Byte-Naut:fix/socket-close-traced-captures

Conversation

@Byte-Naut

Copy link
Copy Markdown

Fixes #7202

Problem

Socket::close() chains several jsg::Promise continuations that capture the Socket via JSG_THIS (plus one that captures the writable abort promise). Since 67f730b these have been plain lambda captures. A jsg::Ref that the GcVisitor never sees stays a strong v8::Global, i.e. a GC root.

When close() is called without being awaited while buffered writes keep the internal flush pending past IoContext teardown, the cycle

Socket -> writable -> flush promise -> continuation (self = JSG_THIS) -> Socket

is rooted from inside itself and can never be collected. The Socket, its closed/opened promises and every AsyncContextFrame reachable from them (the AsyncLocalStorage stores of long-finished requests) stay alive for the lifetime of the isolate. This is the retention reported in #7202.

While investigating I checked the issue's own hypothesis (unsettled promises at teardown) and could not reproduce it: with --expose-gc and WeakRefs, closed.then(...) / reader.read().then(...) registered inside als.run() on a socket that is never closed are collected normally once the request context is gone. The leak needs the pending close().

Fix

Wrap the five captures in JSG_VISITABLE_LAMBDA so the visitor traces them. The captures still keep the Socket alive while a continuation is reachable (from the pending flush's resolver held by KJ, or from the microtask queue), which is what 67f730b needed to fix its use-after-free; only the unreachable case becomes collectable. socket-close-gc-test (the UAF regression test) still passes, including the @gc-stress variant.

No change to IoContext cancellation semantics. Whether pending promises should be settled at teardown is a separate question and is left out of this PR.

Tests

socket-close-als-gc-test (legacy streams) and socket-close-als-gc-ts-test (TypeScript streams), three cases each:

case what it does before after
pendingCloseCollects 12 requests each write to a non-reading peer, call close() without awaiting, return 12/12 ALS stores retained collected
rpcTransferredPendingCloseCollects same, on a socket returned over a loopback capnp RPC boundary 12/12 retained collected
pendingWithoutCloseCollects control: same reactions, no close() collected collected

A fixture guard asserts that close() was still pending when each request returned, measured inside the request, so the test does not depend on what happens to the promise after teardown.

Verification

  • bazel build //src/workerd/... clean.
  • bazel test on the new tests plus socket-close-gc-test, js-rpc-socket-test, js-rpc-socket-streams-ts-test, connect-handler-test: all default, @all-compat-flags, @all-autogates, @eslint and @gc-stress variants pass.
  • Rebuilt a baseline binary from main's sockets.c++ to confirm the new tests fail without the fix (output above).
  • Additionally checked with a raw TCP peer that the KJ connection is dropped at IoContext teardown both before and after: the retention was JS-heap only, no fd leak.
  • tools/cross/format.py --check clean.

@github-actions

github-actions Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Byte-Naut

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Sep 7, 2026
@Byte-Naut

Byte-Naut commented Sep 7, 2026 •

Copy link
Copy Markdown
Author

Hi @jasnell — this PR addresses the retention reported in #7202, and it builds directly on the capture model you introduced in 67f730b70 (JSG_THIS rooting in Socket::close()), so I'm hoping you'd be able to take a look when you have a moment.

What changed: the five plain lambda captures in Socket::close() (four JSG_THIS, one abortPromise) are now wrapped in JSG_VISITABLE_LAMBDA, so GcVisitor can trace them. That turns them from unconditional GC roots (strong v8::Global) into conditionally-traced references — breaking the uncollectable Socket → writable → flush promise → continuation → Socket cycle that was pinning finished requests' AsyncLocalStorage stores, while still keeping the Socket alive as long as a continuation is reachable (the UAF protection from 67f730b70 is preserved).

Tests: socket-close-als-gc-test + a TypeScript-streams variant, exercising a directly-connected socket, the passive control, and a socket transferred over a real RPC boundary. On the baseline (untraced captures) both pending-close modes retain 12/12 request stores; with the fix they collect. The IoContext-cancellation semantics are unchanged and tracked separately in the test (closedBeforeReturn).

@Byte-Naut
Byte-Naut force-pushed the fix/socket-close-traced-captures branch from fb3e425 to b632c99 Compare September 14, 2026 03:45
@Byte-Naut

Copy link
Copy Markdown
Author

Hi @jasnell — I've rebased onto current main and resolved the Socket::close() overlap with #7313, retaining its close reason and this PR's traced captures. The focused socket-close and GC tests still pass, including the pipe-close test added in #7313. Could you or another sockets/JSG maintainer take a look and help run the internal build when convenient?

@Byte-Naut
Byte-Naut force-pushed the fix/socket-close-traced-captures branch from b632c99 to ddba9f4 Compare September 14, 2026 11:22
@Byte-Naut
Byte-Naut force-pushed the fix/socket-close-traced-captures branch from ddba9f4 to 0a03187 Compare September 22, 2026 02:24
…n be collected

Socket::close() chains several jsg::Promise continuations that capture the
Socket via JSG_THIS (and one that captures the writable abort promise). Since
67f730b these were plain lambda captures, i.e. untraced jsg::Refs, which act
as strong GC roots. When close() is called without being awaited while buffered
writes keep the internal flush pending past IoContext teardown, the resulting
cycle (Socket -> writable -> flush promise -> continuation -> Socket) can never
be collected. The Socket, its promises and every AsyncContextFrame reachable
from them (AsyncLocalStorage stores of finished requests) stay alive for the
lifetime of the isolate. This is the retention reported in cloudflare#7202.

Wrap the five captures in JSG_VISITABLE_LAMBDA so GcVisitor can trace them.
The captures still keep the Socket alive for as long as a continuation is
reachable (from the pending flush's resolver or the microtask queue), which is
what 67f730b needed; only the unreachable case becomes collectable.

Add socket-close-als-gc-test (legacy and TypeScript streams variants) which
fails on the previous code with 12/12 retained stores for both a directly
connected socket and a socket transferred over RPC, and passes with the fix.
The passive control (no close()) shows that pending promises alone are
collected after teardown. IoContext cancellation semantics are unchanged:
whether promises settle at teardown is tracked separately.

Fixes cloudflare#7202
@Byte-Naut
Byte-Naut force-pushed the fix/socket-close-traced-captures branch from 0a03187 to 260ef57 Compare October 7, 2026 10:05
@Byte-Naut

Byte-Naut commented Oct 10, 2026 •

Copy link
Copy Markdown
Author

Hi @guybedford — following up with someone who has direct context on this area: you authored #7313 which changed the same Socket::close() continuation to pass a TypeError reason to forceCancel/forceAbort. This PR's fix (wrapping the five self = JSG_THIS and abortPromise captures in JSG_VISITABLE_LAMBDA) sits right on top of that same code, and I've already resolved the overlap — the rebase retains your close reason and adds the traced captures without conflict.

The fix addresses the ALS frame retention reported in #7202: plain lambda captures act as unconditional GC roots, keeping the Socket → writable → flush promise → continuation → Socket cycle alive past IoContext teardown. Making them visitable breaks that cycle while preserving the strong hold during actual execution.

If you have a moment, a review would be very welcome — and as an external contributor I'd also appreciate your help triggering the internal-build check when convenient. Thanks very much.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant