Fix spawnStreamAsync hang: end provided pipes on failure paths without truncating output - #541
Merged
Merged
Conversation
…runcating output
spawnStreamAsync only ended caller-provided stdOutPipe/stdErrPipe on the
cancellation path. On a spawn error (e.g. ENOENT) or a pre-spawn throw
(already-cancelled token or executable-resolution failure), the child's
stdio streams never deliver EOF to the destination, so a caller awaiting an
AccumulatorStream (which resolves only on 'finish') could hang indefinitely.
Add an idempotent endPipes() helper and invoke it on the pre-spawn throw and
the spawn 'error' paths so awaiting an AccumulatorStream after those
rejections always settles.
Deliberately do NOT end the pipes on the 'exit' path: the .pipe({ end: true })
wiring already ends the destination once the child stream reaches EOF (after a
full drain). Ending manually on 'exit' races that drain and can truncate
trailing output and emit 'write after end' for a slower/backpressured
destination. Cancellation behavior is unchanged.
Add (unit) regression tests covering ENOENT spawn error, non-zero exit, and
pre-spawn cancellation (each asserting the accumulators settle), plus a
backpressure test asserting a slow destination receives the full output with
no stream error on non-zero exit.
Fixes #540
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 706ba4c2-eeaf-46d2-b2b5-1e8da0eccef4
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 706ba4c2-eeaf-46d2-b2b5-1e8da0eccef4
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a hang in spawnStreamAsync when callers provide stdOutPipe/stdErrPipe streams (notably AccumulatorStream) and the spawn fails before the child’s stdio can naturally reach EOF. It ensures those caller-provided pipes are ended on pre-spawn failure and on the child process error event, preventing downstream awaits on finish from hanging indefinitely.
Changes:
- Added an
endPipes()helper and invoked it on pre-spawn failures and spawnerrorevents (e.g.ENOENT) so caller-provided pipes always settle on these terminal paths. - Added unit regression tests covering spawn
ENOENT, pre-spawn cancellation, non-zero exit settling behavior, and a backpressure scenario to ensure output isn’t truncated on non-zero exit. - Updated the
vscode-processutilschangelog with a 1.0.0 “Fixed” entry referencing #540.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/vscode-processutils/src/utils/spawnStreamAsync.ts | Ends caller-provided pipes on pre-spawn and spawn-error terminal paths; documents why exit-path does not manually end pipes. |
| packages/vscode-processutils/src/test/spawnStreamAsync.test.ts | Adds unit tests to prevent regressions (hang prevention + backpressure/non-truncation guard). |
| packages/vscode-processutils/CHANGELOG.md | Documents the fix under 1.0.0. |
Comments suppressed due to low confidence (2)
packages/vscode-processutils/src/utils/spawnStreamAsync.ts:186
- In the spawn
errorhandler, whencancellationToken.isCancellationRequestedis true you callreject(...)and then immediately fall through toreject(err). While the second reject is ignored, it can still triggermultipleResolvesdiagnostics and makes the control flow harder to reason about. Return after rejecting the cancellation error (or use anelse).
if (cancellationToken.isCancellationRequested) {
reject(new CancellationError('Command cancelled', cancellationToken));
}
reject(err);
packages/vscode-processutils/src/utils/spawnStreamAsync.ts:200
- In the
exithandler, ifcancellationToken.isCancellationRequestedis true you reject with aCancellationErrorbut then continue on to resolve/reject based oncode/signal. Add an earlyreturnafter the cancellation rejection to avoid multiple settle attempts and keep the logic unambiguous.
if (cancellationToken.isCancellationRequested) {
reject(new CancellationError('Command cancelled', cancellationToken));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Avoids a redundant second settle (no-op on an already-settled promise) that could trip Node's multipleResolves diagnostics and made the control flow harder to follow. Addresses Copilot code review feedback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 706ba4c2-eeaf-46d2-b2b5-1e8da0eccef4
bwateratmsft
enabled auto-merge (squash)
July 21, 2026 14:25
patverb
approved these changes
Jul 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #540.
spawnStreamAsynconly ended caller-providedstdOutPipe/stdErrPipestreams on the cancellation path. On a spawnerror(e.g.ENOENTwhen the executable is not onPATH) or a pre-spawn throw (already-cancelled token, or Windows executable-resolution failure), the child's stdio streams never deliver EOF to the destination, so a caller awaiting anAccumulatorStream(which resolves only on the stream'sfinishevent) could hang indefinitely. This was observed in Azure/azure-dev#9136, where anENOENTturned a clean "not found" rejection into an indefinite hang.Changes
endPipes()helper and invoke it on the two paths where the child streams never flow:errorevent (e.g.ENOENT)exitpath. ThechildProcess.stdout.pipe(dest)wiring already uses the default{ end: true }, so the destination is ended when the source reaches EOF — after a full drain, race-free. This is what makes a non-zero exit settle the accumulator.returnafter the cancellationrejectin theerror/exithandlers, so a cancelled token no longer falls through to a redundant second settle (avoids tripping Node'smultipleResolvesdiagnostics). Addresses Copilot code-review feedback.Why not end on
exit?An earlier iteration also ended pipes in the
exithandler. Testing with a slow/backpressured destination and a child that writes a large payload then exits non-zero showed theexitevent fires while stdout is still draining, so a manual.end()truncated the tail and threwwrite after end.AccumulatorStreamwrites synchronously so it usually drains fast enough to hide this, but any slower caller-provided pipe (file, socket, transform) would lose data. Relying on the pipe's EOF-driven end avoids the race entirely while still ensuring the accumulator settles on non-zero exit.Tests
Added
(unit)regression tests (no Docker required; the defaultmocharun greps(unit)), each with timeout-guarded awaits so a re-hang fails deterministically:ChildProcessErrorand accumulators settle (stderr captured)CancellationErrorand accumulators settleend-on-exit, passes on the fix)Verification
end-on-exit and passes 3/3 on the fix.Changelog
Added a
Fixedentry under the 1.0.0 - 21 July 2026 release.