mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-17 16:51:15 -07:00
feat: implement background process logging and cleanup (#21189)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
|
||||
import EventEmitter from 'node:events';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
@@ -28,14 +29,44 @@ const mockPtySpawn = vi.hoisted(() => vi.fn());
|
||||
const mockCpSpawn = vi.hoisted(() => vi.fn());
|
||||
const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
|
||||
const mockGetPty = vi.hoisted(() => vi.fn());
|
||||
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
||||
const mockResolveExecutable = vi.hoisted(() => vi.fn());
|
||||
const mockDebugLogger = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Top-level Mocks
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getGlobalTempDir: vi.fn().mockReturnValue('/mock/temp'),
|
||||
},
|
||||
}));
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: mockDebugLogger,
|
||||
}));
|
||||
vi.mock('@lydell/node-pty', () => ({
|
||||
spawn: mockPtySpawn,
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
mkdirSync: mockMkdirSync,
|
||||
createWriteStream: mockCreateWriteStream,
|
||||
},
|
||||
mkdirSync: mockMkdirSync,
|
||||
createWriteStream: mockCreateWriteStream,
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/shell-utils.js')>();
|
||||
@@ -57,6 +88,7 @@ vi.mock('../utils/textUtils.js', () => ({
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -65,6 +97,7 @@ vi.mock('node:os', () => ({
|
||||
},
|
||||
},
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -159,6 +192,8 @@ describe('ShellExecutionService', () => {
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: number;
|
||||
length: number;
|
||||
getLine: Mock;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -201,6 +236,8 @@ describe('ShellExecutionService', () => {
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY: 0,
|
||||
length: 0,
|
||||
getLine: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -432,13 +469,20 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('pty interaction', () => {
|
||||
let ptySpy: { mockRestore(): void };
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
ptySpy = vi
|
||||
.spyOn(ShellExecutionService['activePtys'], 'get')
|
||||
.mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ptySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should write to the pty and trigger a render', async () => {
|
||||
@@ -667,6 +711,163 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backgrounding', () => {
|
||||
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
|
||||
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockWriteStream = {
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb) => cb?.()),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
mockMkdirSync.mockReturnValue(undefined);
|
||||
mockCreateWriteStream.mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockWriteStream as any,
|
||||
);
|
||||
mockHomedir.mockReturnValue('/mock/home');
|
||||
|
||||
mockBgChildProcess = new EventEmitter() as EventEmitter &
|
||||
Partial<ChildProcess>;
|
||||
mockBgChildProcess.stdout = new EventEmitter() as Readable;
|
||||
mockBgChildProcess.stderr = new EventEmitter() as Readable;
|
||||
mockBgChildProcess.kill = vi.fn();
|
||||
Object.defineProperty(mockBgChildProcess, 'pid', {
|
||||
value: 99999,
|
||||
configurable: true,
|
||||
});
|
||||
mockCpSpawn.mockReturnValue(mockBgChildProcess);
|
||||
|
||||
// Explicitly clear state between runs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundLogStreams.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).activePtys.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).activeChildProcesses.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundLogStreams.clear();
|
||||
});
|
||||
|
||||
it('should move a running pty process to the background and start logging', async () => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'long-running-pty',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Use the registered onData listener
|
||||
const onDataListener = mockPtyProcess.onData.mock.calls[0][0];
|
||||
onDataListener('initial pty output');
|
||||
|
||||
// Wait for async write to headless terminal
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
mockSerializeTerminalToObject.mockReturnValue([
|
||||
[{ text: 'initial pty output', fg: '', bg: '' }],
|
||||
]);
|
||||
|
||||
// Background the process
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(result.output).toContain('initial pty output');
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('background-processes'),
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// Verify initial output was written
|
||||
expect(
|
||||
mockWriteStream.write.mock.calls.some((call) =>
|
||||
call[0].includes('initial pty output'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
expect(mockWriteStream.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue logging after backgrounding for child_process', async () => {
|
||||
mockGetPty.mockResolvedValue(null); // Force child_process fallback
|
||||
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'long-running-cp',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Trigger data before backgrounding
|
||||
mockBgChildProcess.stdout?.emit('data', Buffer.from('initial cp output'));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(result.output).toBe('initial cp output');
|
||||
|
||||
expect(
|
||||
mockWriteStream.write.mock.calls.some((call) =>
|
||||
call[0].includes('initial cp output'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Subsequent output
|
||||
mockBgChildProcess.stdout?.emit('data', Buffer.from('more cp output'));
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
expect(mockWriteStream.write).toHaveBeenCalledWith('more cp output');
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
expect(mockWriteStream.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning if background log setup fails', async () => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'failing-log-setup',
|
||||
'/',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
// Mock mkdirSync to fail
|
||||
const error = new Error('Permission denied');
|
||||
mockMkdirSync.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Background the process
|
||||
ShellExecutionService.background(handle.pid!);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.backgrounded).toBe(true);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to setup background logging:',
|
||||
error,
|
||||
);
|
||||
|
||||
await ShellExecutionService.kill(handle.pid!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Binary Output', () => {
|
||||
it('should detect binary output and switch to progress events', async () => {
|
||||
mockIsBinary.mockReturnValueOnce(true);
|
||||
@@ -894,7 +1095,7 @@ describe('ShellExecutionService', () => {
|
||||
'destroy',
|
||||
);
|
||||
|
||||
ShellExecutionService.kill(pid);
|
||||
await ShellExecutionService.kill(pid);
|
||||
|
||||
expect(storedDestroySpy).toHaveBeenCalled();
|
||||
expect(ShellExecutionService['activePtys'].has(pid)).toBe(false);
|
||||
@@ -974,7 +1175,10 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
// Helper function to run a standard execution simulation
|
||||
const simulateExecution = async (
|
||||
command: string,
|
||||
simulation: (cp: typeof mockChildProcess, ac: AbortController) => void,
|
||||
simulation: (
|
||||
cp: typeof mockChildProcess,
|
||||
ac: AbortController,
|
||||
) => void | Promise<void>,
|
||||
) => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
@@ -987,7 +1191,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
simulation(mockChildProcess, abortController);
|
||||
await simulation(mockChildProcess, abortController);
|
||||
const result = await handle.result;
|
||||
return { result, handle, abortController };
|
||||
};
|
||||
@@ -1315,9 +1519,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
describe('Platform-Specific Behavior', () => {
|
||||
it('should use powershell.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) =>
|
||||
cp.emit('exit', 0, null),
|
||||
);
|
||||
await simulateExecution('dir "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -1332,7 +1536,9 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should use bash and detached process group on Linux', async () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
|
||||
await simulateExecution('ls "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
|
||||
Reference in New Issue
Block a user