Skip to content

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

Description

@guybedford

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions