mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
feat: implement background process logging and cleanup (#21189)
This commit is contained in:
@@ -109,6 +109,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.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;
|
||||
@@ -370,6 +371,7 @@ export async function main() {
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
|
||||
@@ -473,9 +473,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();
|
||||
|
||||
@@ -35,6 +35,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'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -222,7 +226,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
76,
|
||||
21,
|
||||
20,
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -242,7 +246,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
96,
|
||||
27,
|
||||
26,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -43,8 +45,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];
|
||||
@@ -81,7 +89,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]);
|
||||
|
||||
@@ -150,7 +158,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;
|
||||
@@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
dismissBackgroundShell(activeShell.pid);
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -336,7 +344,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 },
|
||||
@@ -383,6 +394,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 (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>Log: {displayPath}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutput = () => {
|
||||
const lines = typeof output === 'string' ? output.split('\n') : output;
|
||||
|
||||
@@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
</Box>
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,6 +101,12 @@ describe('<Footer />', () => {
|
||||
beforeEach(() => {
|
||||
const root = path.parse(process.cwd()).root;
|
||||
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders the component', async () => {
|
||||
@@ -427,15 +433,6 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders complete footer with all sections visible (baseline)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
@@ -459,23 +456,21 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
it('renders footer with all optional sections hidden (minimal footer)', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
},
|
||||
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Wait for Ink to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
|
||||
'footer-minimal',
|
||||
);
|
||||
@@ -797,21 +792,19 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
it('handles empty items array', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<Footer />,
|
||||
{
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: [],
|
||||
},
|
||||
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Wait for Ink to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
expect(output).toBeDefined();
|
||||
|
||||
@@ -410,6 +410,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-3-pro-');
|
||||
expect(output).toContain('gemini-3-flash-');
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
|
||||
│ (Focused) (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -19,6 +20,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1003.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -27,6 +29,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -35,6 +38,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
||||
"┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -48,6 +52,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
@@ -61,6 +66,7 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1002.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -165,6 +165,29 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Auto (Gemini 3) Stats For Nerds │
|
||||
│ │
|
||||
│ │
|
||||
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
|
||||
│ ────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 10 20 │
|
||||
│ Errors 0 (0.0%) 0 (0.0%) │
|
||||
│ Avg Latency 200ms 50ms │
|
||||
│ Tokens │
|
||||
│ Total 6,000 12,000 │
|
||||
│ ↳ Input 1,000 2,000 │
|
||||
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
|
||||
│ ↳ Thoughts 100 200 │
|
||||
│ ↳ Tool 50 100 │
|
||||
│ ↳ Output 4,000 8,000 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
|
||||
@@ -830,8 +830,8 @@ describe('useShellCommandProcessor', () => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(1001);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
@@ -936,8 +936,8 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(999);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
});
|
||||
|
||||
@@ -205,11 +205,11 @@ export const useShellCommandProcessor = (
|
||||
}, [state.activeShellPtyId, activeToolPtyId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
(pid: number) => {
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
ShellExecutionService.kill(pid);
|
||||
await ShellExecutionService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
promises as fs,
|
||||
type PathLike,
|
||||
type Dirent,
|
||||
type Stats,
|
||||
} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { cleanupBackgroundLogs } from './logCleanup.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
ShellExecutionService: {
|
||||
getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'),
|
||||
},
|
||||
debugLogger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
access: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('logCleanup', () => {
|
||||
const logDir = '/tmp/gemini/tmp/background-processes';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should skip cleanup if the directory does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(logDir);
|
||||
expect(fs.readdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip cleanup if the directory is empty', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true });
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete log files older than 7 days', async () => {
|
||||
const now = Date.now();
|
||||
const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||
const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago
|
||||
|
||||
const entries = [
|
||||
{ name: 'old.log', isFile: () => true },
|
||||
{ name: 'new.log', isFile: () => true },
|
||||
{ name: 'not-a-log.txt', isFile: () => true },
|
||||
{ name: 'some-dir', isFile: () => false },
|
||||
] as Dirent[];
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(
|
||||
fs.readdir as (
|
||||
path: PathLike,
|
||||
options: { withFileTypes: true },
|
||||
) => Promise<Dirent[]>,
|
||||
).mockResolvedValue(entries);
|
||||
vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = filePath.toString();
|
||||
if (pathStr.endsWith('old.log')) {
|
||||
return Promise.resolve({ mtime: new Date(oldTime) } as Stats);
|
||||
}
|
||||
if (pathStr.endsWith('new.log')) {
|
||||
return Promise.resolve({ mtime: new Date(newTime) } as Stats);
|
||||
}
|
||||
return Promise.resolve({ mtime: new Date(now) } as Stats);
|
||||
});
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await cleanupBackgroundLogs();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log'));
|
||||
expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log'));
|
||||
});
|
||||
|
||||
it('should handle errors during file deletion gracefully', async () => {
|
||||
const now = Date.now();
|
||||
const oldTime = now - 8 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const entries = [{ name: 'old.log', isFile: () => true }];
|
||||
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.readdir).mockResolvedValue(entries as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any);
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(cleanupBackgroundLogs()).resolves.not.toThrow();
|
||||
expect(fs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ShellExecutionService, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const RETENTION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Cleans up background process log files older than 7 days.
|
||||
* Scans ~/.gemini/tmp/background-processes/ for .log files.
|
||||
*
|
||||
* @param debugMode Whether to log detailed debug information.
|
||||
*/
|
||||
export async function cleanupBackgroundLogs(
|
||||
debugMode: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(logDir);
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(logDir, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.log')) {
|
||||
const filePath = path.join(logDir, entry.name);
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
if (now - stats.mtime.getTime() > RETENTION_PERIOD_MS) {
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
if (debugMode) {
|
||||
debugLogger.debug(
|
||||
`Failed to process log file ${entry.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0 && debugMode) {
|
||||
debugLogger.debug(`Cleaned up ${deletedCount} expired background logs.`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort cleanup, don't let it crash the CLI
|
||||
if (debugMode) {
|
||||
debugLogger.warn('Background log cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getPty, type PtyImplementation } from '../utils/getPty.js';
|
||||
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import fs, { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
||||
import {
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import pkg from '@xterm/headless';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import {
|
||||
serializeTerminalToObject,
|
||||
type AnsiOutput,
|
||||
@@ -152,20 +156,37 @@ interface ActiveChildProcess {
|
||||
};
|
||||
}
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
const findLastContentLine = (
|
||||
buffer: pkg.IBuffer,
|
||||
startLine: number,
|
||||
): number => {
|
||||
const lineCount = buffer.length;
|
||||
for (let i = lineCount - 1; i >= startLine; i--) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line && line.translateToString(true).length > 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
|
||||
const lastContentLine = findLastContentLine(buffer, startLine);
|
||||
|
||||
if (lastContentLine === -1 || lastContentLine < startLine) return '';
|
||||
|
||||
for (let i = startLine; i <= lastContentLine; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) {
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
// If the NEXT line is wrapped, it means it's a continuation of THIS line.
|
||||
// We should not trim the right side of this line because trailing spaces
|
||||
// might be significant parts of the wrapped content.
|
||||
// If it's not wrapped, we trim normally.
|
||||
|
||||
let trimRight = true;
|
||||
if (i + 1 < buffer.length) {
|
||||
if (i + 1 <= lastContentLine) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
trimRight = false;
|
||||
@@ -181,12 +202,56 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const writeBufferToLogStream = (
|
||||
terminal: pkg.Terminal,
|
||||
stream: fs.WriteStream,
|
||||
startLine = 0,
|
||||
): number => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lastContentLine = findLastContentLine(buffer, startLine);
|
||||
|
||||
if (lastContentLine === -1 || lastContentLine < startLine) return startLine;
|
||||
|
||||
for (let i = startLine; i <= lastContentLine; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) {
|
||||
stream.write('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
let trimRight = true;
|
||||
if (i + 1 <= lastContentLine) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
trimRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineContent = line.translateToString(trimRight);
|
||||
const stripped = stripAnsi(lineContent);
|
||||
|
||||
if (line.isWrapped) {
|
||||
stream.write(stripped);
|
||||
} else {
|
||||
if (i > startLine) {
|
||||
stream.write('\n');
|
||||
}
|
||||
stream.write(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
// Ensure it ends with a newline if we wrote anything and the next line is not wrapped
|
||||
if (lastContentLine >= startLine) {
|
||||
const nextLine = terminal.buffer.active.getLine(lastContentLine + 1);
|
||||
if (!nextLine?.isWrapped) {
|
||||
stream.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return lastContentLine + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -198,10 +263,43 @@ const getFullBufferText = (terminal: pkg.Terminal): 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 exitedPtyInfo = new Map<
|
||||
number,
|
||||
{ exitCode: number; signal?: number }
|
||||
>();
|
||||
|
||||
static getLogDir(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), 'background-processes');
|
||||
}
|
||||
|
||||
static getLogFilePath(pid: number): string {
|
||||
return path.join(this.getLogDir(), `background-${pid}.log`);
|
||||
}
|
||||
|
||||
private static syncBackgroundLog(pid: number, content: string): void {
|
||||
if (!this.backgroundLogPids.has(pid)) return;
|
||||
|
||||
const stream = this.backgroundLogStreams.get(pid);
|
||||
if (stream && content) {
|
||||
// Strip ANSI escape codes before logging
|
||||
stream.write(stripAnsi(content));
|
||||
}
|
||||
}
|
||||
|
||||
private static async cleanupLogStream(pid: number): Promise<void> {
|
||||
const stream = this.backgroundLogStreams.get(pid);
|
||||
if (stream) {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve());
|
||||
});
|
||||
this.backgroundLogStreams.delete(pid);
|
||||
}
|
||||
|
||||
this.backgroundLogPids.delete(pid);
|
||||
}
|
||||
|
||||
private static activeResolvers = new Map<
|
||||
number,
|
||||
(res: ShellExecutionResult) => void
|
||||
@@ -432,7 +530,15 @@ export class ShellExecutionService {
|
||||
chunk: decodedChunk,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
|
||||
if (child.pid) {
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
if (ShellExecutionService.backgroundLogPids.has(child.pid)) {
|
||||
ShellExecutionService.syncBackgroundLog(
|
||||
child.pid,
|
||||
decodedChunk,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const totalBytes = state.outputChunks.reduce(
|
||||
@@ -468,17 +574,21 @@ export class ShellExecutionService {
|
||||
const exitSignal = signal ? os.constants.signals[signal] : null;
|
||||
|
||||
if (child.pid) {
|
||||
const pid = child.pid;
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: exitSignal,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
ShellExecutionService.emitEvent(pid, event);
|
||||
|
||||
this.activeChildProcesses.delete(child.pid);
|
||||
this.activeResolvers.delete(child.pid);
|
||||
this.activeListeners.delete(child.pid);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(pid).then(() => {
|
||||
this.activeChildProcesses.delete(pid);
|
||||
this.activeResolvers.delete(pid);
|
||||
this.activeListeners.delete(pid);
|
||||
});
|
||||
}
|
||||
|
||||
resolve({
|
||||
@@ -800,6 +910,16 @@ export class ShellExecutionService {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ShellExecutionService.backgroundLogPids.has(ptyProcess.pid)
|
||||
) {
|
||||
ShellExecutionService.syncBackgroundLog(
|
||||
ptyProcess.pid,
|
||||
decodedChunk,
|
||||
);
|
||||
}
|
||||
|
||||
isWriting = true;
|
||||
headlessTerminal.write(decodedChunk, () => {
|
||||
render();
|
||||
@@ -832,7 +952,6 @@ export class ShellExecutionService {
|
||||
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
// Attempt to destroy the PTY to ensure FD is closed
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -853,31 +972,36 @@ export class ShellExecutionService {
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.activeResolvers.delete(ptyProcess.pid);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
ShellExecutionService.cleanupLogStream(ptyProcess.pid).then(
|
||||
() => {
|
||||
this.activePtys.delete(ptyProcess.pid);
|
||||
this.activeResolvers.delete(ptyProcess.pid);
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
this.activeListeners.delete(ptyProcess.pid);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
this.activeListeners.delete(ptyProcess.pid);
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
pid: ptyProcess.pid,
|
||||
executionMethod: ptyInfo?.name ?? 'node-pty',
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
@@ -1050,10 +1174,12 @@ export class ShellExecutionService {
|
||||
*
|
||||
* @param pid The process ID to kill.
|
||||
*/
|
||||
static kill(pid: number): void {
|
||||
static async kill(pid: number): Promise<void> {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
await this.cleanupLogStream(pid);
|
||||
|
||||
if (activeChild) {
|
||||
killProcessGroup({ pid }).catch(() => {});
|
||||
this.activeChildProcesses.delete(pid);
|
||||
@@ -1079,44 +1205,53 @@ export class ShellExecutionService {
|
||||
*/
|
||||
static background(pid: number): void {
|
||||
const resolve = this.activeResolvers.get(pid);
|
||||
if (resolve) {
|
||||
let output = '';
|
||||
const rawOutput = Buffer.from('');
|
||||
if (!resolve) return;
|
||||
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (!activePty && !activeChild) return;
|
||||
|
||||
const output = activePty
|
||||
? getFullBufferText(activePty.headlessTerminal)
|
||||
: (activeChild?.state.output ?? '');
|
||||
const executionMethod = activePty ? 'node-pty' : 'child_process';
|
||||
|
||||
const logPath = this.getLogFilePath(pid);
|
||||
const logDir = this.getLogDir();
|
||||
try {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'w' });
|
||||
stream.on('error', (err) => {
|
||||
debugLogger.warn('Background log stream error:', err);
|
||||
});
|
||||
this.backgroundLogStreams.set(pid, stream);
|
||||
|
||||
if (activePty) {
|
||||
output = getFullBufferText(activePty.headlessTerminal);
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'node-pty',
|
||||
backgrounded: true,
|
||||
});
|
||||
writeBufferToLogStream(activePty.headlessTerminal, stream, 0);
|
||||
} else if (activeChild) {
|
||||
output = activeChild.state.output;
|
||||
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'child_process',
|
||||
backgrounded: true,
|
||||
});
|
||||
if (output) {
|
||||
stream.write(stripAnsi(output) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to setup background logging:', e);
|
||||
}
|
||||
|
||||
this.backgroundLogPids.add(pid);
|
||||
|
||||
resolve({
|
||||
rawOutput: Buffer.from(''),
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod,
|
||||
backgrounded: true,
|
||||
});
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
}
|
||||
|
||||
static subscribe(
|
||||
|
||||
Reference in New Issue
Block a user