cloudflare / cloudflare/workerd

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

Open
#6,479 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

## Which version of workerd are you using?
main (0d830b19, 2026-03-27)
## Describe the Bug
When the remote peer cleanly closes a TCP connection (sends FIN) with `allowHalfOpen=false` (the default), calling `socket.close()` after the readable stream reaches EOF causes `socket.closed` to reject with `undefined` instead of resolving.
Per the [docs](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/#socket):
> `closed` `Promise` — This promise is resolved when the socket is closed and is rejected if the socket encounters an error.
A remote FIN followed by application-initiated cleanup is not an error condition — `closed` should resolve.
## Minimal Reproduction
Server (in-process `connect()` handler):
```js
export default {
async connect(socket) {
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode('ping'));
await writer.close(); // sends FIN to the client
},
};
```
Client — the key is calling `socket.close()` after draining the readable to EOF:
```js
import { connect } from 'cloudflare:sockets';
const socket = connect('localhost:8084'); // allowHalfOpen defaults to false
await socket.opened;
for await (const chunk of socket.readable) {
// drain to EOF
}
// Application calls close() as part of cleanup after the read loop exits.
// This races with the maybeCloseWriteSide microtask triggered by EOF.
socket.close();
// BUG: rejects with `undefined` instead of resolving
await socket.closed;
```
This is a common pattern — drain the readable, then clean up by closing the socket.
## Root Cause
Two code paths race when EOF arrives with `allowHalfOpen=false`:
**Path A — `handleReadableEof` (line 497):** When the readable stream reaches EOF, `handleReadableEof` queues `maybeCloseWriteSide` as a `.then` microtask. `maybeCloseWriteSide` (line 508) calls `writable->getController().close(js)` to flush and close the write side, then resolves `closedResolver`.
**Path B — `Socket::close()` (line 281):** Application cleanup code (running synchronously after `for await` exits) calls `socket.close()`, which calls `writable->getController().abort(js, kj::none)` (line 304). `kj::none` becomes `js.v8Undefined()` as the abort reason, transitioning the writable into `StreamStates::Errored` with stored error = `undefined`.
**The race:** Path B runs synchronously after the `for await` loop exits. Path A's `.then` microtask runs after. By the time `maybeCloseWriteSide` calls `writable->getController().close(js)`, the writable is already in `Errored(undefined)`. `closeImpl` (at `src/workerd/api/streams/internal.c++:1137-1139`) sees the errored state and returns a rejected promise with the stored error (`undefined`). The `.catch_` in `maybeCloseWriteSide` (line 526-529) passes that raw V8 handle directly to `closedResolver.reject`:
```cpp
ref->closedResolver.reject(js, exc.getHandle(js));
```
This uses the `reject(Lock&, v8::Local)` overload (at `src/workerd/jsg/promise.h:345`), which does not wrap the value. Since the value is `undefined`, `socket.closed` rejects with `undefined`.
## Two issues
1. **`closed` rejects instead of resolving on clean remote FIN + application close.** The documented contract is "resolved when the socket is closed, rejected if the socket encounters an error." Both the remote FIN and the application's `socket.close()` are intentional — neither is an error.
2. **The rejection reason is `undefined`, making the error undiagnosable.** While the docs do not specify what the rejection value should be, rejecting with `undefined` means `err instanceof Error` is false and `String(err)` yields `"undefined"`, giving callers no information about what happened. The `undefined` originates from `abort(js, kj::none)` at line 304, which is spec-compliant for WHATWG writable stream abort — but `maybeCloseWriteSide` surfaces that raw internal value as the `socket.closed` rejection reason without wrapping it.
## Reproduction test files
`socket-closed-test.wd-test`:
```capnp
using Workerd = import "/workerd/workerd.capnp";
const unitTests :Workerd.Config = (
services = [
( name = "socket-closed-test",
worker = (
modules = [
(name = "worker", esModule = embed "socket-closed-test.js"),
],
compatibilityFlags = ["nodejs_compat_v2", "experimental"],
)
),
( name = "internet", network = ( allow = ["private"] ) )
],
sockets = [
(name = "tcp", address = "*:8084", tcp = (), service = "socket-closed-test"),
]
);
```
`socket-closed-test.js`:
```js
import { connect } from 'cloudflare:sockets';
import { strictEqual, notStrictEqual } from 'assert';
// Server: writes a small payload then closes (sends FIN).
export default {
async connect(socket) {
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode('ping'));
await writer.close();
},
};
// Test 1 (core bug): socket.close() after EOF causes socket.closed to reject with undefined.
// The for-await exits on EOF, then socket.close() races with the maybeCloseWriteSide microtask.
export const closedRejectsUndefinedAfterCloseRace = {
async test() {
const socket = connect('localhost:8084');
await socket.opened;
const dec = new TextDecoder();
let data = '';
for await (const chunk of socket.readable) {
data += dec.decode(chunk, { stream: true });
}
data += dec.decode();
strictEqual(data, 'ping');
// This is what application cleanup code does — close the socket after the read loop.
// It races with maybeCloseWriteSide (triggered by EOF).
socket.close();
// socket.closed must resolve (not reject with undefined).
try {
await socket.closed;
} catch (e) {
notStrictEqual(e, undefined,
'socket.closed rejected with undefined — should resolve or reject with a real Error');
}
},
};
// Test 2: if socket.closed rejects via any path, the reason must not be undefined.
export const closedRejectionReasonIsNeverUndefined = {
async test() {
const socket = connect('localhost:8084');
await socket.opened;
// Close immediately — don't drain readable.
socket.close();
try {
await socket.closed;
} catch (e) {
notStrictEqual(e, undefined,
'socket.closed rejected with undefined');
}
},
};
// Test 3: without socket.close(), a clean remote FIN should resolve socket.closed.
export const closedResolvesAfterRemoteEofWithoutExplicitClose = {
async test() {
const socket = connect('localhost:8084');
await socket.opened;
const dec = new TextDecoder();
let data = '';
for await (const chunk of socket.readable) {
data += dec.decode(chunk, { stream: true });
}
data += dec.decode();
strictEqual(data, 'ping');
// Do NOT call socket.close() — let maybeCloseWriteSide handle everything.
await socket.closed;
},
};
```
`BUILD.bazel` entry (alongside `connect-handler-test`):
```python
wd_test(
src = "socket-closed-test.wd-test",
args = ["--experimental"],
data = ["socket-closed-test.js"],
tags = ["exclusive"],
)
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.