#7314·workerd

Same-socket pipeTo echo over a service binding deadlocks a client that awaits write() before reading

Author: guybedfordCreated Sep 11, 2026Updated Sep 11, 2026

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

javascript
// 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));
  },
};
capnp
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

javascript
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.