Skip to content

More deduplication and cleanup in TS streams - #7795

Open
jasnell wants to merge 2 commits into
mainfrom
jasnell/ts-streams-duplicate-consolidaton-15
Open

jasnell wants to merge 2 commits into
mainfrom
jasnell/ts-streams-duplicate-consolidaton-15

Conversation

@jasnell

@jasnell jasnell commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

A TransformStreamDefaultController was created and then attached to
its stream in one synchronous step, before any user code could reach
it, so the four "Controller is not attached to a stream" throws in
its methods could never fire. The controller now takes its stream as
a constructor argument, as ReadableStreamDefaultController does, and
the init slot goes. Its length stays 0, which the module sets
explicitly.

The inner streams' start algorithms record their controllers while
the TransformStream constructor builds the pair, before
transformer.start(), the first user code that can reach it. The two
fields are now typed as always set, and the nine checks for an unset
one go, among them desiredSize's null arm and enqueue's
"TransformStream is not fully initialized" throw.

Nothing observable changes.

@jasnell
jasnell requested a review from a team October 16, 2026 23:46
@jasnell
jasnell requested review from a team as code owners October 16, 2026 23:46
@clanky-the-clanker

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

Copy link
Copy Markdown

✅ Approved — This is a behaviour-preserving cleanup with no findings. The inner streams' controllers are recorded synchronously during construction, which makes the removed unset-checks unreachable, as the description says.

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


✅ All checks passed

🔒 Security: ✅ LGTM

No security concerns.


📖 AGENTS.md: ✅ LGTM

No AGENTS.md updates are needed.


📋 Code Quality: ✅ LGTM

No correctness problems were found in transform.ts or readable.ts.

  • Controllers always set: The writable's and readable's start callbacks (transform.ts:548-551, transform.ts:568-571) assign #writableController and #readableController. The inner controllers call start synchronously from their constructors (for example writable.ts:1180-1183). Both fields are therefore set before transformer.start() runs, so typing them as always set and removing the unset checks is sound.
  • Merged finish path: #finish keeps the earlier behaviour for each of its three cases:
    • close runs the flush algorithm; on success it closes the readable.
    • abort runs the cancel algorithm; on success it errors the readable with the reason.
    • cancel runs the cancel algorithm; on success it errors the writable.
    • In every case the finish promise is set before the algorithm runs, and a rejection errors the half that ended.
  • #closeReadable: It now checks canCloseOrEnqueue before closing, in place of the old try/catch. The result is the same.
  • Controller constructor: TransformStreamDefaultController now takes its stream as a constructor argument. This removes the init slot and the four "not attached" throws, which could never fire.

⚡ Performance: ✅ LGTM

No performance concerns.


📝 Documentation: ✅ LGTM

The updated comments describe the current code.


Reviewed commit: 7f02098d

@ask-bonk

ask-bonk Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Since last review: 0 resolved, 0 still open, 1 new.
LGTM!

The TransformStream cleanup remains sound; the completion-path comment still needs correction.

Other findings

  • [INFO] src/per_isolate/webstreams/transform.ts:330: terminate() closes the readable side but errors the writable side. Therefore a pending close or abort observes a closed readable and resolves after its algorithm fulfills; only cancel observes the writable error and rejects. Update the comment so it does not promise rejection for all three paths.

    // it, as does any started later. The ended half's state is read when the
    // algorithm settles (step 7.1.1). A prior abort or error that reached it
    // rejects the finish with its stored error; terminate() does so for cancel
    // because it errors the writable, while close and abort observe the readable,
    // which terminate() closes.
    

Carried forward from the last review: docs (no author changes in their files since then; earlier findings stand)

Not re-run: api-compat, tests, compat-flags, design-simplicity (no author changes in their files since the last review)


Reviewed commit: 7f02098d · github run

DUP-42, as D-7 decides: unreachable code goes.

A TransformStreamDefaultController was created and then attached to
its stream in one synchronous step, before any user code could reach
it, so the four "Controller is not attached to a stream" throws in
its methods could never fire. The controller now takes its stream as
a constructor argument, as ReadableStreamDefaultController does, and
the init slot goes. Its length stays 0, which the module sets
explicitly.

The inner streams' start algorithms record their controllers while
the TransformStream constructor builds the pair, before
transformer.start(), the first user code that can reach it. The two
fields are now typed as always set, and the nine checks for an unset
one go, among them desiredSize's null arm and enqueue's
"TransformStream is not fully initialized" throw.

Nothing observable changes.
DUP-24. The sink's close and abort algorithms and the source's cancel
algorithm were three copies of one skeleton: return the finish
promise if a finish has started; otherwise set it, run flush or
cancel, clear the algorithms, and settle the finish when that result
settles, rejecting with the ended half's stored error if that half
has errored by then. They differed only in the algorithm and in the
half they end: the readable for the sink's close and abort, the
writable for the source's cancel. #finish(kind, reason) now holds the
skeleton once, and the inner sink and source call it.

#closeReadable is the spec's ReadableStreamDefaultControllerClose,
which returns at once when the readable cannot close. terminate() and
the close finish each spelled it as a try/catch around the captured
close(). It now checks CanCloseOrEnqueue, which internalsForTransform
already exports, rather than catching whatever close() throws:
nothing in close() can throw once that check passes.

The finish promise is still set before the algorithm runs and the
algorithms clear after it, with one reaction on its result, so
nothing observable changes. Mutation probes against the three copies
found each of their differences pinned, by the transform suite or by
WPT's transform-streams.
@jasnell
jasnell force-pushed the jasnell/ts-streams-duplicate-consolidaton-15 branch from eb5c02f to 7f02098 Compare October 17, 2026 17:16
@jasnell
jasnell added this pull request to stack #7801 October 17, 2026 17:18
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.

1 participant