mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-14 21:37:20 -07:00
feat: Implement background shell commands (#14849)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:child_process');
|
||||
|
||||
describe('process-utils', () => {
|
||||
const mockProcessKill = vi
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation(() => true);
|
||||
const mockSpawn = vi.mocked(cpSpawn);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('killProcessGroup', () => {
|
||||
it('should use taskkill on Windows', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('taskkill', [
|
||||
'/pid',
|
||||
'1234',
|
||||
'/f',
|
||||
'/t',
|
||||
]);
|
||||
expect(mockProcessKill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use pty.kill() on Windows if pty is provided', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should kill the process group on Unix with SIGKILL by default', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use escalation on Unix if requested', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const exited = false;
|
||||
const isExited = () => exited;
|
||||
|
||||
const killPromise = killProcessGroup({
|
||||
pid: 1234,
|
||||
escalate: true,
|
||||
isExited,
|
||||
});
|
||||
|
||||
// First call should be SIGTERM
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Advance time
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
// Second call should be SIGKILL
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should skip SIGKILL if isExited returns true after SIGTERM', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
let exited = false;
|
||||
const isExited = vi.fn().mockImplementation(() => exited);
|
||||
|
||||
const killPromise = killProcessGroup({
|
||||
pid: 1234,
|
||||
escalate: true,
|
||||
isExited,
|
||||
});
|
||||
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
|
||||
|
||||
// Simulate process exiting
|
||||
exited = true;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
|
||||
|
||||
expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
await killPromise;
|
||||
});
|
||||
|
||||
it('should fallback to specific process kill if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
// Failed group kill
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
// Fallback individual kill
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(1234, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('should use pty fallback on Unix if group kill fails', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
|
||||
/** Default timeout for SIGKILL escalation on Unix systems. */
|
||||
export const SIGKILL_TIMEOUT_MS = 200;
|
||||
|
||||
/** Configuration for process termination. */
|
||||
export interface KillOptions {
|
||||
/** The process ID to terminate. */
|
||||
pid: number;
|
||||
/** Whether to attempt SIGTERM before SIGKILL on Unix systems. */
|
||||
escalate?: boolean;
|
||||
/** Initial signal to use (defaults to SIGTERM if escalate is true, else SIGKILL). */
|
||||
signal?: NodeJS.Signals | number;
|
||||
/** Callback to check if the process has already exited. */
|
||||
isExited?: () => boolean;
|
||||
/** Optional PTY object for PTY-specific kill methods. */
|
||||
pty?: { kill: (signal?: string) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Robustly terminates a process or process group across platforms.
|
||||
*
|
||||
* On Windows, it uses `taskkill /f /t` to ensure the entire tree is terminated,
|
||||
* or the PTY's built-in kill method.
|
||||
*
|
||||
* On Unix, it attempts to kill the process group (using -pid) with escalation
|
||||
* from SIGTERM to SIGKILL if requested.
|
||||
*/
|
||||
export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
const { pid, escalate = false, isExited = () => false, pty } = options;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
if (pty) {
|
||||
try {
|
||||
pty.kill();
|
||||
} catch {
|
||||
// Ignore errors for dead processes
|
||||
}
|
||||
} else {
|
||||
cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix logic
|
||||
try {
|
||||
const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL');
|
||||
|
||||
// Try killing the process group first (-pid)
|
||||
process.kill(-pid, initialSignal);
|
||||
|
||||
if (escalate && !isExited()) {
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to specific process kill if group kill fails or on error
|
||||
if (!isExited()) {
|
||||
if (pty) {
|
||||
if (escalate) {
|
||||
try {
|
||||
pty.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ export const enum ColorMode {
|
||||
}
|
||||
|
||||
class Cell {
|
||||
private readonly cell: IBufferCell | null;
|
||||
private readonly x: number;
|
||||
private readonly y: number;
|
||||
private readonly cursorX: number;
|
||||
private readonly cursorY: number;
|
||||
private readonly attributes: number = 0;
|
||||
private cell: IBufferCell | null = null;
|
||||
private x = 0;
|
||||
private y = 0;
|
||||
private cursorX = 0;
|
||||
private cursorY = 0;
|
||||
private attributes: number = 0;
|
||||
fg = 0;
|
||||
bg = 0;
|
||||
fgColorMode: ColorMode = ColorMode.DEFAULT;
|
||||
@@ -51,12 +51,23 @@ class Cell {
|
||||
y: number,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
) {
|
||||
this.update(cell, x, y, cursorX, cursorY);
|
||||
}
|
||||
|
||||
update(
|
||||
cell: IBufferCell | null,
|
||||
x: number,
|
||||
y: number,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
) {
|
||||
this.cell = cell;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.cursorX = cursorX;
|
||||
this.cursorY = cursorY;
|
||||
this.attributes = 0;
|
||||
|
||||
if (!cell) {
|
||||
return;
|
||||
@@ -131,7 +142,11 @@ class Cell {
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
export function serializeTerminalToObject(
|
||||
terminal: Terminal,
|
||||
startLine?: number,
|
||||
endLine?: number,
|
||||
): AnsiOutput {
|
||||
const buffer = terminal.buffer.active;
|
||||
const cursorX = buffer.cursorX;
|
||||
const cursorY = buffer.cursorY;
|
||||
@@ -140,22 +155,30 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
|
||||
const result: AnsiOutput = [];
|
||||
|
||||
for (let y = 0; y < terminal.rows; y++) {
|
||||
const line = buffer.getLine(buffer.viewportY + y);
|
||||
// Reuse cell instances
|
||||
const lastCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
const currentCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
|
||||
const effectiveStart = startLine ?? buffer.viewportY;
|
||||
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
|
||||
|
||||
for (let y = effectiveStart; y < effectiveEnd; y++) {
|
||||
const line = buffer.getLine(y);
|
||||
const currentLine: AnsiLine = [];
|
||||
if (!line) {
|
||||
result.push(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCell = new Cell(null, -1, -1, cursorX, cursorY);
|
||||
// Reset lastCell for new line
|
||||
lastCell.update(null, -1, -1, cursorX, cursorY);
|
||||
let currentText = '';
|
||||
|
||||
for (let x = 0; x < terminal.cols; x++) {
|
||||
const cellData = line.getCell(x);
|
||||
const cell = new Cell(cellData || null, x, y, cursorX, cursorY);
|
||||
currentCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
|
||||
if (x > 0 && !cell.equals(lastCell)) {
|
||||
if (x > 0 && !currentCell.equals(lastCell)) {
|
||||
if (currentText) {
|
||||
const token: AnsiToken = {
|
||||
text: currentText,
|
||||
@@ -172,8 +195,10 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
|
||||
}
|
||||
currentText = '';
|
||||
}
|
||||
currentText += cell.getChars();
|
||||
lastCell = cell;
|
||||
currentText += currentCell.getChars();
|
||||
// Copy state from currentCell to lastCell. Since we can't easily deep copy
|
||||
// without allocating, we just update lastCell with the same data.
|
||||
lastCell.update(cellData || null, x, y, cursorX, cursorY);
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
|
||||
Reference in New Issue
Block a user