-
Notifications
You must be signed in to change notification settings - Fork 332
Fix opaque "Process exited with code 1" in VS Code workspace view #9136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ad01279
Fix opaque "Process exited with code 1" in VS Code workspace view
vhvb1989 304a3cd
Make execAsync spawn injectable and mock it in tests
vhvb1989 3f404a1
Address Copilot review: guard stderr wait and fix test ordering
vhvb1989 211cf44
test: cover suppressDisplay in workspace application resolution
vhvb1989 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
53 changes: 53 additions & 0 deletions
53
ext/vscode/src/test/suite/unit/azureDevCliApplication.test.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| import { WorkspaceResource } from '@microsoft/vscode-azureresources-api'; | ||
| import { IActionContext } from '@microsoft/vscode-azext-utils'; | ||
| import { expect } from 'chai'; | ||
| import { AzureDevCliApplication } from '../../../views/workspace/AzureDevCliApplication'; | ||
| import { AzDevShowResults, AzureDevShowProvider } from '../../../services/AzureDevShowProvider'; | ||
| import { AzureDevEnvListProvider } from '../../../services/AzureDevEnvListProvider'; | ||
| import { AzureDevEnvValuesProvider } from '../../../services/AzureDevEnvValuesProvider'; | ||
|
|
||
| suite('AzureDevCliApplication Tests', () => { | ||
| // Captures the errorHandling context that AzureDevCliApplication passes into getShowResults so the | ||
| // test can assert the passive tree-resolution path opts out of the modal "Report Issue" dialog. | ||
| function buildApplication(showProvider: AzureDevShowProvider): AzureDevCliApplication { | ||
| const resource = { id: '/test/azure.yaml', name: 'test-app' } as unknown as WorkspaceResource; | ||
| const envListProvider = {} as unknown as AzureDevEnvListProvider; | ||
| const envValuesProvider = {} as unknown as AzureDevEnvValuesProvider; | ||
|
|
||
| return new AzureDevCliApplication( | ||
| resource, | ||
| () => { /* no-op refresh */ }, | ||
| showProvider, | ||
| envListProvider, | ||
| envValuesProvider, | ||
| new Set<string>(), | ||
| () => { /* no-op toggle */ }, | ||
| /* includeEnvironments */ false | ||
| ); | ||
| } | ||
|
|
||
| test('Suppresses the error dialog when show fails during passive tree resolution', async () => { | ||
| let capturedSuppressDisplay: boolean | undefined; | ||
|
|
||
| const failingShowProvider: AzureDevShowProvider = { | ||
| getShowResults: (context: IActionContext): Promise<AzDevShowResults> => { | ||
| // Record the flag at call time - AzureDevCliApplication must set it before invoking show. | ||
| capturedSuppressDisplay = context.errorHandling.suppressDisplay; | ||
| return Promise.reject(new Error('Process exited with code 1')); | ||
| }, | ||
| }; | ||
|
|
||
| const application = buildApplication(failingShowProvider); | ||
|
|
||
| // Resolving children triggers the show call. The failure is swallowed (suppressDisplay + no | ||
| // rethrow), so this must not throw and must not surface a modal dialog. | ||
| const children = await application.getChildren(); | ||
|
|
||
| expect(capturedSuppressDisplay, 'getShowResults must be invoked with suppressDisplay enabled').to.equal(true); | ||
| // Even though show failed, the Services node is still returned (with no services). | ||
| expect(children).to.have.lengthOf(1); | ||
| }); | ||
| }); |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| import { ChildProcessError, StreamSpawnOptions } from '@microsoft/vscode-processutils'; | ||
| import { expect } from 'chai'; | ||
| import { execAsync, SpawnStreamAsync } from '../../../utils/execAsync'; | ||
|
|
||
| // Builds a fake spawnStreamAsync that emits the given stdout/stderr into the accumulator pipes that | ||
| // execAsync wires up, then either resolves or rejects to simulate the process exit. The pipes must be | ||
| // ended so that AccumulatorStream.getString() (which awaits the stream 'close' event) resolves - this | ||
| // mirrors how a real child process closes its stdio streams on exit. | ||
| function fakeSpawn(stdout: string, stderr: string, rejectWith?: Error): SpawnStreamAsync { | ||
| return (_command: string, _args, options?: StreamSpawnOptions) => { | ||
| if (stdout) { | ||
| options?.stdOutPipe?.write(Buffer.from(stdout)); | ||
| } | ||
| options?.stdOutPipe?.end(); | ||
|
|
||
| if (stderr) { | ||
| options?.stdErrPipe?.write(Buffer.from(stderr)); | ||
| } | ||
| options?.stdErrPipe?.end(); | ||
|
|
||
| return rejectWith ? Promise.reject(rejectWith) : Promise.resolve(); | ||
| }; | ||
| } | ||
|
|
||
| // Simulates a rejection that happens *before* a child process is spawned (e.g. an already-cancelled | ||
| // token or a Windows executable-resolution failure). Crucially, the pipes are never ended, so any code | ||
| // that awaits stderrFinal.getString() would hang forever. | ||
| function fakePreSpawnFailure(rejectWith: Error): SpawnStreamAsync { | ||
| return (_command: string, _args, _options?: StreamSpawnOptions) => { | ||
| return Promise.reject(rejectWith); | ||
| }; | ||
| } | ||
|
|
||
| // Reproduces the real ordering behind this bug: spawnStreamAsync rejects on the process 'exit' event | ||
| // *before* the stderr stream has closed. The rejection is returned immediately while the stderr | ||
| // write/end is deferred to a later tick, so this only passes if execAsync waits for stderr to close | ||
| // after catching the rejection (rather than reading it too early and missing the message). | ||
| function fakeSpawnStderrAfterRejection(stderr: string, rejectWith: Error): SpawnStreamAsync { | ||
| return (_command: string, _args, options?: StreamSpawnOptions) => { | ||
| options?.stdOutPipe?.end(); | ||
|
|
||
| setTimeout(() => { | ||
| if (stderr) { | ||
| options?.stdErrPipe?.write(Buffer.from(stderr)); | ||
| } | ||
| options?.stdErrPipe?.end(); | ||
| }, 10); | ||
|
|
||
| return Promise.reject(rejectWith); | ||
| }; | ||
| } | ||
|
|
||
| suite('execAsync Tests', () => { | ||
| test('Returns stdout and stderr on success', async () => { | ||
| const { stdout, stderr } = await execAsync('cmd', [], undefined, fakeSpawn('out', 'err')); | ||
|
vhvb1989 marked this conversation as resolved.
|
||
|
|
||
| expect(stdout).to.equal('out'); | ||
| expect(stderr).to.equal('err'); | ||
| }); | ||
|
|
||
| test('Includes process stderr in the error message on non-zero exit', async () => { | ||
| // stderr closes *after* the spawn promise rejects - the real ordering - so this proves execAsync | ||
| // waits for the stderr pipe to close before appending it, instead of reading it too early. | ||
| const spawn = fakeSpawnStderrAfterRejection( | ||
| 'ERROR: parsing project file: File is empty.', | ||
| new ChildProcessError('Process exited with code 1', 1, null) | ||
| ); | ||
|
|
||
| try { | ||
| await execAsync('cmd', [], undefined, spawn); | ||
| expect.fail('Should have thrown an error'); | ||
| } catch (error) { | ||
| expect(error).to.be.instanceOf(Error); | ||
| // Without this behavior, the message would only be the generic "Process exited with code 1", | ||
| // discarding the real reason emitted by the child process on stderr. | ||
| expect((error as Error).message, 'Error should retain the exit-code context').to.include( | ||
| 'Process exited with code 1' | ||
| ); | ||
| expect((error as Error).message, 'Error should surface the underlying stderr').to.include( | ||
| 'parsing project file: File is empty.' | ||
| ); | ||
|
vhvb1989 marked this conversation as resolved.
|
||
| } | ||
| }); | ||
|
|
||
| test('Rethrows the original error unchanged when stderr is empty', async () => { | ||
| const originalError = new ChildProcessError('Process exited with code 1', 1, null); | ||
| const spawn = fakeSpawn('', '', originalError); | ||
|
|
||
| try { | ||
| await execAsync('cmd', [], undefined, spawn); | ||
| expect.fail('Should have thrown an error'); | ||
| } catch (error) { | ||
| expect(error).to.equal(originalError); | ||
| expect((error as Error).message).to.equal('Process exited with code 1'); | ||
| } | ||
| }); | ||
|
|
||
| test('Rethrows pre-spawn failures immediately without waiting on stderr', async () => { | ||
| const originalError = new Error('Operation cancelled'); | ||
| const spawn = fakePreSpawnFailure(originalError); | ||
|
|
||
| // If execAsync awaited stderr for non-ChildProcessError rejections, this would hang because the | ||
| // fake never ends the stderr pipe. A short timeout guards against a regression that reintroduces | ||
| // the hang. | ||
| const timeout = new Promise<never>((_resolve, reject) => | ||
| setTimeout(() => reject(new Error('execAsync hung waiting on an un-ended stderr pipe')), 1000) | ||
| ); | ||
|
|
||
| try { | ||
| await Promise.race([execAsync('cmd', [], undefined, spawn), timeout]); | ||
| expect.fail('Should have thrown an error'); | ||
| } catch (error) { | ||
| expect(error, 'Pre-spawn failure should be rethrown unchanged').to.equal(originalError); | ||
| } | ||
| }); | ||
| }); | ||
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
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
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.