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
TypeScript streams: a native-to-native pipeTo() leaves a failed source closed rather than errored #8055
With typescript_implemented_streams, when a native-backed ReadableStream (a fetch() or service-binding body, a socket's readable) is piped into a native-backed writable (a connect() socket's writable, a writable received over JS RPC) and the source errors mid-pipe, the source is left closed rather than errored. Reading it afterwards resolves { done: true } instead of rejecting with the source's error.
Per the spec, a source that errors stays errored: pipeTo() only cancels the source when the destination fails or the pipe is aborted. The C++ implementation leaves it errored.
Cause
Both ends being native, pipeTo() takes the fast path (readableStreamPipeTo in src/per_isolate/webstreams/readable.ts): it extracts the source's native half and hands the pipe to the sink's pipeFrom hook (WritableStreamNativeSink::pipeFrom in src/workerd/api/js-writable-stream.c++). Extraction closes the source at the start (closeReadableStreamHusk; see HANDED-OFF STREAMS in src/per_isolate/webstreams/AGENTS.md), before the outcome is known, and closed cannot become errored afterwards.
Fixing it needs two things:
The fast path must not close the source until the pipe settles: closed on success or abort, errored on a source failure. pipeSocketToSocketClosesSource in src/tests/streams/sockets/socket-streams.js currently pins the source as closed at the start, so it would change.
pipeFrom's rejection must say which side failed. Today the pump (source->pumpTo(sink)) rejects with one exception and doesn't distinguish a source failure from a sink failure.
Reproduction
In the sockets streams suite (src/tests/streams/sockets/), whose SELF service serves /failing: a body that delivers one chunk and then errors.
constsocket=connect(echoAddress(env));constbody=(awaitenv.SELF.fetch('http://self/failing')).body;awaitbody.pipeTo(socket.writable).catch(()=>{});// rejects: the body failedconstreader=body.getReader();console.log(awaitreader.read().then((r)=>`done=${r.done}`,(e)=>`rejected ${e.message}`));
Source read after the pipe
C++ streams
rejected the body failed
typescript_implemented_streams
done=true
nativePipeReleasesEndpoints in src/tests/streams/sockets/socket-streams.js pins this as a divergence, along with the abort cases where C++ also differs. On an abort the TypeScript behavior (source cancelled, so closed) is the spec's. Only the source-error case is a spec deviation.
Related
Found while fixing #8049, which made a native-to-native pipe release both endpoints once it settles. That fix leaves the source in the state described here.
Closing as an accepted, identified deviation rather than fixing it.
Getting the spec result here needs the native pipe to know which side failed. It can't tell a source failure from a destination failure: on the KJ-level pump paths (socket and HTTP-body pumps through EncodedAsyncOutputStream::pumpFromNative), a failed read and a failed write reach pipeFrom as the same exception. Recording which side failed would mean wrapping the KJ streams, which defeats fd splice and KJ pipe adoption.
The spec cancels the source, leaving it closed, on success, on an abort and on a destination failure, so the current behaviour is spec-correct in every case except a source failure. After a native-to-native pipe, the TypeScript source therefore reads closed whatever the outcome; the source-failure case is the one deviation. nativePipeReleasesEndpoints in src/tests/streams/sockets/socket-streams.js pins it, along with the C++ behaviour (C++ errors the source on any failure).
Activity
jasnell commentedon Oct 28, 2026
Closing as an accepted, identified deviation rather than fixing it.
Getting the spec result here needs the native pipe to know which side failed. It can't tell a source failure from a destination failure: on the KJ-level pump paths (socket and HTTP-body pumps through
EncodedAsyncOutputStream::pumpFromNative), a failed read and a failed write reachpipeFromas the same exception. Recording which side failed would mean wrapping the KJ streams, which defeats fd splice and KJ pipe adoption.The spec cancels the source, leaving it closed, on success, on an abort and on a destination failure, so the current behaviour is spec-correct in every case except a source failure. After a native-to-native pipe, the TypeScript source therefore reads closed whatever the outcome; the source-failure case is the one deviation.
nativePipeReleasesEndpointsinsrc/tests/streams/sockets/socket-streams.jspins it, along with the C++ behaviour (C++ errors the source on any failure).