feat: Implement background shell commands (#14849)

This commit is contained in:
Gal Zahavi
2026-01-30 09:53:09 -08:00
committed by GitHub
parent fc90f581b2
commit 2eb8dc3042
52 changed files with 3957 additions and 470 deletions
@@ -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');
});
});
});
+98
View File
@@ -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
}
}
}
}
}
+39 -14
View File
@@ -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) {