Skip to content

Commit 1e1e7e3

Browse files
fix(core): handle GUI editor non-zero exit codes gracefully (#20376)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent 43eb74a commit 1e1e7e3

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

packages/core/src/utils/editor.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,17 +392,84 @@ describe('editor utils', () => {
392392
);
393393
});
394394

395-
it(`should reject if ${editor} exits with non-zero code`, async () => {
395+
it(`should resolve and log warning if ${editor} exits with non-zero code`, async () => {
396+
const warnSpy = vi
397+
.spyOn(debugLogger, 'warn')
398+
.mockImplementation(() => {});
396399
const mockSpawnOn = vi.fn((event, cb) => {
397400
if (event === 'close') {
398401
cb(1);
399402
}
400403
});
401404
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
402405

406+
await openDiff('old.txt', 'new.txt', editor);
407+
expect(warnSpy).toHaveBeenCalledWith(`${editor} exited with code 1`);
408+
});
409+
410+
it(`should emit ExternalEditorClosed when ${editor} exits successfully`, async () => {
411+
const emitSpy = vi.spyOn(coreEvents, 'emit');
412+
const mockSpawnOn = vi.fn((event, cb) => {
413+
if (event === 'close') {
414+
cb(0);
415+
}
416+
});
417+
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
418+
419+
await openDiff('old.txt', 'new.txt', editor);
420+
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
421+
});
422+
423+
it(`should emit ExternalEditorClosed when ${editor} exits with non-zero code`, async () => {
424+
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
425+
const emitSpy = vi.spyOn(coreEvents, 'emit');
426+
const mockSpawnOn = vi.fn((event, cb) => {
427+
if (event === 'close') {
428+
cb(1);
429+
}
430+
});
431+
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
432+
433+
await openDiff('old.txt', 'new.txt', editor);
434+
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
435+
});
436+
437+
it(`should emit ExternalEditorClosed when ${editor} spawn errors`, async () => {
438+
const emitSpy = vi.spyOn(coreEvents, 'emit');
439+
const mockError = new Error('spawn error');
440+
const mockSpawnOn = vi.fn((event, cb) => {
441+
if (event === 'error') {
442+
cb(mockError);
443+
}
444+
});
445+
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
446+
403447
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
404-
`${editor} exited with code 1`,
448+
'spawn error',
449+
);
450+
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
451+
});
452+
453+
it(`should only emit ExternalEditorClosed once when ${editor} fires both error and close`, async () => {
454+
const emitSpy = vi.spyOn(coreEvents, 'emit');
455+
const callbacks: Record<string, (arg: unknown) => void> = {};
456+
const mockSpawnOn = vi.fn(
457+
(event: string, cb: (arg: unknown) => void) => {
458+
callbacks[event] = cb;
459+
},
460+
);
461+
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
462+
463+
const promise = openDiff('old.txt', 'new.txt', editor);
464+
// Simulate Node.js behavior: error fires first, then close.
465+
callbacks['error'](new Error('spawn error'));
466+
callbacks['close'](1);
467+
468+
await expect(promise).rejects.toThrow('spawn error');
469+
const editorClosedEmissions = emitSpy.mock.calls.filter(
470+
(call) => call[0] === CoreEvent.ExternalEditorClosed,
405471
);
472+
expect(editorClosedEmissions).toHaveLength(1);
406473
});
407474
}
408475

packages/core/src/utils/editor.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,15 +323,30 @@ export async function openDiff(
323323
shell: process.platform === 'win32',
324324
});
325325

326+
// Guard against both 'error' and 'close' firing for a single failure,
327+
// which would emit ExternalEditorClosed twice and attempt to settle
328+
// the promise twice.
329+
let isSettled = false;
330+
326331
childProcess.on('close', (code) => {
327-
if (code === 0) {
328-
resolve();
329-
} else {
330-
reject(new Error(`${editor} exited with code ${code}`));
332+
if (isSettled) return;
333+
isSettled = true;
334+
335+
if (code !== 0) {
336+
// GUI editors (VS Code, Zed, etc.) can exit with non-zero codes
337+
// under normal circumstances (e.g., window closed while loading).
338+
// Log a warning instead of crashing the CLI process.
339+
debugLogger.warn(`${editor} exited with code ${code}`);
331340
}
341+
coreEvents.emit(CoreEvent.ExternalEditorClosed);
342+
resolve();
332343
});
333344

334345
childProcess.on('error', (error) => {
346+
if (isSettled) return;
347+
isSettled = true;
348+
349+
coreEvents.emit(CoreEvent.ExternalEditorClosed);
335350
reject(error);
336351
});
337352
});

0 commit comments

Comments
 (0)