Skip to content

TypeScript streams: a native-to-native pipeTo() leaves a failed source closed rather than errored #8055

Closed as not planned

Description

@jasnell

Description

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.

const socket = connect(echoAddress(env));
const body = (await env.SELF.fetch('http://self/failing')).body;
await body.pipeTo(socket.writable).catch(() => {}); // rejects: the body failed
const reader = body.getReader();
console.log(await reader.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.

Activity

  1. jasnell commented on Oct 28, 2026

    @jasnell
    CollaboratorAuthor

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

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