Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/services/shellExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.output).toBe('file1.txt\na warning');
expect(handle.pid).toBe(undefined);
expect(handle.pid).toBe(12345);

expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
Expand Down
56 changes: 53 additions & 3 deletions packages/core/src/services/shellExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import stripAnsi from 'strip-ansi';
import type { PtyImplementation } from '../utils/getPty.js';
import { getPty } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'node:child_process';
import { spawn as cpSpawn, spawnSync } from 'node:child_process';
import { TextDecoder } from 'node:util';
import os from 'node:os';
import type { IPty } from '@lydell/node-pty';
Expand Down Expand Up @@ -106,6 +106,52 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {

export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Set<number>();

static cleanup() {
// Cleanup PTYs
for (const [pid, pty] of this.activePtys) {
try {
if (os.platform() === 'win32') {
pty.ptyProcess.kill();
} else {
process.kill(-pid, 'SIGKILL');
}
} catch {
// ignore
}
}

// Cleanup child processes
if (os.platform() === 'win32') {
if (this.activeChildProcesses.size > 0) {
try {
const args = ['/f', '/t'];
for (const pid of this.activeChildProcesses) {
args.push('/pid', pid.toString());
}
spawnSync('taskkill', args);
} catch {
// ignore
}
}
} else {
for (const pid of this.activeChildProcesses) {
try {
process.kill(-pid, 'SIGKILL');
} catch {
// ignore
}
}
}
}

static {
process.on('exit', () => {
ShellExecutionService.cleanup();
});
}

/**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
Expand Down Expand Up @@ -281,9 +327,13 @@ export class ShellExecutionService {

abortSignal.addEventListener('abort', abortHandler, { once: true });

if (child.pid) {
this.activeChildProcesses.add(child.pid);
}

child.on('exit', (code, signal) => {
if (child.pid) {
this.activePtys.delete(child.pid);
this.activeChildProcesses.delete(child.pid);
}
handleExit(code, signal);
});
Expand All @@ -310,7 +360,7 @@ export class ShellExecutionService {
}
});

return { pid: undefined, result };
return { pid: child.pid, result };
} catch (e) {
const error = e as Error;
return {
Expand Down
94 changes: 79 additions & 15 deletions packages/core/src/tools/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand All @@ -237,7 +237,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand All @@ -262,7 +262,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand All @@ -287,7 +287,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand All @@ -312,7 +312,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir/subdir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -340,7 +340,7 @@ describe('ShellTool', () => {
'dir',
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -433,7 +433,7 @@ describe('ShellTool', () => {
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
expect.any(String),
mockConfig.getGeminiClient(),
mockAbortSignal,
expect.any(AbortSignal),
1000,
);
expect(result.llmContent).toBe('summarized output');
Expand Down Expand Up @@ -542,7 +542,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -572,7 +572,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -602,7 +602,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -631,7 +631,7 @@ describe('ShellTool', () => {
expect.stringContaining('npm install'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -660,7 +660,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -690,7 +690,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -726,7 +726,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -763,7 +763,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
Expand Down Expand Up @@ -831,4 +831,68 @@ describe('ShellTool', () => {
expect(shellTool.description).toMatchSnapshot();
});
});

describe('Windows background execution', () => {
it('should clean up trailing ampersand on Windows for background tasks', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const mockAbortSignal = new AbortController().signal;

const invocation = shellTool.build({
command: 'npm start &',
is_background: true,
});

const promise = invocation.execute(mockAbortSignal);

// Simulate immediate success (process started)
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
});

await promise;

expect(mockShellExecutionService).toHaveBeenCalledWith(
'npm start',
expect.any(String),
expect.any(Function),
expect.any(AbortSignal),
false,
{},
);
});

it('should detect immediate failure in Windows background task', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const mockAbortSignal = new AbortController().signal;

const invocation = shellTool.build({
command: 'invalid_command',
is_background: true,
});

const promise = invocation.execute(mockAbortSignal);

// Wait a tick to ensure mockShellOutputCallback is assigned
await new Promise((resolve) => setTimeout(resolve, 0));

if (mockShellOutputCallback) {
mockShellOutputCallback({
type: 'data',
chunk:
"'invalid_command' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n",
});
}

const result = await promise;
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('Command failed to start');
});
});
});
Loading