From ad0127990836f77f81ccdd16b4b6a3d20bb46968 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Jul 2026 16:35:29 +0000 Subject: [PATCH 1/2] Fix opaque "Process exited with code 1" in VS Code workspace view The Azure Resources workspace tree resolves each application node by running `azd show --no-prompt --output json` via execAsync. When azd exits non-zero, spawnStreamAsync rejects from a ChildProcess handler with a generic "Process exited with code 1" message *before* the caller reads stderr, so the real azd error is discarded. The failure is then reported as a modal error with a "Report Issue" button, prompting users to file bugs (e.g. #9130, #9131) for routine states like not being logged in or having no environment yet. - execAsync: on non-zero exit, append the child process's stderr to the error message so callers can classify and report the real failure instead of an opaque exit code. This also makes the existing azure.yaml error classification in AzureDevShowProvider effective. - AzureDevCliApplication.getResults: suppress the error display for this background tree resolution so expected/environmental failures no longer surface a Report Issue popup. Telemetry still records them. - Add execAsync unit tests covering success, stderr-on-failure, and empty-stderr failure paths. Fixes #9130 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8907c2b4-8adc-4280-8f11-af43d905644f --- ext/vscode/CHANGELOG.md | 2 +- .../src/test/suite/unit/execAsync.test.ts | 47 +++++++++++++++++++ ext/vscode/src/utils/execAsync.ts | 15 +++++- .../views/workspace/AzureDevCliApplication.ts | 6 +++ 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 ext/vscode/src/test/suite/unit/execAsync.test.ts diff --git a/ext/vscode/CHANGELOG.md b/ext/vscode/CHANGELOG.md index 8b46381f634..a4a366e6e40 100644 --- a/ext/vscode/CHANGELOG.md +++ b/ext/vscode/CHANGELOG.md @@ -8,7 +8,7 @@ ### Bugs Fixed -### Other Changes +- [[#9136]](https://github.com/Azure/azure-dev/pull/9136) Fixed the workspace resource view surfacing an opaque "Process exited with code 1" error (with a Report Issue prompt) when `azd show` fails for expected reasons such as not being logged in or having no environment. The underlying `azd` error is now included for diagnosis and these background failures no longer prompt users to file a bug. ## 0.10.0 (2025-09-22) diff --git a/ext/vscode/src/test/suite/unit/execAsync.test.ts b/ext/vscode/src/test/suite/unit/execAsync.test.ts new file mode 100644 index 00000000000..476405a3673 --- /dev/null +++ b/ext/vscode/src/test/suite/unit/execAsync.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { execAsync } from '../../../utils/execAsync'; + +// Use the current Node.js executable to run tiny, cross-platform scripts. This exercises the real +// spawnStreamAsync code path (rather than a mock) so we verify how execAsync surfaces process failures. +const node = process.execPath; + +suite('execAsync Tests', () => { + test('Returns stdout and stderr on success', async () => { + const { stdout, stderr } = await execAsync(node, [ + '-e', + 'process.stdout.write("out"); process.stderr.write("err")', + ]); + + expect(stdout).to.equal('out'); + expect(stderr).to.equal('err'); + }); + + test('Includes process stderr in the error message on non-zero exit', async () => { + try { + await execAsync(node, [ + '-e', + 'process.stderr.write("ERROR: parsing project file: File is empty."); process.exit(1)', + ]); + 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 surface the underlying stderr').to.include( + 'parsing project file: File is empty.' + ); + } + }); + + test('Rejects on non-zero exit even when stderr is empty', async () => { + try { + await execAsync(node, ['-e', 'process.exit(1)']); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); +}); diff --git a/ext/vscode/src/utils/execAsync.ts b/ext/vscode/src/utils/execAsync.ts index 7552a841d20..0100c2defda 100644 --- a/ext/vscode/src/utils/execAsync.ts +++ b/ext/vscode/src/utils/execAsync.ts @@ -16,7 +16,20 @@ export async function execAsync(command: string, args: CommandLineArgs, options? stdErrPipe: stderrFinal, }; - await spawnStreamAsync(command, args, spawnOptions); + try { + await spawnStreamAsync(command, args, spawnOptions); + } catch (error) { + // On a non-zero exit, spawnStreamAsync rejects with a generic message (e.g. "Process exited + // with code 1") from within a ChildProcess handler, before the caller ever reads stderr. Append + // the process's stderr to the error so callers can classify and report the real failure instead + // of an opaque exit code. + const stderr = (await stderrFinal.getString()).trim(); + if (stderr && error instanceof Error) { + error.message = `${error.message}\n${stderr}`.trim(); + } + + throw error; + } return { stdout: await stdoutFinal.getString(), diff --git a/ext/vscode/src/views/workspace/AzureDevCliApplication.ts b/ext/vscode/src/views/workspace/AzureDevCliApplication.ts index 98ab92e2fd9..c15a7202046 100644 --- a/ext/vscode/src/views/workspace/AzureDevCliApplication.ts +++ b/ext/vscode/src/views/workspace/AzureDevCliApplication.ts @@ -70,6 +70,12 @@ export class AzureDevCliApplication implements AzureDevCliModel { return callWithTelemetryAndErrorHandling( TelemetryId.WorkspaceViewApplicationResolve, async actionContext => { + // This runs automatically whenever the workspace tree resolves. A non-zero `azd show` + // exit here is almost always an expected/environmental state (not logged in, no + // environment yet, invalid azure.yaml) rather than an extension bug, so don't surface a + // modal error with a "Report Issue" button. The failure is still captured in telemetry, + // and azure.yaml problems are reported independently via the Problems panel. + actionContext.errorHandling.suppressDisplay = true; return await this.showProvider.getShowResults(actionContext, this.context.configurationFile); } ) as Promise; From 304a3cdd1af4068283450c74d48f56d0c3e8b46b Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 14 Jul 2026 16:49:12 +0000 Subject: [PATCH 2/2] Make execAsync spawn injectable and mock it in tests The previous execAsync test spawned a real `node -e` subprocess, which failed on Windows CI: NoShell stripped the inner double quotes from the inline script (`process.stdout.write(out)` -> ReferenceError). Relying on a real process / process.execPath inside the extension host is fragile and platform-dependent. Add an optional injectable spawn function to execAsync (defaulting to the real spawnStreamAsync) and rewrite the tests to inject a fake that writes to the accumulator pipes and resolves/rejects. This exercises the real stderr-appending logic deterministically with no subprocess, so it passes identically on Linux, macOS, and Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8907c2b4-8adc-4280-8f11-af43d905644f --- .../src/test/suite/unit/execAsync.test.ts | 54 +++++++++++++------ ext/vscode/src/utils/execAsync.ts | 7 ++- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/ext/vscode/src/test/suite/unit/execAsync.test.ts b/ext/vscode/src/test/suite/unit/execAsync.test.ts index 476405a3673..b2eea699daa 100644 --- a/ext/vscode/src/test/suite/unit/execAsync.test.ts +++ b/ext/vscode/src/test/suite/unit/execAsync.test.ts @@ -1,47 +1,71 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { StreamSpawnOptions } from '@microsoft/vscode-processutils'; import { expect } from 'chai'; -import { execAsync } from '../../../utils/execAsync'; +import { execAsync, SpawnStreamAsync } from '../../../utils/execAsync'; -// Use the current Node.js executable to run tiny, cross-platform scripts. This exercises the real -// spawnStreamAsync code path (rather than a mock) so we verify how execAsync surfaces process failures. -const node = process.execPath; +// 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(); + }; +} suite('execAsync Tests', () => { test('Returns stdout and stderr on success', async () => { - const { stdout, stderr } = await execAsync(node, [ - '-e', - 'process.stdout.write("out"); process.stderr.write("err")', - ]); + const { stdout, stderr } = await execAsync('cmd', [], undefined, fakeSpawn('out', 'err')); expect(stdout).to.equal('out'); expect(stderr).to.equal('err'); }); test('Includes process stderr in the error message on non-zero exit', async () => { + const spawn = fakeSpawn( + '', + 'ERROR: parsing project file: File is empty.', + new Error('Process exited with code 1') + ); + try { - await execAsync(node, [ - '-e', - 'process.stderr.write("ERROR: parsing project file: File is empty."); process.exit(1)', - ]); + 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.' ); } }); - test('Rejects on non-zero exit even when stderr is empty', async () => { + test('Rethrows the original error unchanged when stderr is empty', async () => { + const originalError = new Error('Process exited with code 1'); + const spawn = fakeSpawn('', '', originalError); + try { - await execAsync(node, ['-e', 'process.exit(1)']); + await execAsync('cmd', [], undefined, spawn); expect.fail('Should have thrown an error'); } catch (error) { - expect(error).to.be.instanceOf(Error); + expect(error).to.equal(originalError); + expect((error as Error).message).to.equal('Process exited with code 1'); } }); }); diff --git a/ext/vscode/src/utils/execAsync.ts b/ext/vscode/src/utils/execAsync.ts index 0100c2defda..38ed5386259 100644 --- a/ext/vscode/src/utils/execAsync.ts +++ b/ext/vscode/src/utils/execAsync.ts @@ -5,7 +5,10 @@ import { AccumulatorStream, CommandLineArgs, NoShell, spawnStreamAsync, StreamSpawnOptions } from '@microsoft/vscode-processutils'; -export async function execAsync(command: string, args: CommandLineArgs, options?: Partial): Promise<{ stdout: string, stderr: string }> { +// Alias for the spawn implementation so it can be overridden in tests without spawning a real process. +export type SpawnStreamAsync = typeof spawnStreamAsync; + +export async function execAsync(command: string, args: CommandLineArgs, options?: Partial, spawnStreamAsyncFunction: SpawnStreamAsync = spawnStreamAsync): Promise<{ stdout: string, stderr: string }> { const stdoutFinal = new AccumulatorStream(); const stderrFinal = new AccumulatorStream(); @@ -17,7 +20,7 @@ export async function execAsync(command: string, args: CommandLineArgs, options? }; try { - await spawnStreamAsync(command, args, spawnOptions); + await spawnStreamAsyncFunction(command, args, spawnOptions); } catch (error) { // On a non-zero exit, spawnStreamAsync rejects with a generic message (e.g. "Process exited // with code 1") from within a ChildProcess handler, before the caller ever reads stderr. Append