From 4cf630c225e144a790a5a11ead90b4063500977c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:17:28 +0000 Subject: [PATCH 1/4] Initial plan From 67bca747004097b9ecc85287cb49aaa58beab7c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:21:46 +0000 Subject: [PATCH 2/4] Make normalizeSpawn async to prevent event loop blocking - Update DoctorDependencies.spawnCommand to return Promise - Update LaunchDependencies (extends DoctorDependencies) - Modify normalizeSpawn to return async wrapper function - Await all spawnCommand calls in launch.ts (preflight, auth, mcp registration) - Await all spawnCommand calls in doctor.ts (cli help, login status, mcp list) - Update launch.test.ts with async spawnCommand mocks - Update doctor.test.ts with async spawnCommand mocks - All tests pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 10 +++++----- src/cli/launch.ts | 16 ++++++++-------- tests/cli/doctor.test.ts | 10 +++++----- tests/cli/launch.test.ts | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 27ad23b..0d818b3 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -41,7 +41,7 @@ export interface DoctorDependencies { command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'>; + ) => Promise, 'status' | 'stdout' | 'stderr'>>; getServiceStatus?: (name: string) => Promise; getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; } @@ -129,7 +129,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const profileNames = parseProfileList(options.mcpProfiles); const fileExists = deps.fileExists ?? existsSync; const spawnCommand = deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); @@ -146,7 +146,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende }); if (distExists) { - const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + const cliHelp = await spawnCommand('node', [distCliPath, '--help'], { cwd, encoding: 'utf8' }); @@ -164,7 +164,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende } if (!options.skipCodexAuth) { - const loginStatus = spawnCommand('codex', ['login', 'status'], { + const loginStatus = await spawnCommand('codex', ['login', 'status'], { cwd, encoding: 'utf8' }); @@ -180,7 +180,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende }); } - const codexMcpList = spawnCommand('codex', ['mcp', 'list', '--json'], { + const codexMcpList = await spawnCommand('codex', ['mcp', 'list', '--json'], { cwd, encoding: 'utf8' }); diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 71e4892..dcb849a 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -62,9 +62,9 @@ function normalizeSpawn( command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'> { + ) => Promise, 'status' | 'stdout' | 'stderr'>> { return deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { @@ -148,8 +148,8 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende const distExists = fileExists(distCliPath); const preflightStep: LaunchStep = distExists - ? (() => { - const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + ? await (async () => { + const cliHelp = await spawnCommand('node', [distCliPath, '--help'], { cwd, encoding: 'utf8' }); @@ -185,8 +185,8 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende ok: true, details: 'Skipped codex auth check (--skip-codex-auth).' } - : (() => { - const loginStatus = spawnCommand('codex', ['login', 'status'], { + : await (async () => { + const loginStatus = await spawnCommand('codex', ['login', 'status'], { cwd, encoding: 'utf8' }); @@ -295,7 +295,7 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende continue; } - const remove = spawnCommand('codex', ['mcp', 'remove', registration.codexName], { + const remove = await spawnCommand('codex', ['mcp', 'remove', registration.codexName], { cwd, encoding: 'utf8' }); @@ -307,7 +307,7 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende ); } - const add = spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { + const add = await spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { cwd, encoding: 'utf8' }); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 2ac04d7..fe86ca8 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -18,7 +18,7 @@ describe('runDoctor', () => { it('fails when the dist CLI artifact is missing', async () => { const deps: DoctorDependencies = { fileExists: () => false, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'codex' && args.join(' ') === 'mcp list --json') { return { status: 0, @@ -49,7 +49,7 @@ describe('runDoctor', () => { it('fails codex auth check when codex login status returns non-zero', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -95,7 +95,7 @@ describe('runDoctor', () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -139,7 +139,7 @@ describe('runDoctor', () => { it('returns actionable remediation for failing MCP profile checks', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -176,7 +176,7 @@ describe('runDoctor', () => { it('fails codex MCP parsing checks when codex mcp list returns malformed JSON', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index 6ee818a..cbd2320 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -15,7 +15,7 @@ describe('runLaunch', () => { const deps: LaunchDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { spawnCalls.push(`${command} ${args.join(' ')}`); if (command === 'node' && args.includes('--help')) { @@ -113,7 +113,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -147,7 +147,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } From be393beb7fb1b670a17f977eaf67087fb965ebbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:23:58 +0000 Subject: [PATCH 3/4] Use truly async spawn instead of wrapped spawnSync Replace wrapped spawnSync with proper async spawn implementation: - Add spawnAsync helper using child_process.spawn with Promise - Update doctor.ts to use spawnAsync by default - Update launch.ts to use spawnAsync by default - Prevents actual event loop blocking (not just wrapping sync in async) - All tests still pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++--- src/cli/launch.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 0d818b3..3a48246 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,8 +1,51 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { spawn, spawnSync, type SpawnSyncReturns } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; +/** + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + */ +function spawnAsync( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } +): Promise, 'status' | 'stdout' | 'stderr'>> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(options.encoding); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(options.encoding); + }); + + child.on('close', (code) => { + resolve({ + status: code ?? 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || error.message + }); + }); + }); +} + export const DEFAULT_MCP_PROFILES = [ 'mcp-filesystem', 'mcp-playwright', @@ -128,8 +171,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const cwd = options.cwd ?? process.cwd(); const profileNames = parseProfileList(options.mcpProfiles); const fileExists = deps.fileExists ?? existsSync; - const spawnCommand = deps.spawnCommand - ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + const spawnCommand = deps.spawnCommand ?? spawnAsync; const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); diff --git a/src/cli/launch.ts b/src/cli/launch.ts index dcb849a..58a5b4a 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -1,4 +1,4 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { spawn, spawnSync, type SpawnSyncReturns } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { @@ -17,6 +17,49 @@ import { } from './doctor.js'; import { BridgeError, ErrorCode } from '../core/errors.js'; +/** + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + */ +function spawnAsync( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } +): Promise, 'status' | 'stdout' | 'stderr'>> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(options.encoding); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(options.encoding); + }); + + child.on('close', (code) => { + resolve({ + status: code ?? 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || error.message + }); + }); + }); +} + export interface LaunchStep { id: string; ok: boolean; @@ -63,8 +106,7 @@ function normalizeSpawn( args: string[], options: { cwd: string; encoding: BufferEncoding } ) => Promise, 'status' | 'stdout' | 'stderr'>> { - return deps.spawnCommand - ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + return deps.spawnCommand ?? spawnAsync; } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { From 7342c0651bbe503e8095c358c1a1cf26c20345c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:24:59 +0000 Subject: [PATCH 4/4] Add clarifying comments to spawnAsync error handling Document that spawnAsync always resolves (never rejects) to match spawnSync API: - Errors communicated via status code and stderr - Matches expected behavior of calling code - Prevents unnecessary try/catch blocks in callers - All tests still pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 4 +++- src/cli/launch.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 3a48246..4cc9d8c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -4,7 +4,9 @@ import { join } from 'path'; import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; /** - * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code. + * Note: This function always resolves (never rejects) to match spawnSync behavior. + * Errors are communicated via status code and stderr, not via Promise rejection. */ function spawnAsync( command: string, diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 58a5b4a..72935f2 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -18,7 +18,9 @@ import { import { BridgeError, ErrorCode } from '../core/errors.js'; /** - * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code. + * Note: This function always resolves (never rejects) to match spawnSync behavior. + * Errors are communicated via status code and stderr, not via Promise rejection. */ function spawnAsync( command: string,