Skip to content
Closed
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: 2 additions & 0 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,8 @@ export class CoreToolScheduler {
confirmationDetails,
reqInfo.callId,
signal,
).catch((err) =>
debugLogger.error(`IDE confirmation handling failed: ${err}`),
);

const originalOnConfirm = confirmationDetails.onConfirm;
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ describe('ShellExecutionService', () => {
expect(result.exitCode).toBe(0);
});

it('should throw unexpected PTY errors from error event', async () => {
it('should capture unexpected PTY errors in output instead of throwing', async () => {
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'ls -l',
Expand All @@ -435,15 +435,16 @@ describe('ShellExecutionService', () => {
);
await new Promise((resolve) => process.nextTick(resolve));

const unexpectedError = Object.assign(new Error('unexpected pty error'), {
const unexpectedError = Object.assign(new Error('connection broken'), {
code: 'EPIPE',
});
expect(() => mockPtyProcess.emit('error', unexpectedError)).toThrow(
'unexpected pty error',
);
// Should not throw — error is reported in output instead
expect(() => mockPtyProcess.emit('error', unexpectedError)).not.toThrow();

mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
await handle.result;
const result = await handle.result;
expect(result.error).toBeNull();
expect(result.output).toBe('connection broken');
expect(result.exitCode).toBe(1);
});

it('should ignore ioctl EBADF message-only resize race errors', async () => {
Expand Down
43 changes: 38 additions & 5 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,16 @@ const getErrorMessage = (error: unknown): string =>

const isExpectedPtyReadExitError = (error: unknown): boolean => {
const code = getErrnoCode(error);
if (code === 'EIO') {
if (code === 'EIO' || code === 'EINTR' || code === 'ENODEV') {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] message.includes('pty') is overly broad — will silently suppress any error containing the substring "pty", including legitimate errors like "pty allocation failed" that should be surfaced to the user.

Suggested change
if (code === 'EIO' || code === 'EINTR' || code === 'ENODEV') {
(message.includes('read') && message.toLowerCase().includes('pty'))

— qwen3.6-plus via Qwen Code /review

return true;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] When this error handler runs, it sets exited = true, removes the abort listener, deletes from activePtys, and resolves. If onExit subsequently fires, cleanup code runs redundantly. Consider adding a guard in onExit to skip if error is already set.

Suggested change
ptyProcess.onExit((exitInfo) => {
if (error) {
// Error handler already resolved, skip
return;
}

— qwen3.6-plus via Qwen Code /review

const message = getErrorMessage(error);
return message.includes('read EIO');
return (
message.includes('read EIO') ||
message.includes('read EINTR') ||
(message.includes('read') && message.toLowerCase().includes('pty'))
);
};

const isExpectedPtyExitRaceError = (error: unknown): boolean => {
Expand Down Expand Up @@ -622,7 +626,7 @@ export class ShellExecutionService {
let decoder: TextDecoder | null = null;
let output: string | AnsiOutput | null = null;
const outputChunks: Buffer[] = [];
const error: Error | null = null;
let error: Error | null = null;
let exited = false;

let isStreamingRawContent = true;
Expand Down Expand Up @@ -812,12 +816,41 @@ export class ShellExecutionService {
return;
}

// Surface unexpected PTY errors to preserve existing crash behavior.
throw err;
// Store the error and trigger exit handling.
// Throwing from an async event callback causes uncaught exception.
error = err;
if (!exited) {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
this.activePtys.delete(ptyProcess.pid);
try {
ptyProcess.kill();
} catch {
// PTY may already be dead
}
// Do NOT set `error` — that field is reserved for spawn failures.
// Include the error message in output so downstream consumers
// correctly treat this as a runtime failure, not a startup failure.
resolve({
rawOutput: Buffer.concat(outputChunks),
output: getErrorMessage(err),
exitCode: 1,
signal: null,
error: null,
aborted: abortSignal.aborted,
pid: ptyProcess.pid,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This change starts returning unexpected runtime PTY failures through ShellExecutionResult.error, but downstream consumers still interpret any non-aborted error as a startup failure. For example, shellProcessor will now report Failed to start shell command even when the command already started and produced partial output. Consider either reserving error for spawn failures only, or updating consumers to distinguish startup failures from runtime PTY failures before surfacing the message.

— gpt-5.4 via Qwen Code /review

executionMethod:
(ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ?? 'node-pty',
});
}
});

ptyProcess.onExit(
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
// If the error handler already resolved, skip redundant cleanup.
if (error) {
return;
}
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
this.activePtys.delete(ptyProcess.pid);
Expand Down