mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-01 08:51:11 -07:00
feat: implement background process logging and cleanup (#21189)
This commit is contained in:
@@ -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