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:
Mingholy 2026-02-04 23:14:26 +08:00
parent ce7506d658
commit 2d75d82ec8
2 changed files with 314 additions and 11 deletions

View file

@ -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;

View file

@ -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,
}),
);
});
});
});