Over a service binding (env.X.connect(), and likewise a Durable Object stub), a connect() handler that echoes with socket.readable.pipeTo(socket.writable) deadlocks a client that awaits write() before reading: the client's write() never resolves. The same handler written as an explicit reader.read() / writer.write() loop echoes correctly.
The connection between the two Workers is an in-process kj two-way pipe with no buffer, and pipeTo between the socket's own halves becomes a zero-copy pump. The client's write therefore only completes once the pumped bytes are written into the client's readable, which the client only reads after its write resolves. A TCP connection has kernel buffers on both sides, so the same client and server work over a sockets TCP listener.
Repro
// worker.js
export default {
async connect(socket) {
await socket.readable.pipeTo(socket.writable);
},
};
export const echoRoundTrip = {
async test(ctrl, env) {
const socket = env.SELF.connect('echo:1');
const writer = socket.writable.getWriter();
const reader = socket.readable.getReader();
const timer = setTimeout(() => console.log('write() still pending after 2s'), 2000);
await writer.write(new TextEncoder().encode('ping'));
clearTimeout(timer);
const { value } = await reader.read();
console.log('echoed:', new TextDecoder().decode(value));
},
};
using Workerd = import "/workerd/workerd.capnp";
const unitTests :Workerd.Config = (
services = [( name = "main", worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2025-06-01",
bindings = [(name = "SELF", service = "main")],
))],
);
workerd test worker.wd-test prints write() still pending after 2s and the test times out. Replacing the handler body with
const reader = socket.readable.getReader();
const writer = socket.writable.getWriter();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
await writer.write(value);
}
await writer.close();
prints echoed: ping and passes. Observed on main at 925464b.
Impact
socket.readable.pipeTo(socket.writable) is the canonical echo, and "write the request, await it, then read the response" is the normal shape of a request/response client, so this is easy to hit in tests and in Worker-to-Worker or Worker-to-Durable-Object socket protocols.
- The failure is a silent hang with no error on either side.
- It only arises when the response path leads back to the caller without a buffer in between: same-socket pipes and loopback shapes. A handler piping to a real upstream (a
sockets TCP connection) is unaffected because the upstream buffers.
- Programs ported from Node.js or POSIX assume socket-buffer semantics; there is no equivalent divergence over real TCP.
Possible resolutions
A bounded buffer in the in-process two-way pipe so that a write() completes on enqueue as it does into a kernel socket buffer, or documenting that pipeTo echo over service bindings requires the client to read concurrently with writing.
Found while testing net.Server over connect() (#7306); the net.Socket adapter uses an explicit read/write loop and is not affected.
Over a service binding (
env.X.connect(), and likewise a Durable Object stub), aconnect()handler that echoes withsocket.readable.pipeTo(socket.writable)deadlocks a client that awaitswrite()before reading: the client'swrite()never resolves. The same handler written as an explicitreader.read()/writer.write()loop echoes correctly.The connection between the two Workers is an in-process kj two-way pipe with no buffer, and
pipeTobetween the socket's own halves becomes a zero-copy pump. The client's write therefore only completes once the pumped bytes are written into the client's readable, which the client only reads after its write resolves. A TCP connection has kernel buffers on both sides, so the same client and server work over asocketsTCP listener.Repro
workerd test worker.wd-testprintswrite() still pending after 2sand the test times out. Replacing the handler body withprints
echoed: pingand passes. Observed onmainat 925464b.Impact
socket.readable.pipeTo(socket.writable)is the canonical echo, and "write the request, await it, then read the response" is the normal shape of a request/response client, so this is easy to hit in tests and in Worker-to-Worker or Worker-to-Durable-Object socket protocols.socketsTCP connection) is unaffected because the upstream buffers.Possible resolutions
A bounded buffer in the in-process two-way pipe so that a
write()completes on enqueue as it does into a kernel socket buffer, or documenting thatpipeToecho over service bindings requires the client to read concurrently with writing.Found while testing
net.Serveroverconnect()(#7306); thenet.Socketadapter uses an explicit read/write loop and is not affected.