diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index ff4518833..17f142d0e 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -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; diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 87bf6bc2a..872452716 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -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, + }), + ); + }); + }); });