mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Make execution lifecycle the owner of background state
This commit is contained in:
@@ -94,7 +94,7 @@ type ToolResponseWithParts = ToolCallResponseInfo & {
|
|||||||
llmContent?: PartListUnion;
|
llmContent?: PartListUnion;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ShellToolData {
|
interface BackgroundExecutionData {
|
||||||
pid?: number;
|
pid?: number;
|
||||||
command?: string;
|
command?: string;
|
||||||
initialOutput?: string;
|
initialOutput?: string;
|
||||||
@@ -111,11 +111,13 @@ const SUPPRESSED_TOOL_ERRORS_NOTE =
|
|||||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||||
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
||||||
|
|
||||||
function isShellToolData(data: unknown): data is ShellToolData {
|
function isBackgroundExecutionData(
|
||||||
|
data: unknown,
|
||||||
|
): data is BackgroundExecutionData {
|
||||||
if (typeof data !== 'object' || data === null) {
|
if (typeof data !== 'object' || data === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const d = data as Partial<ShellToolData>;
|
const d = data as Partial<BackgroundExecutionData>;
|
||||||
return (
|
return (
|
||||||
(d.pid === undefined || typeof d.pid === 'number') &&
|
(d.pid === undefined || typeof d.pid === 'number') &&
|
||||||
(d.command === undefined || typeof d.command === 'string') &&
|
(d.command === undefined || typeof d.command === 'string') &&
|
||||||
@@ -311,7 +313,7 @@ export const useGeminiStream = (
|
|||||||
getPreferredEditor,
|
getPreferredEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeToolPtyId = useMemo(() => {
|
const activeToolExecutionId = useMemo(() => {
|
||||||
const executingShellTool = toolCalls.find(
|
const executingShellTool = toolCalls.find(
|
||||||
(tc) =>
|
(tc) =>
|
||||||
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
||||||
@@ -347,7 +349,7 @@ export const useGeminiStream = (
|
|||||||
setShellInputFocused,
|
setShellInputFocused,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
terminalHeight,
|
terminalHeight,
|
||||||
activeToolPtyId,
|
activeToolExecutionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const streamingState = useMemo(
|
const streamingState = useMemo(
|
||||||
@@ -525,7 +527,7 @@ export const useGeminiStream = (
|
|||||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const activePtyId = activeShellPtyId || activeToolPtyId;
|
const activePtyId = activeShellPtyId || activeToolExecutionId;
|
||||||
|
|
||||||
const prevActiveShellPtyIdRef = useRef<number | null>(null);
|
const prevActiveShellPtyIdRef = useRef<number | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1657,7 +1659,7 @@ export const useGeminiStream = (
|
|||||||
// Access result from the tracked tool call response
|
// Access result from the tracked tool call response
|
||||||
const response = t.response as ToolResponseWithParts;
|
const response = t.response as ToolResponseWithParts;
|
||||||
const rawData = response?.data;
|
const rawData = response?.data;
|
||||||
const data = isShellToolData(rawData) ? rawData : undefined;
|
const data = isBackgroundExecutionData(rawData) ? rawData : undefined;
|
||||||
|
|
||||||
// Use data.pid for shell commands moved to the background.
|
// Use data.pid for shell commands moved to the background.
|
||||||
const pid = data?.pid;
|
const pid = data?.pid;
|
||||||
|
|||||||
@@ -4,105 +4,231 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
|
||||||
ShellExecutionService,
|
|
||||||
type ShellExecutionResult,
|
|
||||||
} from './shellExecutionService.js';
|
|
||||||
import {
|
import {
|
||||||
ExecutionLifecycleService,
|
ExecutionLifecycleService,
|
||||||
type ExecutionCompletionOptions,
|
type ExecutionHandle,
|
||||||
|
type ExecutionResult,
|
||||||
} from './executionLifecycleService.js';
|
} from './executionLifecycleService.js';
|
||||||
|
|
||||||
const createResult = (): ShellExecutionResult => ({
|
const BASE_VIRTUAL_ID = 2_000_000_000;
|
||||||
rawOutput: Buffer.from(''),
|
|
||||||
output: '',
|
function resetLifecycleState() {
|
||||||
exitCode: 0,
|
(
|
||||||
signal: null,
|
ExecutionLifecycleService as unknown as {
|
||||||
error: null,
|
activeExecutions: Map<number, unknown>;
|
||||||
aborted: false,
|
activeResolvers: Map<number, unknown>;
|
||||||
pid: 123,
|
activeListeners: Map<number, unknown>;
|
||||||
executionMethod: 'none',
|
exitedExecutionInfo: Map<number, unknown>;
|
||||||
});
|
nextVirtualExecutionId: number;
|
||||||
|
}
|
||||||
|
).activeExecutions.clear();
|
||||||
|
(
|
||||||
|
ExecutionLifecycleService as unknown as {
|
||||||
|
activeExecutions: Map<number, unknown>;
|
||||||
|
activeResolvers: Map<number, unknown>;
|
||||||
|
activeListeners: Map<number, unknown>;
|
||||||
|
exitedExecutionInfo: Map<number, unknown>;
|
||||||
|
nextVirtualExecutionId: number;
|
||||||
|
}
|
||||||
|
).activeResolvers.clear();
|
||||||
|
(
|
||||||
|
ExecutionLifecycleService as unknown as {
|
||||||
|
activeExecutions: Map<number, unknown>;
|
||||||
|
activeResolvers: Map<number, unknown>;
|
||||||
|
activeListeners: Map<number, unknown>;
|
||||||
|
exitedExecutionInfo: Map<number, unknown>;
|
||||||
|
nextVirtualExecutionId: number;
|
||||||
|
}
|
||||||
|
).activeListeners.clear();
|
||||||
|
(
|
||||||
|
ExecutionLifecycleService as unknown as {
|
||||||
|
activeExecutions: Map<number, unknown>;
|
||||||
|
activeResolvers: Map<number, unknown>;
|
||||||
|
activeListeners: Map<number, unknown>;
|
||||||
|
exitedExecutionInfo: Map<number, unknown>;
|
||||||
|
nextVirtualExecutionId: number;
|
||||||
|
}
|
||||||
|
).exitedExecutionInfo.clear();
|
||||||
|
(
|
||||||
|
ExecutionLifecycleService as unknown as {
|
||||||
|
activeExecutions: Map<number, unknown>;
|
||||||
|
activeResolvers: Map<number, unknown>;
|
||||||
|
activeListeners: Map<number, unknown>;
|
||||||
|
exitedExecutionInfo: Map<number, unknown>;
|
||||||
|
nextVirtualExecutionId: number;
|
||||||
|
}
|
||||||
|
).nextVirtualExecutionId = BASE_VIRTUAL_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResult(
|
||||||
|
overrides: Partial<ExecutionResult> = {},
|
||||||
|
): ExecutionResult {
|
||||||
|
return {
|
||||||
|
rawOutput: Buffer.from(''),
|
||||||
|
output: '',
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
error: null,
|
||||||
|
aborted: false,
|
||||||
|
pid: 123,
|
||||||
|
executionMethod: 'child_process',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('ExecutionLifecycleService', () => {
|
describe('ExecutionLifecycleService', () => {
|
||||||
it('creates executions through ShellExecutionService virtual execution API', () => {
|
beforeEach(() => {
|
||||||
const onKill = vi.fn();
|
resetLifecycleState();
|
||||||
const handle = {
|
|
||||||
pid: 123,
|
|
||||||
result: Promise.resolve(createResult()),
|
|
||||||
};
|
|
||||||
const createSpy = vi
|
|
||||||
.spyOn(ShellExecutionService, 'createVirtualExecution')
|
|
||||||
.mockReturnValue(handle);
|
|
||||||
|
|
||||||
const created = ExecutionLifecycleService.createExecution('seed', onKill);
|
|
||||||
|
|
||||||
expect(createSpy).toHaveBeenCalledWith('seed', onKill);
|
|
||||||
expect(created).toBe(handle);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delegates append and completion to ShellExecutionService virtual APIs', () => {
|
it('completes virtual executions in the foreground and notifies exit subscribers', async () => {
|
||||||
const appendSpy = vi.spyOn(ShellExecutionService, 'appendVirtualOutput');
|
const handle = ExecutionLifecycleService.createExecution();
|
||||||
const completeSpy = vi.spyOn(
|
if (handle.pid === undefined) {
|
||||||
ShellExecutionService,
|
throw new Error('Expected virtual execution ID.');
|
||||||
'completeVirtualExecution',
|
}
|
||||||
);
|
|
||||||
const options: ExecutionCompletionOptions = {
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
ExecutionLifecycleService.appendOutput(123, 'delta');
|
|
||||||
ExecutionLifecycleService.completeExecution(123, options);
|
|
||||||
|
|
||||||
expect(appendSpy).toHaveBeenCalledWith(123, 'delta');
|
|
||||||
expect(completeSpy).toHaveBeenCalledWith(123, options);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates backgrounding, subscriptions, exit callbacks, and kill', () => {
|
|
||||||
const unsubscribe = vi.fn();
|
|
||||||
const backgroundSpy = vi
|
|
||||||
.spyOn(ShellExecutionService, 'background')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const subscribeSpy = vi
|
|
||||||
.spyOn(ShellExecutionService, 'subscribe')
|
|
||||||
.mockReturnValue(unsubscribe);
|
|
||||||
const onExitSpy = vi
|
|
||||||
.spyOn(ShellExecutionService, 'onExit')
|
|
||||||
.mockReturnValue(unsubscribe);
|
|
||||||
const killSpy = vi
|
|
||||||
.spyOn(ShellExecutionService, 'kill')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
const listener = vi.fn();
|
|
||||||
const onExit = vi.fn();
|
const onExit = vi.fn();
|
||||||
const returnedSub = ExecutionLifecycleService.subscribe(123, listener);
|
const unsubscribe = ExecutionLifecycleService.onExit(handle.pid, onExit);
|
||||||
const returnedExit = ExecutionLifecycleService.onExit(123, onExit);
|
|
||||||
ExecutionLifecycleService.background(123);
|
|
||||||
ExecutionLifecycleService.kill(123);
|
|
||||||
|
|
||||||
expect(subscribeSpy).toHaveBeenCalledWith(123, listener);
|
ExecutionLifecycleService.appendOutput(handle.pid, 'Hello');
|
||||||
expect(onExitSpy).toHaveBeenCalledWith(123, onExit);
|
ExecutionLifecycleService.appendOutput(handle.pid, ' World');
|
||||||
expect(backgroundSpy).toHaveBeenCalledWith(123);
|
ExecutionLifecycleService.completeExecution(handle.pid, { exitCode: 0 });
|
||||||
expect(killSpy).toHaveBeenCalledWith(123);
|
|
||||||
expect(returnedSub).toBe(unsubscribe);
|
const result = await handle.result;
|
||||||
expect(returnedExit).toBe(unsubscribe);
|
expect(result.output).toBe('Hello World');
|
||||||
|
expect(result.executionMethod).toBe('none');
|
||||||
|
expect(result.backgrounded).toBeUndefined();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delegates active checks and input writes', () => {
|
it('supports backgrounding virtual executions and continues streaming updates', async () => {
|
||||||
const isActiveSpy = vi
|
const handle = ExecutionLifecycleService.createExecution();
|
||||||
.spyOn(ShellExecutionService, 'isPtyActive')
|
if (handle.pid === undefined) {
|
||||||
.mockReturnValue(true);
|
throw new Error('Expected virtual execution ID.');
|
||||||
const writeSpy = vi
|
}
|
||||||
.spyOn(ShellExecutionService, 'writeToPty')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
|
|
||||||
const isActive = ExecutionLifecycleService.isActive(123);
|
const chunks: string[] = [];
|
||||||
ExecutionLifecycleService.writeInput(123, 'input');
|
const onExit = vi.fn();
|
||||||
|
|
||||||
expect(isActiveSpy).toHaveBeenCalledWith(123);
|
const unsubscribeStream = ExecutionLifecycleService.subscribe(
|
||||||
expect(writeSpy).toHaveBeenCalledWith(123, 'input');
|
handle.pid,
|
||||||
expect(isActive).toBe(true);
|
(event) => {
|
||||||
|
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||||
|
chunks.push(event.chunk);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const unsubscribeExit = ExecutionLifecycleService.onExit(handle.pid, onExit);
|
||||||
|
|
||||||
|
ExecutionLifecycleService.appendOutput(handle.pid, 'Chunk 1');
|
||||||
|
ExecutionLifecycleService.background(handle.pid);
|
||||||
|
|
||||||
|
const backgroundResult = await handle.result;
|
||||||
|
expect(backgroundResult.backgrounded).toBe(true);
|
||||||
|
expect(backgroundResult.output).toBe('Chunk 1');
|
||||||
|
|
||||||
|
ExecutionLifecycleService.appendOutput(handle.pid, '\nChunk 2');
|
||||||
|
ExecutionLifecycleService.completeExecution(handle.pid, { exitCode: 0 });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(chunks.join('')).toContain('Chunk 2');
|
||||||
|
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribeStream();
|
||||||
|
unsubscribeExit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills virtual executions and resolves with aborted result', async () => {
|
||||||
|
const onKill = vi.fn();
|
||||||
|
const handle = ExecutionLifecycleService.createExecution('', onKill);
|
||||||
|
if (handle.pid === undefined) {
|
||||||
|
throw new Error('Expected virtual execution ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecutionLifecycleService.appendOutput(handle.pid, 'work');
|
||||||
|
ExecutionLifecycleService.kill(handle.pid);
|
||||||
|
|
||||||
|
const result = await handle.result;
|
||||||
|
expect(onKill).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.aborted).toBe(true);
|
||||||
|
expect(result.exitCode).toBe(130);
|
||||||
|
expect(result.error?.message).toContain('Operation cancelled by user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manages external executions through registration hooks', async () => {
|
||||||
|
const writeInput = vi.fn();
|
||||||
|
const terminate = vi.fn();
|
||||||
|
const isActive = vi.fn().mockReturnValue(true);
|
||||||
|
const exitListener = vi.fn();
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
let output = 'seed';
|
||||||
|
const handle: ExecutionHandle = ExecutionLifecycleService.registerExecution(
|
||||||
|
4321,
|
||||||
|
{
|
||||||
|
executionMethod: 'child_process',
|
||||||
|
getBackgroundOutput: () => output,
|
||||||
|
getSubscriptionSnapshot: () => output,
|
||||||
|
writeInput,
|
||||||
|
kill: terminate,
|
||||||
|
isActive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribe = ExecutionLifecycleService.subscribe(4321, (event) => {
|
||||||
|
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||||
|
chunks.push(event.chunk);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ExecutionLifecycleService.onExit(4321, exitListener);
|
||||||
|
|
||||||
|
ExecutionLifecycleService.writeInput(4321, 'stdin');
|
||||||
|
expect(writeInput).toHaveBeenCalledWith('stdin');
|
||||||
|
expect(ExecutionLifecycleService.isActive(4321)).toBe(true);
|
||||||
|
|
||||||
|
const firstChunk = { type: 'data', chunk: ' +delta' } as const;
|
||||||
|
ExecutionLifecycleService.emitEvent(4321, firstChunk);
|
||||||
|
output += firstChunk.chunk;
|
||||||
|
|
||||||
|
ExecutionLifecycleService.background(4321);
|
||||||
|
const backgroundResult = await handle.result;
|
||||||
|
expect(backgroundResult.backgrounded).toBe(true);
|
||||||
|
expect(backgroundResult.output).toBe('seed +delta');
|
||||||
|
expect(backgroundResult.executionMethod).toBe('child_process');
|
||||||
|
|
||||||
|
ExecutionLifecycleService.finalizeExecution(
|
||||||
|
4321,
|
||||||
|
createResult({
|
||||||
|
pid: 4321,
|
||||||
|
output: 'seed +delta done',
|
||||||
|
rawOutput: Buffer.from('seed +delta done'),
|
||||||
|
executionMethod: 'child_process',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(exitListener).toHaveBeenCalledWith(0, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lateExit = vi.fn();
|
||||||
|
ExecutionLifecycleService.onExit(4321, lateExit);
|
||||||
|
expect(lateExit).toHaveBeenCalledWith(0, undefined);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
const killHandle = ExecutionLifecycleService.registerExecution(4322, {
|
||||||
|
executionMethod: 'child_process',
|
||||||
|
kill: terminate,
|
||||||
|
});
|
||||||
|
expect(killHandle.pid).toBe(4322);
|
||||||
|
ExecutionLifecycleService.kill(4322);
|
||||||
|
expect(terminate).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,48 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||||
ShellExecutionService,
|
|
||||||
type ShellExecutionHandle,
|
export type ExecutionMethod =
|
||||||
type ShellOutputEvent,
|
| 'lydell-node-pty'
|
||||||
} from './shellExecutionService.js';
|
| 'node-pty'
|
||||||
|
| 'child_process'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
rawOutput: Buffer;
|
||||||
|
output: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: number | null;
|
||||||
|
error: Error | null;
|
||||||
|
aborted: boolean;
|
||||||
|
pid: number | undefined;
|
||||||
|
executionMethod: ExecutionMethod;
|
||||||
|
backgrounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionHandle {
|
||||||
|
pid: number | undefined;
|
||||||
|
result: Promise<ExecutionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecutionOutputEvent =
|
||||||
|
| {
|
||||||
|
type: 'data';
|
||||||
|
chunk: string | AnsiOutput;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'binary_detected';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'binary_progress';
|
||||||
|
bytesReceived: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'exit';
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExecutionCompletionOptions {
|
export interface ExecutionCompletionOptions {
|
||||||
exitCode?: number | null;
|
exitCode?: number | null;
|
||||||
@@ -17,58 +54,347 @@ export interface ExecutionCompletionOptions {
|
|||||||
aborted?: boolean;
|
aborted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalExecutionRegistration {
|
||||||
|
executionMethod: ExecutionMethod;
|
||||||
|
initialOutput?: string;
|
||||||
|
getBackgroundOutput?: () => string;
|
||||||
|
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
|
||||||
|
writeInput?: (input: string) => void;
|
||||||
|
kill?: () => void;
|
||||||
|
isActive?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedExecutionState {
|
||||||
|
executionMethod: ExecutionMethod;
|
||||||
|
output: string;
|
||||||
|
isVirtual: boolean;
|
||||||
|
onKill?: () => void;
|
||||||
|
getBackgroundOutput?: () => string;
|
||||||
|
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
|
||||||
|
writeInput?: (input: string) => void;
|
||||||
|
kill?: () => void;
|
||||||
|
isActive?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic lifecycle facade for backgroundable executions.
|
* Central owner for execution backgrounding lifecycle across shell and tools.
|
||||||
*
|
|
||||||
* This wraps ShellExecutionService so non-shell executors (remote/local agents)
|
|
||||||
* can use neutral lifecycle naming without duplicating process-management logic.
|
|
||||||
*/
|
*/
|
||||||
export class ExecutionLifecycleService {
|
export class ExecutionLifecycleService {
|
||||||
|
private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000;
|
||||||
|
private static nextVirtualExecutionId = 2_000_000_000;
|
||||||
|
|
||||||
|
private static activeExecutions = new Map<number, ManagedExecutionState>();
|
||||||
|
private static activeResolvers = new Map<
|
||||||
|
number,
|
||||||
|
(result: ExecutionResult) => void
|
||||||
|
>();
|
||||||
|
private static activeListeners = new Map<
|
||||||
|
number,
|
||||||
|
Set<(event: ExecutionOutputEvent) => void>
|
||||||
|
>();
|
||||||
|
private static exitedExecutionInfo = new Map<
|
||||||
|
number,
|
||||||
|
{ exitCode: number; signal?: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private static storeExitInfo(
|
||||||
|
executionId: number,
|
||||||
|
exitCode: number,
|
||||||
|
signal?: number,
|
||||||
|
): void {
|
||||||
|
this.exitedExecutionInfo.set(executionId, {
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
this.exitedExecutionInfo.delete(executionId);
|
||||||
|
}, this.EXIT_INFO_TTL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static allocateVirtualExecutionId(): number {
|
||||||
|
let executionId = ++this.nextVirtualExecutionId;
|
||||||
|
while (this.activeExecutions.has(executionId)) {
|
||||||
|
executionId = ++this.nextVirtualExecutionId;
|
||||||
|
}
|
||||||
|
return executionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createPendingResult(executionId: number): Promise<ExecutionResult> {
|
||||||
|
return new Promise<ExecutionResult>((resolve) => {
|
||||||
|
this.activeResolvers.set(executionId, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static registerExecution(
|
||||||
|
executionId: number,
|
||||||
|
registration: ExternalExecutionRegistration,
|
||||||
|
): ExecutionHandle {
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
executionMethod: registration.executionMethod,
|
||||||
|
output: registration.initialOutput ?? '',
|
||||||
|
isVirtual: false,
|
||||||
|
getBackgroundOutput: registration.getBackgroundOutput,
|
||||||
|
getSubscriptionSnapshot: registration.getSubscriptionSnapshot,
|
||||||
|
writeInput: registration.writeInput,
|
||||||
|
kill: registration.kill,
|
||||||
|
isActive: registration.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pid: executionId,
|
||||||
|
result: this.createPendingResult(executionId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static createExecution(
|
static createExecution(
|
||||||
initialOutput = '',
|
initialOutput = '',
|
||||||
onKill?: () => void,
|
onKill?: () => void,
|
||||||
): ShellExecutionHandle {
|
): ExecutionHandle {
|
||||||
return ShellExecutionService.createVirtualExecution(initialOutput, onKill);
|
const executionId = this.allocateVirtualExecutionId();
|
||||||
|
|
||||||
|
this.activeExecutions.set(executionId, {
|
||||||
|
executionMethod: 'none',
|
||||||
|
output: initialOutput,
|
||||||
|
isVirtual: true,
|
||||||
|
onKill,
|
||||||
|
getBackgroundOutput: () => {
|
||||||
|
const state = this.activeExecutions.get(executionId);
|
||||||
|
return state?.output ?? initialOutput;
|
||||||
|
},
|
||||||
|
getSubscriptionSnapshot: () => {
|
||||||
|
const state = this.activeExecutions.get(executionId);
|
||||||
|
return state?.output ?? initialOutput;
|
||||||
|
},
|
||||||
|
isActive: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
pid: executionId,
|
||||||
|
result: this.createPendingResult(executionId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static appendOutput(executionId: number, chunk: string): void {
|
static appendOutput(executionId: number, chunk: string): void {
|
||||||
ShellExecutionService.appendVirtualOutput(executionId, chunk);
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (!execution || chunk.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
execution.output += chunk;
|
||||||
|
this.emitEvent(executionId, { type: 'data', chunk });
|
||||||
|
}
|
||||||
|
|
||||||
|
static emitEvent(executionId: number, event: ExecutionOutputEvent): void {
|
||||||
|
const listeners = this.activeListeners.get(executionId);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static resolvePending(
|
||||||
|
executionId: number,
|
||||||
|
result: ExecutionResult,
|
||||||
|
): void {
|
||||||
|
const resolve = this.activeResolvers.get(executionId);
|
||||||
|
if (!resolve) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result);
|
||||||
|
this.activeResolvers.delete(executionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static completeExecution(
|
static completeExecution(
|
||||||
executionId: number,
|
executionId: number,
|
||||||
options?: ExecutionCompletionOptions,
|
options?: ExecutionCompletionOptions,
|
||||||
): void {
|
): void {
|
||||||
ShellExecutionService.completeVirtualExecution(executionId, options);
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (!execution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
error = null,
|
||||||
|
aborted = false,
|
||||||
|
exitCode = error ? 1 : 0,
|
||||||
|
signal = null,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
|
const output = execution.getBackgroundOutput?.() ?? execution.output;
|
||||||
|
|
||||||
|
this.resolvePending(executionId, {
|
||||||
|
rawOutput: Buffer.from(output, 'utf8'),
|
||||||
|
output,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
error,
|
||||||
|
aborted,
|
||||||
|
pid: executionId,
|
||||||
|
executionMethod: execution.executionMethod,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emitEvent(executionId, {
|
||||||
|
type: 'exit',
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeListeners.delete(executionId);
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
this.storeExitInfo(executionId, exitCode ?? 0, signal ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
static finalizeExecution(
|
||||||
|
executionId: number,
|
||||||
|
result: ExecutionResult,
|
||||||
|
): void {
|
||||||
|
this.resolvePending(executionId, result);
|
||||||
|
|
||||||
|
this.emitEvent(executionId, {
|
||||||
|
type: 'exit',
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
signal: result.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeListeners.delete(executionId);
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
|
this.storeExitInfo(
|
||||||
|
executionId,
|
||||||
|
result.exitCode ?? 0,
|
||||||
|
result.signal ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static background(executionId: number): void {
|
static background(executionId: number): void {
|
||||||
ShellExecutionService.background(executionId);
|
const resolve = this.activeResolvers.get(executionId);
|
||||||
|
if (!resolve) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (!execution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = execution.getBackgroundOutput?.() ?? execution.output;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
rawOutput: Buffer.from(''),
|
||||||
|
output,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
error: null,
|
||||||
|
aborted: false,
|
||||||
|
pid: executionId,
|
||||||
|
executionMethod: execution.executionMethod,
|
||||||
|
backgrounded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeResolvers.delete(executionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static subscribe(
|
static subscribe(
|
||||||
executionId: number,
|
executionId: number,
|
||||||
listener: (event: ShellOutputEvent) => void,
|
listener: (event: ExecutionOutputEvent) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
return ShellExecutionService.subscribe(executionId, listener);
|
if (!this.activeListeners.has(executionId)) {
|
||||||
|
this.activeListeners.set(executionId, new Set());
|
||||||
|
}
|
||||||
|
this.activeListeners.get(executionId)?.add(listener);
|
||||||
|
|
||||||
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (execution) {
|
||||||
|
const snapshot =
|
||||||
|
execution.getSubscriptionSnapshot?.() ??
|
||||||
|
(execution.output.length > 0 ? execution.output : undefined);
|
||||||
|
if (snapshot && (typeof snapshot !== 'string' || snapshot.length > 0)) {
|
||||||
|
listener({ type: 'data', chunk: snapshot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.activeListeners.get(executionId)?.delete(listener);
|
||||||
|
if (this.activeListeners.get(executionId)?.size === 0) {
|
||||||
|
this.activeListeners.delete(executionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static onExit(
|
static onExit(
|
||||||
executionId: number,
|
executionId: number,
|
||||||
callback: (exitCode: number, signal?: number) => void,
|
callback: (exitCode: number, signal?: number) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
return ShellExecutionService.onExit(executionId, callback);
|
if (this.activeExecutions.has(executionId)) {
|
||||||
|
const listener = (event: ExecutionOutputEvent) => {
|
||||||
|
if (event.type === 'exit') {
|
||||||
|
callback(event.exitCode ?? 0, event.signal ?? undefined);
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribe = this.subscribe(executionId, listener);
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitedInfo = this.exitedExecutionInfo.get(executionId);
|
||||||
|
if (exitedInfo) {
|
||||||
|
callback(exitedInfo.exitCode, exitedInfo.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static kill(executionId: number): void {
|
static kill(executionId: number): void {
|
||||||
ShellExecutionService.kill(executionId);
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (!execution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution.isVirtual) {
|
||||||
|
execution.onKill?.();
|
||||||
|
this.completeExecution(executionId, {
|
||||||
|
error: new Error('Operation cancelled by user.'),
|
||||||
|
aborted: true,
|
||||||
|
exitCode: 130,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
execution.kill?.();
|
||||||
|
this.activeResolvers.delete(executionId);
|
||||||
|
this.activeListeners.delete(executionId);
|
||||||
|
this.activeExecutions.delete(executionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isActive(executionId: number): boolean {
|
static isActive(executionId: number): boolean {
|
||||||
return ShellExecutionService.isPtyActive(executionId);
|
const execution = this.activeExecutions.get(executionId);
|
||||||
|
if (!execution) {
|
||||||
|
try {
|
||||||
|
return process.kill(executionId, 0);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution.isVirtual) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (execution.isActive) {
|
||||||
|
try {
|
||||||
|
return execution.isActive();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return process.kill(executionId, 0);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static writeInput(executionId: number, input: string): void {
|
static writeInput(executionId: number, input: string): void {
|
||||||
ShellExecutionService.writeToPty(executionId, input);
|
this.activeExecutions.get(executionId)?.writeInput?.(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1641,85 +1641,3 @@ describe('ShellExecutionService environment variables', () => {
|
|||||||
await new Promise(process.nextTick);
|
await new Promise(process.nextTick);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ShellExecutionService virtual executions', () => {
|
|
||||||
it('completes a virtual execution in the foreground', async () => {
|
|
||||||
const { pid, result } = ShellExecutionService.createVirtualExecution();
|
|
||||||
if (pid === undefined) {
|
|
||||||
throw new Error('Expected virtual pid to be defined.');
|
|
||||||
}
|
|
||||||
const onExit = vi.fn();
|
|
||||||
const unsubscribe = ShellExecutionService.onExit(pid, onExit);
|
|
||||||
|
|
||||||
ShellExecutionService.appendVirtualOutput(pid, 'Hello');
|
|
||||||
ShellExecutionService.appendVirtualOutput(pid, ' World');
|
|
||||||
ShellExecutionService.completeVirtualExecution(pid, { exitCode: 0 });
|
|
||||||
|
|
||||||
const executionResult = await result;
|
|
||||||
|
|
||||||
expect(executionResult.output).toBe('Hello World');
|
|
||||||
expect(executionResult.backgrounded).toBeUndefined();
|
|
||||||
expect(executionResult.exitCode).toBe(0);
|
|
||||||
expect(executionResult.error).toBeNull();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports backgrounding virtual executions and streaming additional output', async () => {
|
|
||||||
const { pid, result } = ShellExecutionService.createVirtualExecution();
|
|
||||||
if (pid === undefined) {
|
|
||||||
throw new Error('Expected virtual pid to be defined.');
|
|
||||||
}
|
|
||||||
const chunks: string[] = [];
|
|
||||||
const onExit = vi.fn();
|
|
||||||
|
|
||||||
const unsubscribeStream = ShellExecutionService.subscribe(pid, (event) => {
|
|
||||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
|
||||||
chunks.push(event.chunk);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const unsubscribeExit = ShellExecutionService.onExit(pid, onExit);
|
|
||||||
|
|
||||||
ShellExecutionService.appendVirtualOutput(pid, 'Chunk 1');
|
|
||||||
ShellExecutionService.background(pid);
|
|
||||||
|
|
||||||
const backgroundResult = await result;
|
|
||||||
expect(backgroundResult.backgrounded).toBe(true);
|
|
||||||
expect(backgroundResult.output).toBe('Chunk 1');
|
|
||||||
|
|
||||||
ShellExecutionService.appendVirtualOutput(pid, '\nChunk 2');
|
|
||||||
ShellExecutionService.completeVirtualExecution(pid, { exitCode: 0 });
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(chunks.join('')).toContain('Chunk 2');
|
|
||||||
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribeStream();
|
|
||||||
unsubscribeExit();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('kills virtual executions via the existing kill API', async () => {
|
|
||||||
const onKill = vi.fn();
|
|
||||||
const { pid, result } = ShellExecutionService.createVirtualExecution(
|
|
||||||
'',
|
|
||||||
onKill,
|
|
||||||
);
|
|
||||||
if (pid === undefined) {
|
|
||||||
throw new Error('Expected virtual pid to be defined.');
|
|
||||||
}
|
|
||||||
|
|
||||||
ShellExecutionService.appendVirtualOutput(pid, 'work');
|
|
||||||
ShellExecutionService.kill(pid);
|
|
||||||
|
|
||||||
const killResult = await result;
|
|
||||||
expect(onKill).toHaveBeenCalledTimes(1);
|
|
||||||
expect(killResult.aborted).toBe(true);
|
|
||||||
expect(killResult.exitCode).toBe(130);
|
|
||||||
expect(killResult.error?.message).toContain('Operation cancelled by user');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user