Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ext/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
71 changes: 71 additions & 0 deletions ext/vscode/src/test/suite/unit/execAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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, 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();
Comment on lines +20 to +24
};
}

suite('execAsync Tests', () => {
test('Returns stdout and stderr on success', async () => {
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('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.'
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This failure test writes and ends stderr before returning the rejected promise, so it can't catch the hang above. A regression test that leaves the stderr pipe open when it rejects (or drives the real spawn path with a bogus command) would fail on the current code and pass once the stderr read is gated on the exit case.

}
});

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('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');
}
});
});
20 changes: 18 additions & 2 deletions ext/vscode/src/utils/execAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

import { AccumulatorStream, CommandLineArgs, NoShell, spawnStreamAsync, StreamSpawnOptions } from '@microsoft/vscode-processutils';

export async function execAsync(command: string, args: CommandLineArgs, options?: Partial<StreamSpawnOptions>): 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<StreamSpawnOptions>, spawnStreamAsyncFunction: SpawnStreamAsync = spawnStreamAsync): Promise<{ stdout: string, stderr: string }> {
const stdoutFinal = new AccumulatorStream();
const stderrFinal = new AccumulatorStream();

Expand All @@ -16,7 +19,20 @@ export async function execAsync(command: string, args: CommandLineArgs, options?
stdErrPipe: stderrFinal,
};

await spawnStreamAsync(command, args, spawnOptions);
try {
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
// 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified this hangs. stderrFinal.getString() only resolves on the stream's close event, which needs .end() called on stdErrPipe. spawnStreamAsync only pipes and ends that stream after the process actually spawns and exits, so for a pre-spawn throw (cancelled token) or a spawn error event (ENOENT when azd is not on PATH) the pipe is never ended and this await never settles. The caller then hangs instead of receiving the original error.

Reproduced against @microsoft/vscode-processutils 0.2.1: an ENOENT command rejects immediately with the old await spawnStreamAsync(...), but with this catch block it never resolves or rejects. Same for a pre-cancelled token. Since azd-not-on-PATH is a common first-run state, this turns a clean not found error into an indefinite hang on the workspace tree node.

Only the non-zero-exit path guarantees stdErrPipe was ended, so gate the stderr read on it and let everything else rethrow:

} catch (error) {
    // Only a non-zero process exit (ChildProcessError) guarantees stdErrPipe was ended, so only
    // then is it safe to await it. Pre-spawn failures (cancelled token, ENOENT) never end the pipe.
    if (isChildProcessError(error)) {
        const stderr = (await stderrFinal.getString()).trim();
        if (stderr) {
            error.message = `${error.message}\n${stderr}`.trim();
        }
    }

    throw error;
}

isChildProcessError is exported from @microsoft/vscode-processutils.

if (stderr && error instanceof Error) {
error.message = `${error.message}\n${stderr}`.trim();
}

throw error;
}

return {
stdout: await stdoutFinal.getString(),
Expand Down
6 changes: 6 additions & 0 deletions ext/vscode/src/views/workspace/AzureDevCliApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AzDevShowResults>;
Expand Down
Loading