Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from 'vitest';

import os from 'node:os';
import path from 'node:path';
import EventEmitter from 'node:events';
import type { Readable } from 'node:stream';
import { type ChildProcess } from 'node:child_process';
Expand Down Expand Up @@ -999,6 +1000,77 @@ describe('ShellExecutionService', () => {
ShellExecutionService.listBackgroundProcesses(undefined as any),
).toThrow('Session ID is required');
});

it('should accept tempDir in background() and delete it when the background process exits', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs')>('node:fs');
const actualOs =
await vi.importActual<typeof import('node:os')>('node:os');
const tempDir = actualFs.mkdtempSync(
path.join(actualOs.tmpdir(), 'gemini-shell-bg-unit-'),
);
actualFs.writeFileSync(path.join(tempDir, 'bgpids.tmp'), '123\n');

let triggerExit:
| ((args: { exitCode: number; signal?: number }) => void)
| undefined;

await simulateExecution('sleep 1', (pty) => {
triggerExit = pty.onExit.mock.calls[0][0];

ShellExecutionService.background(
pty.pid,
'default',
'sleep 1',
tempDir,
);
});

const history = (
ShellExecutionService as unknown as {
backgroundProcessHistory: Map<
string,
Map<number, { tempDir?: string }>
>;
}
).backgroundProcessHistory.get('default');
expect(history?.get(12345)?.tempDir).toBe(tempDir);
expect(actualFs.existsSync(tempDir)).toBe(true);

triggerExit?.({ exitCode: 0 });

await vi.waitFor(() => {
expect(actualFs.existsSync(tempDir)).toBe(false);
});
});

it('should return early and clean up tempDir if background() is called for an untracked or already-exited process', async () => {
const actualFs =
await vi.importActual<typeof import('node:fs')>('node:fs');
const actualOs =
await vi.importActual<typeof import('node:os')>('node:os');
const tempDir = actualFs.mkdtempSync(
path.join(actualOs.tmpdir(), 'gemini-shell-bg-untracked-'),
);

ShellExecutionService.background(
99999,
'default',
'echo exited',
tempDir,
);

const backgroundTempDirs = (
ShellExecutionService as unknown as {
backgroundTempDirs: Map<number, string>;
}
).backgroundTempDirs;
expect(backgroundTempDirs.has(99999)).toBe(false);

await vi.waitFor(() => {
expect(actualFs.existsSync(tempDir)).toBe(false);
});
});
});

