Skip to content

Release the endpoints of a native TypeScript streams pipe - #8056

Merged
jasnell merged 1 commit into
mainfrom
jasnell/ts-streams-native-pipe-locks
Oct 29, 2026
Merged

jasnell merged 1 commit into
mainfrom
jasnell/ts-streams-native-pipe-locks

Conversation

@jasnell

@jasnell jasnell commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Under typescript_implemented_streams, pipeTo() from a native-backed readable (a fetch() or service-binding body, a buffer-backed body, Blob.stream(), a socket's readable) into a native-backed writable (a connect() socket's writable, a writable received over JS RPC) runs at the C++ layer through the sink's pipeFrom hook. Extracting the two native halves locks both streams with internal handles, which the fast path never released: once the pipe settled, both endpoints stayed locked for good, so the writable could not be closed, aborted or written again, and the source could not be read or cancelled. The JS pipe path, and the C++ implementation, release both.

Once pipeFrom settles, the fast path now finishes the pipe as the JS pipe's shutdown and finalize would: it closes the destination through its internal writer when the pipe succeeded, or aborts it with the pipe's error, and then releases the writer and the source's reader. The extracted sink's hooks no longer reach the C++ sink, so neither reaches the connection.

The source has been closed since its extraction and stays closed, so it reads done whatever the outcome. On an abort that is the spec's cancel. C++ instead errors the source when the pipe is aborted or the source fails, and with an already-aborted signal touches neither endpoint. The sockets suite pins these as a divergence.

Tests: nativePipeReleasesEndpoints in the sockets suite (success, mid-pipe abort, an already-aborted signal, a failing service-binding body), and the cross-implementation RPC test for a writable received over RPC, with and without a write made before the pipe, and with a failing source.

typescript_implemented_streams is still experimental, with no enable date, so the change needs no compatibility flag.

Fixes: #8049

@jasnell
jasnell requested review from a team and guybedford October 28, 2026 00:40
@jasnell
jasnell requested review from a team as code owners October 28, 2026 00:40
@clanky-the-clanker

clanky-the-clanker Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

💬 Approved with comments — The fix is correct and well tested. One optional suggestion from the last review is still open: a synchronous throw from the pipeFrom hook, after both streams are extracted, leaves them locked.

Findings: 1 suggestion

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.


📋 Code Quality: 💬 1 suggestion

The fix is correct. One optional hardening suggestion from the last review is still open.

Recommendation: 💬 Optional Nits

  • [SUGGESTION] A synchronous throw from the pipeFrom call, or the missing-hook TypeError, happens after both streams are extracted. finishNativePipe never runs, so both endpoints stay locked. Turn the throw into a rejected promise and pass it to finishNativePipe. (src/per_isolate/webstreams/readable.ts:3919)
View full review

A synchronous throw after extraction leaves both endpoints locked (src/per_isolate/webstreams/readable.ts:3908-3922)

Both sourceExtractor and sinkExtractor run before the hook is called, so both streams are already locked with internal handles. Two things can throw at that point, and in both cases finishNativePipe never runs:

  • the pipeFrom === undefined TypeError;
  • uncurryThis(pipeFrom)(nativeSink, nativeSource, converted), which is evaluated as an argument to finishNativePipe and can throw synchronously, for example from a JSG_REQUIRE check in the C++ sink's pipeFrom.

The outer catch then rejects, and both endpoints stay locked for good. That is the outcome this pull request fixes on the asynchronous path. The dispatch checks make most of these throws unreachable, so this is defensive hardening, not a regression.

To close the gap, turn a synchronous throw into a rejected promise and pass that promise to finishNativePipe, for example let p; try { p = call(); } catch (e) { p = PromiseReject(e); }. The error then takes the same abort-and-release path.

The new nativePipeIntoRpcWritableReleasesLocks test covers success, a write made before the pipe and a failing source. It looks correct.


✅ All other checks passed

🔒 Security: ✅ LGTM

No security concerns.


📖 AGENTS.md: ✅ LGTM

The AGENTS.md updates match the new behaviour.


⚡ Performance: ✅ LGTM

No performance concerns.


📝 Documentation: ✅ LGTM

No documentation issues.


Reviewed commit: 2eb5b37a

@ask-bonk

ask-bonk Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

LGTM!

PR #8056 releases TypeScript native-pipe endpoint locks and expands socket/RPC coverage.

Other findings

  • [INFO] src/per_isolate/webstreams/AGENTS.md:128: The source's closed promise settles during closeReadableStreamHusk() at extraction, while pipe settlement only closes or errors the destination. Saying it settles "then" makes the documented timing of finished(source) ambiguous.

    The source's Node.js interop closed-promise settles at its extraction.
    The destination's settles when the pipe closes or errors it; the interop
    error hook, which acts only on a readable stream, cannot reach the moved
    source.
    
Specialist findings not posted (1)
  • correctness#1 [WARNING] src/per_isolate/webstreams/readable.ts:3915: pipeFrom() is evaluated before finishNativePipe() can install its rejection finalizer. It can throw synchronously after both extractors have taken their endpoints: at old getter-on-instance compat… — Verified, but it duplicates the existing published clanky-the-clanker review on this head.

Reviewed commit: 35f0c1fc · github run

@jasnell
jasnell enabled auto-merge October 28, 2026 20:40
Under typescript_implemented_streams, pipeTo() from a native-backed
readable (a fetch() or service-binding body, a buffer-backed body,
Blob.stream(), a socket's readable) into a native-backed writable (a
connect() socket's writable, a writable received over JS RPC) runs at
the C++ layer through the sink's pipeFrom hook. Extracting the two
native halves locks both streams with internal handles, which the
fast path never released: once the pipe settled, both endpoints
stayed locked for good, so the writable could not be closed, aborted
or written again, and the source could not be read or cancelled. The
JS pipe path, and the C++ implementation, release both.

Once pipeFrom settles, the fast path now finishes the pipe as the JS
pipe's shutdown and finalize would: it closes the destination
through its internal writer when the pipe succeeded, or aborts it
with the pipe's error, and then releases the writer and the source's
reader. The extracted sink's hooks no longer reach the C++ sink, so
neither reaches the connection.

The source has been closed since its extraction and stays closed, so
it reads done whatever the outcome. On an abort that is the spec's
cancel. C++ instead errors the source when the pipe is aborted or the
source fails, and with an already-aborted signal touches neither
endpoint. The sockets suite pins these as a divergence.

Tests: nativePipeReleasesEndpoints in the sockets suite (success,
mid-pipe abort, an already-aborted signal, a failing service-binding
body), and the cross-implementation RPC test for a writable received
over RPC, with and without a write made before the pipe, and with a
failing source.

typescript_implemented_streams is still experimental, with no enable
date, so the change needs no compatibility flag.

Fixes: #8049
@jasnell
jasnell force-pushed the jasnell/ts-streams-native-pipe-locks branch from 35f0c1f to 2eb5b37 Compare October 28, 2026 22:47
@jasnell
jasnell merged commit c564d8a into main Oct 29, 2026
25 of 26 checks passed
@jasnell
jasnell deleted the jasnell/ts-streams-native-pipe-locks branch October 29, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report — Runtime APIs: pipeTo() from a native body into a socket writable never releases its locks with typescript_implemented_streams

2 participants