Make execution lifecycle the owner of background state

This commit is contained in:
Adam Weidman
2026-03-08 18:02:25 -04:00
parent 6e291cfab8
commit e9edd60615
5 changed files with 1058 additions and 971 deletions
+9 -7
View File
@@ -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