Skip to content

stream: skip pipeline callback on sync throw - #65128

Open
lazerg wants to merge 1 commit into
nodejs:mainfrom
lazerg:fix/issue-65127-pipeline-sync-throw-callback
Open

stream: skip pipeline callback on sync throw#65128
lazerg wants to merge 1 commit into
nodejs:mainfrom
lazerg:fix/issue-65127-pipeline-sync-throw-callback

Conversation

@lazerg

@lazerg lazerg commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

When pipelineImpl() throws while wiring the streams together, the stages it already wired stay live. As they close they call finish(), finishCount drains to zero and finishImpl() runs with final set, so the callback is scheduled with error still undefined. The caller ends up with the same failure reported twice, once as the exception and once as a successful completion.

The callback is now only scheduled if the wiring loop actually finished. An error that arrives on an already-wired stream after the throw is no longer forwarded to the callback either, which is intentional and matches what already happens when the throw comes before anything was wired. The streams themselves are still left untouched, since ownership is not taken until pipeline() succeeds.

Fixes: #65127

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/streams

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. stream Issues and PRs related to the stream subsystem. labels Aug 8, 2026
Signed-off-by: Lazizbek Ergashev <lazerg2@gmail.com>
@lazerg
lazerg force-pushed the fix/issue-65127-pipeline-sync-throw-callback branch from 369726d to 358beaa Compare August 8, 2026 06:21
@lazerg lazerg changed the title stream: don't call pipeline callback after a sync throw stream: skip pipeline callback on sync throw Aug 8, 2026
@lazerg

lazerg commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

The x86_64-darwin shared-libraries job failed on a test-tick-processor-arguments timeout. That test is unrelated to this change, which only touches stream code.

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.30%. Comparing base (e2d7b34) to head (358beaa).
⚠️ Report is 206 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65128      +/-   ##
==========================================
- Coverage   90.31%   90.30%   -0.01%     
==========================================
  Files         759      759              
  Lines      248290   248297       +7     
  Branches    46859    46854       -5     
==========================================
- Hits       224241   224230      -11     
- Misses      15472    15516      +44     
+ Partials     8577     8551      -26     
Files with missing lines Coverage Δ
lib/internal/streams/pipeline.js 95.19% <100.00%> (+0.07%) ⬆️

... and 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell
jasnell requested review from mcollina and ronag August 8, 2026 15:29
MILLERMARRU

This comment was marked as low quality.

@lazerg

lazerg commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful trace and repro, this is a real gap and I checked it myself against unpatched main (v26.7.0, node --expose-internals): a stream already wired into the pipeline does stay alive after a synchronous throw, since nothing tells it to stop.

That said, I don't think this PR should close that gap. pipeline() does not take ownership of the streams until it succeeds. If it throws while still wiring, the caller keeps ownership of whatever was already wired. That rule is already pinned down by four assertions in test/parallel/test-stream-pipeline.js (around lines 871-916), each checking s.destroyed === false right after a synchronous ERR_INVALID_RETURN_VALUE throw on an already-wired stream. I ran the same check directly against the unpatched internal module and got s.destroyed: false, matching those assertions.

This exact approach (destroy already-wired stages in the catch block, the same shape #65165 used) was tried before on #65064. Its first version did that, CI failed at line 890 on that assertion, @ronag flagged the ownership problem in review, and the PR got narrowed to disposing only the AbortSignal listener that pipeline() itself attaches, leaving the caller's streams untouched. @mcollina approved that narrower version. #65064 is still open, targeting a separate leak (#65063), not this one.

On the #65165 overlap: @pacocartones already reconciled it on 2026-08-12. The split is: this PR (#65128) fixes #65127, the double-report bug, with the wired flag; #65064 fixes #65063, the AbortSignal-listener leak; #65165 got closed since its proactive-destroy approach conflicts with the ownership assertions above. So the split is settled and this PR is complete as is for the issue it targets. The gap you found is real but out of scope by design, not something missed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. stream Issues and PRs related to the stream subsystem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stream.pipeline() can both throw synchronously and invoke the callback reporting success

3 participants