From 076d635a7a906df195b5ed156af66dc87e87a4e9 Mon Sep 17 00:00:00 2001 From: cosin2077 Date: Fri, 24 Jul 2026 11:46:05 +0800 Subject: [PATCH] fix(vscode): force-kill unresponsive reviews --- .../src/extension/services/CliService.ts | 5 +- .../__tests__/CliService.cancel.test.ts | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 extensions/vscode/src/extension/services/__tests__/CliService.cancel.test.ts diff --git a/extensions/vscode/src/extension/services/CliService.ts b/extensions/vscode/src/extension/services/CliService.ts index 8de2994e..2f39f9ce 100644 --- a/extensions/vscode/src/extension/services/CliService.ts +++ b/extensions/vscode/src/extension/services/CliService.ts @@ -158,7 +158,10 @@ export class CliService { if (this.current && this.current.pid) { this.current.kill('SIGTERM'); const proc = this.current; - setTimeout(() => { if (!proc.killed) proc.kill('SIGKILL'); }, 3000); + const forceKillTimer = setTimeout(() => { + if (proc.exitCode === null && proc.signalCode === null) proc.kill('SIGKILL'); + }, 3000); + proc.once('close', () => clearTimeout(forceKillTimer)); } } } diff --git a/extensions/vscode/src/extension/services/__tests__/CliService.cancel.test.ts b/extensions/vscode/src/extension/services/__tests__/CliService.cancel.test.ts new file mode 100644 index 00000000..a9010099 --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/CliService.cancel.test.ts @@ -0,0 +1,68 @@ +process.env.OCR_SKIP_SHELL_RESOLVE = '1'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { PassThrough } from 'stream'; +import { CliService } from '../CliService'; + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawn: jest.fn(), +})); + +const mockedSpawn = jest.mocked(spawn); + +function createMockProcess() { + const proc = Object.assign(new EventEmitter(), { + pid: 123, + killed: false, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: jest.fn<(signal?: NodeJS.Signals | number) => boolean>(), + }); + proc.kill.mockImplementation((signal) => { + proc.killed = true; + if (signal === 'SIGKILL') proc.signalCode = 'SIGKILL'; + return true; + }); + return proc; +} + +describe('CliService.cancel', () => { + afterEach(() => { + jest.useRealTimers(); + mockedSpawn.mockReset(); + }); + + it('子进程忽略 SIGTERM 时,超时后发送 SIGKILL', () => { + jest.useFakeTimers(); + const proc = createMockProcess(); + mockedSpawn.mockReturnValue(proc as unknown as ReturnType); + + const svc = new CliService('node'); + void svc.runRaw([], '.', () => {}); + svc.cancel(); + jest.advanceTimersByTime(3000); + + expect(proc.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); + expect(proc.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + }); + + it('子进程已正常退出时,不再发送 SIGKILL', async () => { + jest.useFakeTimers(); + const proc = createMockProcess(); + mockedSpawn.mockReturnValue(proc as unknown as ReturnType); + + const svc = new CliService('node'); + const run = svc.runRaw([], '.', () => {}); + svc.cancel(); + proc.exitCode = 0; + proc.emit('close', 0); + await run; + jest.advanceTimersByTime(3000); + + expect(proc.kill).toHaveBeenCalledTimes(1); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + }); +});