fix: resolve infinite loop when using 'Modify with external editor' (#17453)

Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
Co-authored-by: ehedlund <ehedlund@google.com>
This commit is contained in:
Philippe
2026-02-05 21:52:41 +01:00
committed by GitHub
parent 8efae719ee
commit 2498114df6
8 changed files with 336 additions and 51 deletions

View File

@@ -14,17 +14,22 @@ import {
type Mock,
} from 'vitest';
import {
checkHasEditorType,
hasValidEditorCommand,
hasValidEditorCommandAsync,
getDiffCommand,
openDiff,
allowEditorTypeInSandbox,
isEditorAvailable,
isEditorAvailableAsync,
resolveEditorAsync,
type EditorType,
} from './editor.js';
import { execSync, spawn, spawnSync } from 'node:child_process';
import { coreEvents, CoreEvent } from './events.js';
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { debugLogger } from './debugLogger.js';
vi.mock('child_process', () => ({
exec: vi.fn(),
execSync: vi.fn(),
spawn: vi.fn(),
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
@@ -51,7 +56,7 @@ describe('editor utils', () => {
});
});
describe('checkHasEditorType', () => {
describe('hasValidEditorCommand', () => {
const testCases: Array<{
editor: EditorType;
commands: string[];
@@ -89,7 +94,7 @@ describe('editor utils', () => {
(execSync as Mock).mockReturnValue(
Buffer.from(`/usr/bin/${commands[0]}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, {
stdio: 'ignore',
});
@@ -103,7 +108,7 @@ describe('editor utils', () => {
throw new Error(); // first command not found
})
.mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledTimes(2);
});
}
@@ -113,7 +118,7 @@ describe('editor utils', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
expect(checkHasEditorType(editor)).toBe(false);
expect(hasValidEditorCommand(editor)).toBe(false);
expect(execSync).toHaveBeenCalledTimes(commands.length);
});
@@ -123,7 +128,7 @@ describe('editor utils', () => {
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(
`where.exe ${win32Commands[0]}`,
{
@@ -142,7 +147,7 @@ describe('editor utils', () => {
.mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`),
); // second command found
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledTimes(2);
});
}
@@ -152,7 +157,7 @@ describe('editor utils', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
expect(checkHasEditorType(editor)).toBe(false);
expect(hasValidEditorCommand(editor)).toBe(false);
expect(execSync).toHaveBeenCalledTimes(win32Commands.length);
});
});
@@ -542,4 +547,167 @@ describe('editor utils', () => {
expect(isEditorAvailable('neovim')).toBe(true);
});
});
// Helper to create a mock exec that simulates async behavior
const mockExecAsync = (implementation: (cmd: string) => boolean): void => {
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
if (implementation(cmd)) {
callback(null, '/usr/bin/cmd', '');
} else {
callback(new Error('Command not found'), '', '');
}
},
);
};
describe('hasValidEditorCommandAsync', () => {
it('should return true if vim command exists', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync((cmd) => cmd.includes('vim'));
expect(await hasValidEditorCommandAsync('vim')).toBe(true);
});
it('should return false if vim command does not exist', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync(() => false);
expect(await hasValidEditorCommandAsync('vim')).toBe(false);
});
it('should check zed and zeditor commands in order', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync((cmd) => cmd.includes('zeditor'));
expect(await hasValidEditorCommandAsync('zed')).toBe(true);
});
});
describe('isEditorAvailableAsync', () => {
it('should return false for undefined editor', async () => {
expect(await isEditorAvailableAsync(undefined)).toBe(false);
});
it('should return false for empty string editor', async () => {
expect(await isEditorAvailableAsync('')).toBe(false);
});
it('should return false for invalid editor type', async () => {
expect(await isEditorAvailableAsync('invalid-editor')).toBe(false);
});
it('should return true for vscode when installed and not in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', '');
expect(await isEditorAvailableAsync('vscode')).toBe(true);
});
it('should return false for vscode when not installed', async () => {
mockExecAsync(() => false);
expect(await isEditorAvailableAsync('vscode')).toBe(false);
});
it('should return false for vscode in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await isEditorAvailableAsync('vscode')).toBe(false);
});
it('should return true for vim in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('vim'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await isEditorAvailableAsync('vim')).toBe(true);
});
});
describe('resolveEditorAsync', () => {
it('should return the preferred editor when available', async () => {
mockExecAsync((cmd) => cmd.includes('vim'));
vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync('vim');
expect(result).toBe('vim');
});
it('should request editor selection when preferred editor is not installed', async () => {
mockExecAsync(() => false);
vi.stubEnv('SANDBOX', '');
const resolvePromise = resolveEditorAsync('vim');
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('neovim');
});
it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox');
const resolvePromise = resolveEditorAsync('vscode');
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
});
it('should request editor selection when no preference is set', async () => {
const emitSpy = vi.spyOn(coreEvents, 'emit');
vi.stubEnv('SANDBOX', '');
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI selection
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
});
it('should return undefined when editor selection is cancelled', async () => {
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI cancellation (exit dialog)
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }),
0,
);
const result = await resolvePromise;
expect(result).toBeUndefined();
});
it('should return undefined when abort signal is triggered', async () => {
const controller = new AbortController();
const resolvePromise = resolveEditorAsync(undefined, controller.signal);
setTimeout(() => controller.abort(), 0);
const result = await resolvePromise;
expect(result).toBeUndefined();
});
it('should request editor selection in sandbox mode when no preference is set', async () => {
const emitSpy = vi.spyOn(coreEvents, 'emit');
vi.stubEnv('SANDBOX', 'sandbox');
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI selection
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
});
});
});