describe('Binary Output', () => {
Expand Down
70 changes: 54 additions & 16 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TextDecoder } from 'node:util';
import type { Writable } from 'node:stream';
import os from 'node:os';
import fs, { mkdirSync } from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { IPty } from '@lydell/node-pty';
import {
Expand Down Expand Up @@ -142,6 +143,7 @@ export interface ShellExecutionConfig {
originalCommand?: string;
sessionId?: string;
env?: Record<string, string | undefined>;
tempDir?: string;
}

/**
Expand Down Expand Up @@ -187,6 +189,7 @@ interface ActivePty {
command: string;
sessionId?: string;
cancelRender?: () => void;
tempDir?: string;
}

interface ActiveChildProcess {
Expand All @@ -199,6 +202,7 @@ interface ActiveChildProcess {
};
command: string;
sessionId?: string;
tempDir?: string;
}

const isAnsiOutputEqual = (
Expand Down Expand Up @@ -369,13 +373,15 @@ export type BackgroundProcess = {
export type BackgroundProcessRecord = Omit<BackgroundProcess, 'pid'> & {
startTime: number;
endTime?: number;
tempDir?: string;
};

export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
private static backgroundLogPids = new Set<number>();
private static backgroundLogStreams = new Map<number, fs.WriteStream>();
private static backgroundTempDirs = new Map<number, string>();
private static backgroundProcessHistory = new Map<
string, // sessionId
Map<number, BackgroundProcessRecord>
Expand Down Expand Up @@ -417,6 +423,12 @@ export class ShellExecutionService {
}

private static async cleanupLogStream(pid: number): Promise<void> {
const tempDir = this.backgroundTempDirs.get(pid);
this.backgroundTempDirs.delete(pid);
const rmPromise = tempDir
? fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {})
: Promise.resolve();

const stream = this.backgroundLogStreams.get(pid);
if (stream) {
await new Promise<void>((resolve) => {
Expand All @@ -426,6 +438,7 @@ export class ShellExecutionService {
}

this.backgroundLogPids.delete(pid);
await rmPromise;
}

/**
Expand Down Expand Up @@ -704,6 +717,7 @@ export class ShellExecutionService {
state,
command: shellExecutionConfig.originalCommand ?? commandToExecute,
sessionId: shellExecutionConfig.sessionId,
tempDir: shellExecutionConfig.tempDir,
});
}

Expand Down Expand Up @@ -1246,6 +1260,7 @@ export class ShellExecutionService {
command: shellExecutionConfig.originalCommand ?? commandToExecute,
sessionId: shellExecutionConfig.sessionId,
cancelRender,
tempDir: shellExecutionConfig.tempDir,
});

const result = ExecutionLifecycleService.attachExecution(assignedPid, {
Expand Down Expand Up @@ -1811,15 +1826,22 @@ export class ShellExecutionService {
* This resolves the execution promise but keeps the PTY active.
*
* @param pid The process ID of the target PTY.
* @param sessionId Optional session ID for process history tracking.
* @param command Optional command string for display.
* @param tempDir Optional temporary directory path owned by this execution to clean up on exit.
*/
static background(pid: number, sessionId?: string, command?: string): void {
if (this.backgroundLogPids.has(pid)) {
return;
}

static background(
pid: number,
sessionId?: string,
command?: string,
tempDir?: string,
): void {
const activePty = this.activePtys.get(pid);
const activeChild = this.activeChildProcesses.get(pid);

const resolvedTempDir =
tempDir ?? activePty?.tempDir ?? activeChild?.tempDir;

const resolvedSessionId =
sessionId ?? activePty?.sessionId ?? activeChild?.sessionId;
const resolvedCommand =
Expand All @@ -1832,20 +1854,27 @@ export class ShellExecutionService {
throw new Error('Session ID is required for background operations');
}

if (!activePty && !activeChild) {
if (resolvedTempDir) {
fsPromises
.rm(resolvedTempDir, { recursive: true, force: true })
.catch(() => {});
}
return;
}

if (resolvedTempDir) {
this.backgroundTempDirs.set(pid, resolvedTempDir);
}

if (this.backgroundLogPids.has(pid)) {
return;
}

const MAX_BACKGROUND_PROCESS_HISTORY_SIZE = 100;
const history =
this.backgroundProcessHistory.get(resolvedSessionId) ??
new Map<
number,
{
command: string;
status: 'running' | 'exited';
exitCode?: number | null;
signal?: number | null;
startTime: number;
endTime?: number;
}
>();
new Map<number, BackgroundProcessRecord>();

if (history.size >= MAX_BACKGROUND_PROCESS_HISTORY_SIZE) {
const oldestPid = history.keys().next().value;
Expand All @@ -1858,6 +1887,7 @@ export class ShellExecutionService {
command: resolvedCommand,
status: 'running',
startTime: Date.now(),
...(resolvedTempDir ? { tempDir: resolvedTempDir } : {}),
});
this.backgroundProcessHistory.set(resolvedSessionId, history);

Expand Down Expand Up @@ -2045,8 +2075,16 @@ export class ShellExecutionService {
// ignored
}
}
for (const tempDir of this.backgroundTempDirs.values()) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignored
}
}
Comment thread
jesussamuel-byte marked this conversation as resolved.
this.backgroundLogPids.clear();
this.backgroundLogStreams.clear();
this.backgroundTempDirs.clear();
this.backgroundProcessHistory.clear();
}
}
34 changes: 33 additions & 1 deletion packages/core/src/tools/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ describe('ShellTool', () => {
});

