mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(sdk): add FORK_MODE support for Electron integration
- Rename USE_FORK_FOR_ELECTRON to FORK_MODE - Add fork mode in ProcessTransport for Electron IPC support - Add comprehensive unit tests for fork mode Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
ce7506d658
commit
2d75d82ec8
2 changed files with 314 additions and 11 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { spawn, fork, type ChildProcess } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Writable, Readable } from 'node:stream';
|
||||
import type { TransportOptions } from '../types/types.js';
|
||||
|
|
@ -52,20 +52,67 @@ export class ProcessTransport implements Transport {
|
|||
const stderrMode =
|
||||
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||
|
||||
logger.debug(
|
||||
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
|
||||
);
|
||||
// Check if we should use fork for Electron integration
|
||||
const useFork = env.FORK_MODE === '1';
|
||||
console.log('useFork', useFork);
|
||||
|
||||
this.childProcess = spawn(
|
||||
spawnInfo.command,
|
||||
[...spawnInfo.args, ...cliArgs],
|
||||
{
|
||||
if (useFork) {
|
||||
// Detect Electron environment
|
||||
const isElectron =
|
||||
typeof process !== 'undefined' &&
|
||||
process.versions &&
|
||||
!!process.versions.electron;
|
||||
|
||||
// In Electron, process.execPath points to Electron, not Node.js
|
||||
// When spawnInfo uses process.execPath to run a JS file, we need to handle it specially
|
||||
const isUsingExecPathForJs =
|
||||
spawnInfo.args.length > 0 &&
|
||||
(spawnInfo.args[0]?.endsWith('.js') ||
|
||||
spawnInfo.args[0]?.endsWith('.mjs') ||
|
||||
spawnInfo.args[0]?.endsWith('.cjs'));
|
||||
|
||||
let forkModulePath: string;
|
||||
let forkArgs: string[];
|
||||
|
||||
if (isElectron && isUsingExecPathForJs) {
|
||||
// In Electron with JS file: use the JS file as module path, rest as args
|
||||
forkModulePath = spawnInfo.args[0] ?? '';
|
||||
forkArgs = [...spawnInfo.args.slice(1), ...cliArgs];
|
||||
} else {
|
||||
// Normal case: use command as module path
|
||||
forkModulePath = spawnInfo.command;
|
||||
forkArgs = [...spawnInfo.args, ...cliArgs];
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Forking CLI (${spawnInfo.type}): ${forkModulePath} ${forkArgs.join(' ')}`,
|
||||
);
|
||||
|
||||
this.childProcess = fork(forkModulePath, forkArgs, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', stderrMode],
|
||||
stdio:
|
||||
stderrMode === 'pipe'
|
||||
? ['pipe', 'pipe', 'pipe', 'ipc']
|
||||
: ['pipe', 'pipe', 'ignore', 'ipc'],
|
||||
signal: this.abortController.signal,
|
||||
},
|
||||
);
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
|
||||
);
|
||||
|
||||
this.childProcess = spawn(
|
||||
spawnInfo.command,
|
||||
[...spawnInfo.args, ...cliArgs],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', stderrMode],
|
||||
signal: this.abortController.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.childStdin = this.childProcess.stdin;
|
||||
this.childStdout = this.childProcess.stdout;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock('../../src/utils/cliPath.js');
|
|||
vi.mock('../../src/utils/jsonLines.js');
|
||||
|
||||
const mockSpawn = vi.mocked(childProcess.spawn);
|
||||
const mockFork = vi.mocked(childProcess.fork);
|
||||
const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo);
|
||||
const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream);
|
||||
|
||||
|
|
@ -75,6 +76,10 @@ describe('ProcessTransport', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clean up environment variables for FORK_MODE tests
|
||||
delete process.env.FORK_MODE;
|
||||
delete (process.versions as { electron?: string }).electron;
|
||||
|
||||
const mockWriteFn = vi.fn((chunk, encoding, callback) => {
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
|
|
@ -1383,4 +1388,255 @@ describe('ProcessTransport', () => {
|
|||
expect(transport.getOutputStream()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fork Mode', () => {
|
||||
it('should use fork when FORK_MODE=1', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use spawn when FORK_MODE is not set', () => {
|
||||
// process.env.FORK_MODE is not set
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockSpawn.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass correct modulePath to fork', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'node',
|
||||
args: ['/path/to/cli.js'],
|
||||
type: 'node',
|
||||
originalInput: 'node /path/to/cli.js',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
// In non-Electron environment, command is used as modulePath
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
'node', // modulePath is the command in non-Electron environment
|
||||
expect.arrayContaining(['/path/to/cli.js']),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure stdio with ipc channel for fork', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
stdio: ['pipe', 'pipe', 'ignore', 'ipc'], // 4th element is ipc
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure stdio with pipe for stderr when debug mode is on', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
debug: true,
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'], // stderr is also pipe
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Electron environment with JS file execution', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
(process.versions as { electron?: string }).electron = '28.0.0';
|
||||
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: '/path/to/Electron.app/Contents/MacOS/Electron',
|
||||
args: ['/path/to/cli.js', '--some-arg'],
|
||||
type: 'node',
|
||||
originalInput: 'electron /path/to/cli.js',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
// In Electron environment, should extract cli.js as modulePath
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
'/path/to/cli.js',
|
||||
expect.arrayContaining(['--some-arg']),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle normal JS execution in non-Electron environment', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
// process.versions.electron is not set
|
||||
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'node',
|
||||
args: ['/path/to/cli.js', '--some-arg'],
|
||||
type: 'node',
|
||||
originalInput: 'node /path/to/cli.js',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
// In normal Node.js, command is used as modulePath, not cli.js
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
'node',
|
||||
expect.arrayContaining(['/path/to/cli.js', '--some-arg']),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass env to fork', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
env: { CUSTOM_VAR: 'value' },
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CUSTOM_VAR: 'value',
|
||||
FORK_MODE: '1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass cwd to fork', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
cwd: '/custom/workdir',
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
cwd: '/custom/workdir',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass abort signal to fork', () => {
|
||||
process.env.FORK_MODE = '1';
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
mockFork.mockReturnValue(mockChildProcess);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options: TransportOptions = {
|
||||
pathToQwenExecutable: 'qwen',
|
||||
abortController,
|
||||
};
|
||||
|
||||
new ProcessTransport(options);
|
||||
|
||||
expect(mockFork).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue