Which version of workerd are you using?
main (0d830b1, 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:
closed Promise<void> — 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):
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:
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:
ref->closedResolver.reject(js, exc.getHandle(js));
This uses the reject(Lock&, v8::Local<v8::Value>) 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
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.
- 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:
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:
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):
wd_test(
src = "socket-closed-test.wd-test",
args = ["--experimental"],
data = ["socket-closed-test.js"],
tags = ["exclusive"],
)
Which version of workerd are you using?
main (0d830b1, 2026-03-27)
Describe the Bug
When the remote peer cleanly closes a TCP connection (sends FIN) with
allowHalfOpen=false(the default), callingsocket.close()after the readable stream reaches EOF causessocket.closedto reject withundefinedinstead of resolving.Per the docs:
Minimal Reproduction
Server (in-process
connect()handler):Client — the key is calling
socket.close()after draining the readable to EOF: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,handleReadableEofqueuesmaybeCloseWriteSideas a.thenmicrotask.maybeCloseWriteSide(line 508) callswritable->getController().close(js)to flush and close the write side, then resolvesclosedResolver.Path B —
Socket::close()(line 281): Application cleanup code (running synchronously afterfor awaitexits) callssocket.close(), which callswritable->getController().abort(js, kj::none)(line 304).kj::nonebecomesjs.v8Undefined()as the abort reason, transitioning the writable intoStreamStates::Erroredwith stored error =undefined.The race: Path B runs synchronously after the
for awaitloop exits. Path A's.thenmicrotask runs after. By the timemaybeCloseWriteSidecallswritable->getController().close(js), the writable is already inErrored(undefined).closeImpl(atsrc/workerd/api/streams/internal.c++:1137-1139) sees the errored state and returns a rejected promise with the stored error (undefined). The.catch_inmaybeCloseWriteSide(line 526-529) passes that raw V8 handle directly toclosedResolver.reject:This uses the
reject(Lock&, v8::Local<v8::Value>)overload (atsrc/workerd/jsg/promise.h:345), which does not wrap the value. Since the value isundefined,socket.closedrejects withundefined.Two issues
closedrejects 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'ssocket.close()are intentional — neither is an error.undefined, making the error undiagnosable. While the docs do not specify what the rejection value should be, rejecting withundefinedmeanserr instanceof Erroris false andString(err)yields"undefined", giving callers no information about what happened. Theundefinedoriginates fromabort(js, kj::none)at line 304, which is spec-compliant for WHATWG writable stream abort — butmaybeCloseWriteSidesurfaces that raw internal value as thesocket.closedrejection reason without wrapping it.Reproduction test files
socket-closed-test.wd-test:socket-closed-test.js:BUILD.bazelentry (alongsideconnect-handler-test):