afterEach(() => {
if (extractedTmpFile) {
const extractedDir = path.dirname(extractedTmpFile);
if (fs.existsSync(extractedDir)) {
fs.rmSync(extractedDir, { recursive: true, force: true });
}
}
if (fs.existsSync(tempRootDir)) {
fs.rmSync(tempRootDir, { recursive: true, force: true });
}
Expand Down Expand Up @@ -482,16 +488,20 @@ describe('ShellTool', () => {

await vi.advanceTimersByTimeAsync(250);

const expectedTempDir = path.dirname(extractedTmpFile);
expect(mockShellBackground).toHaveBeenCalledWith(
12345,
'default',
'sleep 10',
expectedTempDir,
);

await promise;
// Ownership was transferred to ShellExecutionService, so shell.ts should not delete it prematurely
expect(fs.existsSync(expectedTempDir)).toBe(true);
});

it('should cancel the promotion timer when the command completes before the delay elapses', async () => {
it('should cancel the promotion timer and clean up tempDir when the command completes before the delay elapses', async () => {
vi.useFakeTimers();
const invocation = shellTool.build({
command: 'echo done',
Expand All @@ -506,6 +516,27 @@ describe('ShellTool', () => {
expect(mockShellBackground).not.toHaveBeenCalled();

await promise;
const expectedTempDir = path.dirname(extractedTmpFile);
expect(fs.existsSync(expectedTempDir)).toBe(false);
});

it('should clean up tempDir in finally if ShellExecutionService.background throws an error', async () => {
vi.useFakeTimers();
mockShellBackground.mockImplementationOnce(() => {
throw new Error('Background failed');
});

const invocation = shellTool.build({
command: 'sleep 10',
is_background: true,
});
const promise = invocation.execute({ abortSignal: mockAbortSignal });

await vi.advanceTimersByTimeAsync(250);
await promise;

const expectedTempDir = path.dirname(extractedTmpFile);
expect(fs.existsSync(expectedTempDir)).toBe(false);
});

itWindowsOnly(
Expand Down Expand Up @@ -963,6 +994,7 @@ EOF`;
12345,
'default',
'sleep 10',
path.dirname(extractedTmpFile),
);

await promise;
Expand Down
31 changes: 22 additions & 9 deletions packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
const isWindows = os.platform() === 'win32';
let tempFilePath = '';
let tempDir = '';
let isBackgrounded = false;

const timeoutMs = this.context.config.getShellToolInactivityTimeout();
const timeoutController = new AbortController();
Expand Down Expand Up @@ -749,6 +750,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
backgroundCompletionBehavior:
this.context.config.getShellBackgroundCompletionBehavior(),
originalCommand: strippedCommand,
tempDir,
},
);

Expand All @@ -765,11 +767,20 @@ export class ShellToolInvocation extends BaseToolInvocation<
() => {
promotionTimer = null;
if (!completed) {
ShellExecutionService.background(
pid,
sessionId,
strippedCommand,
);
try {
ShellExecutionService.background(
pid,
sessionId,
strippedCommand,
tempDir,
);
isBackgrounded = true;
} catch (err) {
debugLogger.error(
'Failed to background shell execution:',
err,
);
}
}
Comment thread
jesussamuel-byte marked this conversation as resolved.
},
delay,
Expand Down Expand Up @@ -813,7 +824,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
}

const result = await resultPromise;
if (!result.backgrounded) {
if (result.backgrounded) {
isBackgrounded = true;
} else {
flushOutput();
}

Expand Down Expand Up @@ -1154,9 +1167,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
timeoutController.signal.removeEventListener('abort', onAbort);

// Only clean up if NOT running in background.
// Background processes need the temp directory and PID file to remain
// available until they exit.
if (!this.params.is_background) {
// Background processes transfer ownership of the temp directory to
// ShellExecutionService, which removes it once the process exits.
if (!isBackgrounded) {
if (tempFilePath) {
try {
await fsPromises.unlink(tempFilePath);
Expand Down
Loading
Loading