cloudflare / cloudflare/workerd
Same-socket pipeTo echo over a service binding deadlocks a client that awaits write() before reading
- Dominant language
- C++
- Stars
- 8.7k
- Forks
- 739
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 174
Description
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
```js
// 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
```js
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.
Contributor guide
Research direction
Run `workerd test worker.wd-test` with the `worker.js` reproduction, then trace the in-process kj two-way pipe used by `env.X.connect()` and the socket `pipeTo` path. Done means the existing client can await `writer.write()` before reading, receives `ping`, and the test no longer hangs; compare with the explicit reader/writer loop and the TCP listener behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, javascript
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100