Same-socket pipeTo echo over a service binding deadlocks a client that awaits write() before reading
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
socketsTCP 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.
Source: cloudflare/workerd