Skip to content

Add socket.closed rejection repro test#6483

Open
ask-bonk[bot] wants to merge 1 commit intomainfrom
opencode/issue6479-20260402115053
Open

Add socket.closed rejection repro test#6483
ask-bonk[bot] wants to merge 1 commit intomainfrom
opencode/issue6479-20260402115053

Conversation

@ask-bonk
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot commented Apr 2, 2026

The commit is clean. Here's a summary of what was done:

Changes

Three files added/modified to reproduce the bug from the issue:

src/workerd/api/tests/socket-closed-test.js (new)

Contains a connect() handler that writes "ping" then closes (sending FIN), plus three test cases:

  1. closedRejectsUndefinedAfterCloseRace — Core bug reproduction. Drains readable via for await, then calls socket.close(). This races with maybeCloseWriteSide (queued as a microtask by handleReadableEof). The close() call aborts the writable with kj::noneundefined, so when the microtask fires, the writable is already errored and closedResolver.reject receives undefined.

  2. closedRejectionReasonIsNeverUndefined — Calls socket.close() immediately without draining. If socket.closed rejects, the reason must not be undefined.

  3. closedResolvesAfterRemoteEofWithoutExplicitClose — Drains to EOF without calling socket.close(). Verifies the baseline: a clean remote FIN should resolve socket.closed.

src/workerd/api/tests/socket-closed-test.wd-test (new)

Cap'n Proto config defining the test worker with nodejs_compat_v2 + experimental flags, an internet network service, and a TCP socket on port 8084.

src/workerd/api/tests/BUILD.bazel (modified)

Added wd_test entry for socket-closed-test with --experimental flag and exclusive tag (due to fixed TCP port usage).

Closes #6479

github run

…remote FIN

Reproduces the bug where calling socket.close() after draining a
readable stream to EOF (remote FIN) causes socket.closed to reject
with undefined instead of resolving. Two code paths race:
Socket::close() aborts the writable with kj::none (→ undefined), then
the maybeCloseWriteSide microtask finds the writable errored and
rejects closedResolver with the raw undefined value.

Three test cases:
1. Core race: close() after for-await EOF → closed rejects undefined
2. Immediate close() → rejection reason must not be undefined
3. No explicit close() → clean remote FIN resolves closed
@danlapid
Copy link
Copy Markdown
Collaborator

danlapid commented Apr 2, 2026

/bonk Only closedRejectsUndefinedAfterCloseRace test fails and only in the all-compat-flags configuration, that seems to mean that there is some specific compat flag causing this bug, can you try to understand which compat flag that is? Then add that flag's "compatDisableFlag" to the test configuration so that we see if disabling the flag indeed makes the test pass. Do it in a new commit

Copy link
Copy Markdown
Contributor Author

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings

  • [HIGH] Test closedRejectsUndefinedAfterCloseRace fails at @all-compat-flags on all platforms. This is an intentional regression test for #6479, but since the bug is unfixed, this test breaks CI. See inline comments for details.
  • [MEDIUM] The connect handler has an unhandled promise rejection from writer.close() when the client disconnects. Visible in CI logs as Uncaught (in promise): Error: Network connection lost.
  • [LOW] Copyright header year says 2017-2022; new files added in 2026 should use the current year range.

This review was generated by an AI assistant and may contain inaccuracies.

'socket.closed rejected with undefined — should resolve or reject with a real Error'
);
}
},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] This test correctly reproduces the bug from #6479, but since the bug is not yet fixed, the test fails at @all-compat-flags across all CI platforms, breaking the build.

The @all-compat-flags variant (compat date 2999-12-31) enables internal_writable_stream_abort_clears_queue (field @57, enable date 2024-09-02). With this flag, Socket::close()writable->getController().abort(js, kj::none) immediately calls drain()doError(js, undefined), putting the writable into Errored(undefined) state synchronously. Then when the maybeCloseWriteSide microtask fires, isClosedOrClosing() returns false (it doesn't check for Errored state), so it calls close() on the errored stream, which rejects with undefined, and the catch handler rejects closedResolver with undefined.

Without the flag (at the default compat date 2000-01-01), the lazy abort path handles things differently and the race doesn't manifest — that's why @ and @all-autogates pass.

Per danlapid's comment, the specific flag to investigate is internal_writable_stream_abort_clears_queue. Adding "internal_writable_stream_abort_does_not_clear_queue" to the test's compatibilityFlags would confirm this and make @all-compat-flags pass — though the real fix belongs in Socket::close() or maybeCloseWriteSide.

Comment on lines +25 to +30
async connect(socket) {
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode('ping'));
await writer.close();
},
};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] The connect handler has an unhandled promise rejection. When the client-side test calls socket.close() and tears down the connection, the server-side writer.close() on line 28 rejects with Error: Network connection lost. This shows up in all CI logs as:

workerd/io/worker.c++:2452: info: uncaught exception; source = Uncaught (in promise); stack = Error: Network connection lost.
    at async Object.connect (worker:28:5)

Wrapping the body in a try/catch would silence this:

Suggested change
async connect(socket) {
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode('ping'));
await writer.close();
},
};
async connect(socket) {
const writer = socket.writable.getWriter();
try {
await writer.write(new TextEncoder().encode('ping'));
await writer.close();
} catch {
// Client may disconnect before close completes — expected in these tests.
}
},

@@ -0,0 +1,98 @@
// Copyright (c) 2017-2022 Cloudflare, Inc.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Copyright year range should be updated for a new file.

Suggested change
// Copyright (c) 2017-2022 Cloudflare, Inc.
// Copyright (c) 2017-2026 Cloudflare, Inc.

@ask-bonk
Copy link
Copy Markdown
Contributor Author

ask-bonk bot commented Apr 2, 2026

Review posted on PR #6483 with 3 inline comments and a summary. Here's what I found:

Key finding: The closedRejectsUndefinedAfterCloseRace test fails only in the @all-compat-flags variant because the compat flag internal_writable_stream_abort_clears_queue (field @57, enable date 2024-09-02) changes the abort behavior. With this flag enabled, Socket::close() calling abort(js, kj::none) immediately drains and errors the writable stream with undefined. The maybeCloseWriteSide microtask then finds the stream in Errored state (which isClosedOrClosing() doesn't detect), tries to close() it, gets a rejection with undefined, and rejects closedResolver with undefined.

The 3 review comments cover:

  1. [HIGH] The compat flag root cause analysis and suggestion to add "internal_writable_stream_abort_does_not_clear_queue" as a compatDisableFlag to confirm the diagnosis
  2. [MEDIUM] Unhandled promise rejection in the connect handler's writer.close() with a suggested fix
  3. [LOW] Copyright year should be 2017-2026 for a new file

github run

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

socket.closed rejects with undefined when socket.close() is called after remote FIN

1 participant