Compare commits

...

13 Commits

Author SHA1 Message Date
xuewenjie
98c043bf50 test: update tests for detached process changes 2025-12-29 11:37:54 +08:00
cris
f610133660 improve ad hoc method for windows background terminal task 2025-12-28 22:14:16 +08:00
xuewenjie
5417de4219 Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2025-12-25 16:44:30 +08:00
xuewenjie
8673426d5c fix(core): use current chunk for shell output update instead of cumulative 2025-12-16 10:26:20 +08:00
xuewenjie
b272ac0119 Fix: Make cleanup strategy dynamic to support testing mocks 2025-12-12 17:47:03 +08:00
xuewenjie
574d89da14 Refactor ShellExecutionService cleanup to use strategy pattern 2025-12-12 17:03:04 +08:00
xuewenjie
16939c0bc8 Refactor ShellTool: remove ping hack and timeout, optimize cleanup 2025-12-10 13:49:51 +08:00
xuewenjie
6fc09a82fb fix: use && for windows background keep-alive ping and add test 2025-12-09 13:33:42 +08:00
xuewenjie
d622f8d1bf Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-background-terminal-execute-x 2025-12-09 11:32:17 +08:00
xuewenjie
28d178b5c1 fix: handle windows background execution errors and add tests 2025-12-09 11:24:30 +08:00
xuewenjie
4c69d536ac test: fix shell tool tests by updating pid expectation and AbortSignal matching 2025-12-05 10:47:06 +08:00
xuewenjie
403fd06117 chore: update .gitignore 2025-12-04 15:55:17 +08:00
xuewenjie
d9928eab66 fix: improve windows background process handling and cleanup 2025-12-04 15:55:11 +08:00
4 changed files with 184 additions and 28 deletions

View File

@@ -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',
@@ -829,7 +829,7 @@ describe('ShellExecutionService child_process fallback', () => {
[],
expect.objectContaining({
shell: true,
detached: false,
detached: true,
}),
);
});

View File

@@ -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';
@@ -98,6 +98,48 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
return lines.join('\n').trimEnd();
};
interface ProcessCleanupStrategy {
killPty(pid: number, pty: ActivePty): void;
killChildProcesses(pids: Set<number>): void;
}
const windowsStrategy: ProcessCleanupStrategy = {
killPty: (_pid, pty) => {
pty.ptyProcess.kill();
},
killChildProcesses: (pids) => {
if (pids.size > 0) {
try {
const args = ['/f', '/t'];
for (const pid of pids) {
args.push('/pid', pid.toString());
}
spawnSync('taskkill', args);
} catch {
// ignore
}
}
},
};
const posixStrategy: ProcessCleanupStrategy = {
killPty: (pid, _pty) => {
process.kill(-pid, 'SIGKILL');
},
killChildProcesses: (pids) => {
for (const pid of pids) {
try {
process.kill(-pid, 'SIGKILL');
} catch {
// ignore
}
}
},
};
const getCleanupStrategy = () =>
os.platform() === 'win32' ? windowsStrategy : posixStrategy;
/**
* A centralized service for executing shell commands with robust process
* management, cross-platform compatibility, and streaming output capabilities.
@@ -106,6 +148,29 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Set<number>();
static cleanup() {
const strategy = getCleanupStrategy();
// Cleanup PTYs
for (const [pid, pty] of this.activePtys) {
try {
strategy.killPty(pid, pty);
} catch {
// ignore
}
}
// Cleanup child processes
strategy.killChildProcesses(this.activeChildProcesses);
}
static {
process.on('exit', () => {
ShellExecutionService.cleanup();
});
}
/**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
@@ -164,7 +229,7 @@ export class ShellExecutionService {
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: !isWindows,
detached: true,
env: {
...process.env,
QWEN_CODE: '1',
@@ -281,9 +346,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);
});
@@ -310,7 +379,7 @@ export class ShellExecutionService {
}
});
return { pid: undefined, result };
return { pid: child.pid, result };
} catch (e) {
const error = e as Error;
return {

View File

@@ -210,7 +210,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -237,7 +237,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -262,7 +262,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -287,7 +287,7 @@ describe('ShellTool', () => {
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -312,7 +312,7 @@ describe('ShellTool', () => {
wrappedCommand,
'/test/dir/subdir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -340,7 +340,7 @@ describe('ShellTool', () => {
'dir',
'/test/dir',
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -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');
@@ -542,7 +542,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -572,7 +572,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -602,7 +602,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -661,7 +661,7 @@ describe('ShellTool', () => {
expect.stringContaining('npm install'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -690,7 +690,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -720,7 +720,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -756,7 +756,7 @@ describe('ShellTool', () => {
expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -793,7 +793,7 @@ describe('ShellTool', () => {
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
expect.any(AbortSignal),
false,
{},
);
@@ -924,4 +924,41 @@ spanning multiple lines"`;
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,
{},
);
});
});
});

View File

@@ -143,11 +143,24 @@ export class ShellToolInvocation extends BaseToolInvocation<
const shouldRunInBackground = this.params.is_background;
let finalCommand = processedCommand;
// If explicitly marked as background and doesn't already end with &, add it
if (shouldRunInBackground && !finalCommand.trim().endsWith('&')) {
// On non-Windows, use & to run in background.
// On Windows, we don't use start /B because it creates a detached process that
// doesn't die when the parent dies. Instead, we rely on the race logic below
// to return early while keeping the process attached (detached: false).
if (
!isWindows &&
shouldRunInBackground &&
!finalCommand.trim().endsWith('&')
) {
finalCommand = finalCommand.trim() + ' &';
}
// On Windows, we rely on the race logic below to handle background tasks.
// We just ensure the command string is clean.
if (isWindows && shouldRunInBackground) {
finalCommand = finalCommand.trim().replace(/&+$/, '').trim();
}
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = isWindows
? finalCommand
@@ -169,10 +182,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
commandToExecute,
cwd,
(event: ShellOutputEvent) => {
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
@@ -201,7 +210,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
if (shouldUpdate) {
if (shouldUpdate && updateOutput) {
updateOutput(
typeof cumulativeOutput === 'string'
? cumulativeOutput
@@ -219,6 +228,47 @@ export class ShellToolInvocation extends BaseToolInvocation<
setPidCallback(pid);
}
if (shouldRunInBackground) {
// Check for obvious startup errors from captured output
const outputStr =
typeof cumulativeOutput === 'string'
? cumulativeOutput
: JSON.stringify(cumulativeOutput);
const errorPatterns = [
'is not recognized as an internal or external command',
'The system cannot find the path specified',
'Access is denied',
'command not found',
'No such file or directory',
'Permission denied',
];
const hasEarlyError = errorPatterns.some((pat) =>
outputStr.includes(pat),
);
if (hasEarlyError) {
return {
llmContent: `Command failed to start: ${outputStr}`,
returnDisplay: `Command failed to start: ${outputStr}`,
error: {
type: ToolErrorType.EXECUTION_FAILED,
message: `Command failed to start: ${outputStr}`,
},
};
}
const pidMsg = pid ? ` PID: ${pid}` : '';
const winHint = isWindows
? ' (Note: Use taskkill /F /T /PID <pid> to stop)'
: '';
return {
llmContent: `Background command started.${pidMsg}${winHint}`,
returnDisplay: `Background command started.${pidMsg}${winHint}`,
};
}
const result = await resultPromise;
const backgroundPIDs: number[] = [];