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

View File

@@ -94,7 +94,7 @@ type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
};
interface ShellToolData {
interface BackgroundExecutionData {
pid?: number;
command?: string;
initialOutput?: string;
@@ -111,11 +111,13 @@ const SUPPRESSED_TOOL_ERRORS_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.';
function isShellToolData(data: unknown): data is ShellToolData {
function isBackgroundExecutionData(
data: unknown,
): data is BackgroundExecutionData {
if (typeof data !== 'object' || data === null) {
return false;
}
const d = data as Partial<ShellToolData>;
const d = data as Partial<BackgroundExecutionData>;
return (
(d.pid === undefined || typeof d.pid === 'number') &&
(d.command === undefined || typeof d.command === 'string') &&
@@ -311,7 +313,7 @@ export const useGeminiStream = (
getPreferredEditor,
);
const activeToolPtyId = useMemo(() => {
const activeToolExecutionId = useMemo(() => {
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
@@ -347,7 +349,7 @@ export const useGeminiStream = (
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
activeToolExecutionId,
);
const streamingState = useMemo(
@@ -525,7 +527,7 @@ export const useGeminiStream = (
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const activePtyId = activeShellPtyId || activeToolPtyId;
const activePtyId = activeShellPtyId || activeToolExecutionId;
const prevActiveShellPtyIdRef = useRef<number | null>(null);
useEffect(() => {
@@ -1657,7 +1659,7 @@ export const useGeminiStream = (
// Access result from the tracked tool call response
const response = t.response as ToolResponseWithParts;
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.
const pid = data?.pid;

View File

@@ -4,105 +4,231 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import {
ShellExecutionService,
type ShellExecutionResult,
} from './shellExecutionService.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ExecutionLifecycleService,
type ExecutionCompletionOptions,
type ExecutionHandle,
type ExecutionResult,
} from './executionLifecycleService.js';
const createResult = (): ShellExecutionResult => ({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 123,
executionMethod: 'none',
});
const BASE_VIRTUAL_ID = 2_000_000_000;
function resetLifecycleState() {
(
ExecutionLifecycleService as unknown as {
activeExecutions: Map<number, unknown>;
activeResolvers: Map<number, unknown>;
activeListeners: Map<number, unknown>;
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', () => {
it('creates executions through ShellExecutionService virtual execution API', () => {
const onKill = vi.fn();
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);
beforeEach(() => {
resetLifecycleState();
});
it('delegates append and completion to ShellExecutionService virtual APIs', () => {
const appendSpy = vi.spyOn(ShellExecutionService, 'appendVirtualOutput');
const completeSpy = vi.spyOn(
ShellExecutionService,
'completeVirtualExecution',
);
const options: ExecutionCompletionOptions = {
exitCode: 0,
signal: null,
};
it('completes virtual executions in the foreground and notifies exit subscribers', async () => {
const handle = ExecutionLifecycleService.createExecution();
if (handle.pid === undefined) {
throw new Error('Expected virtual execution ID.');
}
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 returnedSub = ExecutionLifecycleService.subscribe(123, listener);
const returnedExit = ExecutionLifecycleService.onExit(123, onExit);
ExecutionLifecycleService.background(123);
ExecutionLifecycleService.kill(123);
const unsubscribe = ExecutionLifecycleService.onExit(handle.pid, onExit);
expect(subscribeSpy).toHaveBeenCalledWith(123, listener);
expect(onExitSpy).toHaveBeenCalledWith(123, onExit);
expect(backgroundSpy).toHaveBeenCalledWith(123);
expect(killSpy).toHaveBeenCalledWith(123);
expect(returnedSub).toBe(unsubscribe);
expect(returnedExit).toBe(unsubscribe);
ExecutionLifecycleService.appendOutput(handle.pid, 'Hello');
ExecutionLifecycleService.appendOutput(handle.pid, ' World');
ExecutionLifecycleService.completeExecution(handle.pid, { exitCode: 0 });
const result = await handle.result;
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', () => {
const isActiveSpy = vi
.spyOn(ShellExecutionService, 'isPtyActive')
.mockReturnValue(true);
const writeSpy = vi
.spyOn(ShellExecutionService, 'writeToPty')
.mockImplementation(() => {});
it('supports backgrounding virtual executions and continues streaming updates', async () => {
const handle = ExecutionLifecycleService.createExecution();
if (handle.pid === undefined) {
throw new Error('Expected virtual execution ID.');
}
const isActive = ExecutionLifecycleService.isActive(123);
ExecutionLifecycleService.writeInput(123, 'input');
const chunks: string[] = [];
const onExit = vi.fn();
expect(isActiveSpy).toHaveBeenCalledWith(123);
expect(writeSpy).toHaveBeenCalledWith(123, 'input');
expect(isActive).toBe(true);
const unsubscribeStream = ExecutionLifecycleService.subscribe(
handle.pid,
(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);
});
});

View File

@@ -4,11 +4,48 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
ShellExecutionService,
type ShellExecutionHandle,
type ShellOutputEvent,
} from './shellExecutionService.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
export type ExecutionMethod =
| 'lydell-node-pty'
| '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 {
exitCode?: number | null;
@@ -17,58 +54,347 @@ export interface ExecutionCompletionOptions {
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.
*
* This wraps ShellExecutionService so non-shell executors (remote/local agents)
* can use neutral lifecycle naming without duplicating process-management logic.
* Central owner for execution backgrounding lifecycle across shell and tools.
*/
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(
initialOutput = '',
onKill?: () => void,
): ShellExecutionHandle {
return ShellExecutionService.createVirtualExecution(initialOutput, onKill);
): ExecutionHandle {
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 {
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(
executionId: number,
options?: ExecutionCompletionOptions,
): 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 {
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(
executionId: number,
listener: (event: ShellOutputEvent) => void,
listener: (event: ExecutionOutputEvent) => 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(
executionId: number,
callback: (exitCode: number, signal?: number) => 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 {
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 {
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 {
ShellExecutionService.writeToPty(executionId, input);
this.activeExecutions.get(executionId)?.writeInput?.(input);
}
}

View File

@@ -1641,85 +1641,3 @@ describe('ShellExecutionService environment variables', () => {
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