View File

@@ -4,9 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync, spawn, spawnSync } from 'node:child_process';
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent } from './events.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
const GUI_EDITORS = [
'vscode',
@@ -23,6 +25,9 @@ const GUI_EDITORS_SET = new Set<string>(GUI_EDITORS);
const TERMINAL_EDITORS_SET = new Set<string>(TERMINAL_EDITORS);
const EDITORS_SET = new Set<string>(EDITORS);
export const NO_EDITOR_AVAILABLE_ERROR =
'No external editor is available. Please run /editor to configure one.';
export const DEFAULT_GUI_EDITOR: GuiEditorType = 'vscode';
export type GuiEditorType = (typeof GUI_EDITORS)[number];
@@ -73,12 +78,26 @@ interface DiffCommand {
args: string[];
}
const execAsync = promisify(exec);
function getCommandExistsCmd(cmd: string): string {
return process.platform === 'win32'
? `where.exe ${cmd}`
: `command -v ${cmd}`;
}
function commandExists(cmd: string): boolean {
try {
execSync(
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
{ stdio: 'ignore' },
);
execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
async function commandExistsAsync(cmd: string): Promise<boolean> {
try {
await execAsync(getCommandExistsCmd(cmd));
return true;
} catch {
return false;
@@ -108,17 +127,29 @@ const editorCommands: Record<
hx: { win32: ['hx'], default: ['hx'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
function getEditorCommands(editor: EditorType): string[] {
const commandConfig = editorCommands[editor];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
return commands.some((cmd) => commandExists(cmd));
return process.platform === 'win32'
? commandConfig.win32
: commandConfig.default;
}
export function hasValidEditorCommand(editor: EditorType): boolean {
return getEditorCommands(editor).some((cmd) => commandExists(cmd));
}
export async function hasValidEditorCommandAsync(
editor: EditorType,
): Promise<boolean> {
return Promise.any(
getEditorCommands(editor).map((cmd) =>
commandExistsAsync(cmd).then((exists) => exists || Promise.reject()),
),
).catch(() => false);
}
export function getEditorCommand(editor: EditorType): string {
const commandConfig = editorCommands[editor];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
const commands = getEditorCommands(editor);
return (
commands.slice(0, -1).find((cmd) => commandExists(cmd)) ||
commands[commands.length - 1]
@@ -134,15 +165,52 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
return true;
}
function isEditorTypeAvailable(
editor: string | undefined,
): editor is EditorType {
return (
!!editor && isValidEditorType(editor) && allowEditorTypeInSandbox(editor)
);
}
/**
* Check if the editor is valid and can be used.
* Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
*/
export function isEditorAvailable(editor: string | undefined): boolean {
if (editor && isValidEditorType(editor)) {
return checkHasEditorType(editor) && allowEditorTypeInSandbox(editor);
return isEditorTypeAvailable(editor) && hasValidEditorCommand(editor);
}
/**
* Check if the editor is valid and can be used.
* Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
*/
export async function isEditorAvailableAsync(
editor: string | undefined,
): Promise<boolean> {
return (
isEditorTypeAvailable(editor) && (await hasValidEditorCommandAsync(editor))
);
}
/**
* Resolves an editor to use for external editing without blocking the event loop.
* 1. If a preferred editor is set and available, uses it.
* 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it.
*/
export async function resolveEditorAsync(
preferredEditor: EditorType | undefined,
signal?: AbortSignal,
): Promise<EditorType | undefined> {
if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) {
return preferredEditor;
}
return false;
coreEvents.emit(CoreEvent.RequestEditorSelection);
return once(coreEvents, CoreEvent.EditorSelected, { signal })
.then(([payload]) => (payload as EditorSelectedPayload).editor)
.catch(() => undefined);
}
/**

View File

@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
import type { AgentDefinition } from '../agents/types.js';
import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js';
import type { EditorType } from './editor.js';
/**
* Defines the severity level for user-facing feedback.
@@ -143,6 +144,15 @@ export enum CoreEvent {
RetryAttempt = 'retry-attempt',
ConsentRequest = 'consent-request',
AgentsDiscovered = 'agents-discovered',
RequestEditorSelection = 'request-editor-selection',
EditorSelected = 'editor-selected',
}
/**
* Payload for the 'editor-selected' event.
*/
export interface EditorSelectedPayload {
editor?: EditorType;
}
export interface CoreEvents extends ExtensionEvents {
@@ -162,6 +172,8 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
[CoreEvent.ConsentRequest]: [ConsentRequestPayload];
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
[CoreEvent.RequestEditorSelection]: never[];
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
}
type EventBacklogItem = {