You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
π Bug Report β Runtime APIs: pipeTo() from a native body into a socket writable never releases its locks with typescript_implemented_streamsΒ #8049
With typescript_implemented_streams, when a native or buffer-backed ReadableStream (a fetch() or service-binding body, new Response("β¦").body, Blob.stream(), another socket's readable) is piped into a connect() socket's writable, pipeTo() never releases either lock. Once the pipe settles, source.locked and socket.writable.locked both stay true forever. This happens whether the pipe resolved, rejected because the source errored, or was aborted through signal.
As a result, socket.writable.close() and socket.writable.abort() reject with Cannot close a stream that is locked / Cannot abort a stream that is locked, getWriter() throws, and the source can never be read or cancelled again. Per the spec, pipeTo() releases its reader and writer when it finalizes. The C++ implementation does this, and so does the TypeScript one on its regular pipe path.
Only the direct native-to-native path is affected:
With a write still pending on the socket when the pipe starts (the shape pinned by pipeBehindUnawaitedWrite in src/tests/streams/sockets/socket-streams.js), both endpoints are unlocked afterwards.
With preventClose: true, both endpoints are unlocked afterwards.
A JavaScript ReadableStream piped into the socket, or a native body piped into an IdentityTransformStream, releases both locks.
Reproduction
worker.js: a worker that sends a raw HTTP request to its own listening socket.
import{connect}from"cloudflare:sockets";exportdefault{asyncfetch(request){consturl=newURL(request.url);if(url.pathname==="/target")returnnewResponse("ok");// Pipe a non-JS body into a TCP socket's writable, here a raw HTTP// request to this same server.constsocket=connect(url.host);constbody=newResponse(`GET /target HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n`).body;awaitbody.pipeTo(socket.writable);constresponse=awaitnewResponse(socket.readable).text();returnResponse.json({response: response.split("\r\n")[0],sourceLocked: body.locked,writableLocked: socket.writable.locked,});},};
The same leak happens when the destination is a WritableStream received over JS RPC, and there a write made before the pipe doesn't avoid it. The remote sink receives every byte and is closed, but locally both endpoints stay locked after the pipe resolves, rejects on a source error, or is aborted.
// env.RPC is a WorkerEntrypoint whose makeWritable() returns a JS WritableStream.constwritable=awaitenv.RPC.makeWritable();constbody=newResponse(newUint8Array(5000)).body;awaitbody.pipeTo(writable);console.log(body.locked,writable.locked);
Source
flag
compatibilityFlags = []
fetch() / service-binding body, Blob.stream(), new Response(bytes).body
true true
false false
same, after a header was written with getWriter() / write() / releaseLock()
true true
false false
a JavaScript ReadableStream
false false
false false
As with sockets, { preventClose: true } releases both locks.
Activity
james-elicx commentedon Oct 27, 2026
The same leak happens when the destination is a
WritableStreamreceived over JS RPC, and there a write made before the pipe doesn't avoid it. The remote sink receives every byte and is closed, but locally both endpoints stay locked after the pipe resolves, rejects on a source error, or is aborted.compatibilityFlags = []fetch()/ service-binding body,Blob.stream(),new Response(bytes).bodytrue truefalse falsegetWriter()/write()/releaseLock()true truefalse falseReadableStreamfalse falsefalse falseAs with sockets,
{ preventClose: true }releases both locks.Release the endpoints of a native TypeScript streams pipe