refactor: extract local agent spawn helper and unify process tree cleanup (#410)

This commit is contained in:
Junmo Kim
2026-04-07 09:50:40 +08:00
committed by GitHub
parent e7ba48c761
commit 2f61852a9e
9 changed files with 166 additions and 95 deletions
+2
View File
@@ -148,3 +148,5 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise<vo
});
});
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('@/ui/terminalState', () => ({
restoreTerminalState: vi.fn(),
}));
vi.mock('@/utils/spawnWithAbort', () => ({
spawnWithAbort: vi.fn(),
}));
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
import { spawnWithAbort } from '@/utils/spawnWithAbort';
import { restoreTerminalState } from '@/ui/terminalState';
const mockSpawnWithAbort = vi.mocked(spawnWithAbort);
const mockRestoreTerminalState = vi.mocked(restoreTerminalState);
const dummyOptions = {
command: 'test-agent',
args: [],
cwd: '/tmp',
env: process.env,
signal: new AbortController().signal,
logLabel: 'Test',
spawnName: 'test',
installHint: 'Test CLI',
};
describe('spawnWithTerminalGuard', () => {
let stdinPauseSpy: ReturnType<typeof vi.spyOn>;
let stdinResumeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stdinPauseSpy = vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
stdinResumeSpy = vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
});
afterEach(() => {
stdinPauseSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
it('pauses stdin before spawn and resumes after success', async () => {
mockSpawnWithAbort.mockResolvedValue();
await spawnWithTerminalGuard(dummyOptions);
expect(stdinPauseSpy).toHaveBeenCalledOnce();
expect(stdinResumeSpy).toHaveBeenCalledOnce();
expect(mockRestoreTerminalState).toHaveBeenCalledOnce();
});
it('passes options through to spawnWithAbort unchanged', async () => {
mockSpawnWithAbort.mockResolvedValue();
await spawnWithTerminalGuard(dummyOptions);
expect(mockSpawnWithAbort).toHaveBeenCalledWith(dummyOptions);
});
it('resumes stdin and restores terminal state even when spawn rejects', async () => {
mockSpawnWithAbort.mockRejectedValue(new Error('spawn failed'));
await expect(spawnWithTerminalGuard(dummyOptions)).rejects.toThrow('spawn failed');
expect(stdinResumeSpy).toHaveBeenCalledOnce();
expect(mockRestoreTerminalState).toHaveBeenCalledOnce();
});
it('propagates the original error from spawnWithAbort', async () => {
const error = new Error('process exited with code 1');
mockSpawnWithAbort.mockRejectedValue(error);
await expect(spawnWithTerminalGuard(dummyOptions)).rejects.toThrow(error);
});
it('calls pause before spawn, and resume after spawn', async () => {
mockSpawnWithAbort.mockResolvedValue();
await spawnWithTerminalGuard(dummyOptions);
expect(stdinPauseSpy.mock.invocationCallOrder[0])
.toBeLessThan(mockSpawnWithAbort.mock.invocationCallOrder[0]);
expect(mockSpawnWithAbort.mock.invocationCallOrder[0])
.toBeLessThan(stdinResumeSpy.mock.invocationCallOrder[0]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { restoreTerminalState } from '@/ui/terminalState';
import { spawnWithAbort, type SpawnWithAbortOptions } from '@/utils/spawnWithAbort';
/**
* Guards the terminal around a spawnWithAbort call: pauses stdin before spawn,
* then resumes stdin and restores terminal escape state in finally.
*/
export async function spawnWithTerminalGuard(options: SpawnWithAbortOptions): Promise<void> {
process.stdin.pause();
try {
await spawnWithAbort(options);
} finally {
process.stdin.resume();
restoreTerminalState();
}
}