From 127fbf9487859037cecd9406774a041d16258193 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 4 Mar 2026 17:00:48 -0800 Subject: [PATCH 1/6] feat: implement background process logging and cleanup - Add persistent logging for backgrounded shell processes in ~/.gemini/tmp/background-processes - Implement automatic cleanup of background logs older than 7 days on CLI startup - Support real-time output syncing to log files for both PTY and child_process execution - Update UI to indicate log file availability for background tasks - Add comprehensive unit and integration tests for logging and cleanup logic --- .../background_shell_output.test.ts | 87 +++++ packages/cli/src/gemini.tsx | 2 + packages/cli/src/ui/AppContainer.tsx | 8 +- .../BackgroundShellDisplay.test.tsx | 8 +- .../ui/components/BackgroundShellDisplay.tsx | 39 ++- .../cli/src/ui/components/Footer.test.tsx | 18 +- .../ui/components/ModelStatsDisplay.test.tsx | 1 + .../BackgroundShellDisplay.test.tsx.snap | 6 + .../cli/src/ui/contexts/UIActionsContext.tsx | 2 +- .../ui/hooks/shellCommandProcessor.test.tsx | 8 +- .../cli/src/ui/hooks/shellCommandProcessor.ts | 4 +- packages/cli/src/utils/logCleanup.test.ts | 116 +++++++ packages/cli/src/utils/logCleanup.ts | 66 ++++ .../services/shellExecutionService.test.ts | 207 +++++++++++- .../src/services/shellExecutionService.ts | 302 +++++++++++++----- 15 files changed, 769 insertions(+), 105 deletions(-) create mode 100644 integration-tests/background_shell_output.test.ts create mode 100644 packages/cli/src/utils/logCleanup.test.ts create mode 100644 packages/cli/src/utils/logCleanup.ts diff --git a/integration-tests/background_shell_output.test.ts b/integration-tests/background_shell_output.test.ts new file mode 100644 index 00000000000..a50feddc566 --- /dev/null +++ b/integration-tests/background_shell_output.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('Background Shell Output Logging', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + it('should log background process output to a file', async () => { + await rig.setup('should log background process output to a file', { + settings: { tools: { core: ['run_shell_command'] } }, + }); + + // We use a command that outputs something, then backgrounds, then outputs more. + // Since we're in the test rig, we have to be careful with how we background. + // The run_shell_command tool backgrounds if is_background: true is passed. + + const prompt = `Please run the command "echo start && sleep 1 && echo end" in the background and tell me the PID.`; + + const result = await rig.run({ + args: [prompt], + // approvalMode: 'yolo' is needed to avoid interactive prompt in tests + approvalMode: 'yolo', + }); + + // Extract PID from result + const cleanResult = result.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '', + ); + const pidMatch = cleanResult.match(/PID.*?\s*(\d+)/i); + expect(pidMatch, `Expected PID in output: ${cleanResult}`).toBeTruthy(); + const pid = parseInt(pidMatch![1], 10); + + const logDir = path.join( + rig.homeDir!, + '.gemini', + 'tmp', + 'background-processes', + ); + const logFilePath = path.join(logDir, `background-${pid}.log`); + + // Wait for the process to finish and log output + // We'll poll the log file + let logContent = ''; + const maxRetries = 40; + let retries = 0; + + while (retries < maxRetries) { + if (fs.existsSync(logFilePath)) { + logContent = fs.readFileSync(logFilePath, 'utf8'); + if (logContent.includes('end')) { + break; + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + retries++; + } + + expect(logContent).toContain('start'); + expect(logContent).toContain('end'); + + // Verify no ANSI escape codes are present (starting with \x1b[ or \u001b[) + const ansiRegex = + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + expect(logContent).not.toMatch(ansiRegex); + + // Cleanup the log file after test + if (fs.existsSync(logFilePath)) { + fs.unlinkSync(logFilePath); + } + }); +}); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7b385453bf8..6e96d3c6153 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -103,6 +103,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; +import { cleanupBackgroundLogs } from './utils/logCleanup.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; const SLOW_RENDER_MS = 200; @@ -358,6 +359,7 @@ export async function main() { await Promise.all([ cleanupCheckpoints(), cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), ]); const parseArgsHandle = startupProfiler.start('parse_arguments'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 446e737394c..36a6c2c1576 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -428,9 +428,11 @@ export const AppContainer = (props: AppContainerProps) => { disableMouseEvents(); // Kill all background shells - for (const pid of backgroundShellsRef.current.keys()) { - ShellExecutionService.kill(pid); - } + await Promise.all( + Array.from(backgroundShellsRef.current.keys()).map((pid) => + ShellExecutionService.kill(pid), + ), + ); const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 8b14c9c41aa..3a4532f4614 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -37,6 +37,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), + getLogFilePath: vi.fn( + (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, + ), + getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), }, }; }); @@ -221,7 +225,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, - 21, + 20, ); rerender( @@ -243,7 +247,7 @@ describe('', () => { expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, - 27, + 26, ); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 03cd10823d9..312a28d0a05 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { theme } from '../semantic-colors.js'; import { ShellExecutionService, + shortenPath, + tildeifyPath, type AnsiOutput, type AnsiLine, type AnsiToken, @@ -42,8 +44,14 @@ interface BackgroundShellDisplayProps { const CONTENT_PADDING_X = 1; const BORDER_WIDTH = 2; // Left and Right border -const HEADER_HEIGHT = 3; // 2 for border, 1 for header +const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border +const HEADER_HEIGHT = 1; +const FOOTER_HEIGHT = 1; +const TOTAL_OVERHEAD_HEIGHT = + MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; +const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom const TAB_DISPLAY_HORIZONTAL_PADDING = 4; +const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) const formatShellCommandForDisplay = (command: string, maxWidth: number) => { const commandFirstLine = command.split('\n')[0]; @@ -79,7 +87,7 @@ export const BackgroundShellDisplay = ({ if (!activePid) return; const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); - const ptyHeight = Math.max(1, height - HEADER_HEIGHT); + const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); }, [activePid, width, height]); @@ -148,7 +156,7 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - dismissBackgroundShell(highlightedPid); + void dismissBackgroundShell(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -169,7 +177,7 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - dismissBackgroundShell(activeShell.pid); + void dismissBackgroundShell(activeShell.pid); return true; } @@ -334,7 +342,10 @@ export const BackgroundShellDisplay = ({ }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} - maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header + maxItemsToShow={Math.max( + 1, + height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, + )} renderItem={( item, { isSelected: _isSelected, titleColor: _titleColor }, @@ -381,6 +392,23 @@ export const BackgroundShellDisplay = ({ ); }; + const renderFooter = () => { + const pidToDisplay = isListOpenProp + ? (highlightedPid ?? activePid) + : activePid; + if (!pidToDisplay) return null; + const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); + const displayPath = shortenPath( + tildeifyPath(logPath), + width - LOG_PATH_OVERHEAD, + ); + return ( + + Log: {displayPath} + + ); + }; + const renderOutput = () => { const lines = typeof output === 'string' ? output.split('\n') : output; @@ -452,6 +480,7 @@ export const BackgroundShellDisplay = ({ {isListOpenProp ? renderProcessList() : renderOutput()} + {renderFooter()} ); }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 635a3bfa831..e2e20106b08 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -60,6 +60,15 @@ const mockSessionStats: SessionStatsState = { }; describe('