diff --git a/src/remote-device/desktop-commander-integration.ts b/src/remote-device/desktop-commander-integration.ts index a0c53a16..40f0da1b 100644 --- a/src/remote-device/desktop-commander-integration.ts +++ b/src/remote-device/desktop-commander-integration.ts @@ -90,18 +90,24 @@ export class DesktopCommanderIntegration { } ); - // Connect to Desktop Commander - console.debug('[DEBUG] Connecting MCP client to transport'); - await this.mcpClient.connect(this.mcpTransport); - this.isReady = true; - // Supervise the local half. Without these, a child crash is silent: // the SDK clears its transport and every subsequent call throws // "Not connected" with nothing tying it back to the death. + // Attached BEFORE connect(): the SDK chains handlers that already + // exist on the transport, so its own close handling β€” which rejects + // in-flight requests with "Connection closed" immediately β€” keeps + // running. Assigned after connect() they would REPLACE the SDK's + // handler and an in-flight call at child death would hang until + // the 60s request timeout instead of failing fast. this.mcpTransport.onclose = () => this.handleLocalDisconnect('stdio transport closed'); this.mcpTransport.onerror = (err: Error) => this.handleLocalDisconnect(`stdio transport error: ${err?.message ?? String(err)}`); + // Connect to Desktop Commander + console.debug('[DEBUG] Connecting MCP client to transport'); + await this.mcpClient.connect(this.mcpTransport); + this.isReady = true; + console.log(' - πŸ”Œ Connected to Desktop Commander MCP'); console.debug('[DEBUG] Desktop Commander MCP connection successful'); @@ -203,10 +209,18 @@ export class DesktopCommanderIntegration { // restarts `desktop-commander remote`. await this.ensureReady(); + // The child can die between ensureReady() resolving and the call below + // (handleLocalDisconnect nulls the client); surface that as a clear + // error instead of a TypeError on null. + const client = this.mcpClient; + if (!client) { + throw new Error('Local Desktop Commander MCP connection was lost while dispatching; it will be restarted on the next call'); + } + // Proxy other tools to MCP server try { console.debug('[DEBUG] Calling MCP tool:', toolName, 'args:', JSON.stringify(args).substring(0, 100)); - const result = await this.mcpClient!.callTool({ + const result = await client.callTool({ name: toolName, arguments: args, _meta: { remote: true, ...metadata || {} } diff --git a/src/remote-device/device-status-arbiter.ts b/src/remote-device/device-status-arbiter.ts new file mode 100644 index 00000000..0c124f4b --- /dev/null +++ b/src/remote-device/device-status-arbiter.ts @@ -0,0 +1,61 @@ +/** + * Single writer for the device's mcp_devices online/offline status. + * + * The device has two independent health signals β€” the Supabase Realtime + * channel (server connectivity) and the local Desktop Commander child + * (execution capability) β€” and previously each wrote status directly. + * Independent writers contradict each other: a channel resubscribe used to + * mark the device online while the local child was dead, advertising a device + * that fails every routed call. + * + * The arbiter owns the truth: online iff BOTH signals are healthy, written + * only on transitions β€” so retry storms that re-report an unchanged state + * (e.g. repeated TIMED_OUT during a reconnect loop) produce zero extra + * PATCHes to mcp_devices. + */ +export type DeviceHealthPart = 'channel' | 'child'; + +export class DeviceStatusArbiter { + // The channel is assumed healthy until it reports otherwise: channel setup + // completes before supervision starts reporting, and the first + // CHANNEL_ERROR / TIMED_OUT / CLOSED flips it. The child must prove itself. + private parts: Record = { channel: true, child: false }; + // Devices register as offline; that is the last known persisted state. + private lastWritten: 'online' | 'offline' | null = 'offline'; + private writeChain: Promise = Promise.resolve(); + private readonly write: (status: 'online' | 'offline') => Promise; + + constructor(options: { write: (status: 'online' | 'offline') => Promise }) { + this.write = options.write; + } + + get status(): 'online' | 'offline' { + return this.parts.channel && this.parts.child ? 'online' : 'offline'; + } + + report(part: DeviceHealthPart, ready: boolean) { + this.parts[part] = ready; + this.maybeWrite(false); + } + + /** + * Force-write the current status. Needed once after registration: reports + * that arrive before a deviceId exists have their writes dropped by the + * device's write callback, so the persisted state must be brought up to + * date as soon as writes can succeed. + */ + sync() { + this.maybeWrite(true); + } + + private maybeWrite(force: boolean) { + const status = this.status; + if (!force && status === this.lastWritten) return; + this.lastWritten = status; + // Serialize writes so offlineβ†’online in quick succession lands in order. + this.writeChain = this.writeChain + .then(() => this.write(status)) + .catch((error: any) => + console.error(`Failed to write device status '${status}':`, error?.message ?? error)); + } +} diff --git a/src/remote-device/device.ts b/src/remote-device/device.ts index baad934c..46150154 100644 --- a/src/remote-device/device.ts +++ b/src/remote-device/device.ts @@ -3,6 +3,7 @@ import { RemoteChannel } from './remote-channel.js'; import { DeviceAuthenticator } from './device-authenticator.js'; import { DesktopCommanderIntegration } from './desktop-commander-integration.js'; +import { DeviceStatusArbiter } from './device-status-arbiter.js'; import { fileURLToPath } from 'url'; import os from 'os'; import fs from 'fs/promises'; @@ -21,6 +22,10 @@ export class MCPDevice { private configPath: string; private persistSession: boolean; private desktop: DesktopCommanderIntegration; + private statusArbiter: DeviceStatusArbiter; + private recoveringLocalMcp: boolean = false; + private localRestartAttempt: number = 0; + private localMcpStableSince: number = 0; constructor(options: MCPDeviceOptions = {}) { this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app'; @@ -33,6 +38,15 @@ export class MCPDevice { // Initialize desktop integration this.desktop = new DesktopCommanderIntegration(); + // Single writer for mcp_devices status: online iff BOTH the remote + // channel and the local child are healthy, written on transitions only. + this.statusArbiter = new DeviceStatusArbiter({ + write: async (status) => { + if (!this.deviceId) return; // pre-registration reports; sync() catches up later + await this.remoteChannel.setOnlineStatus(this.deviceId, status); + }, + }); + // Graceful shutdown handlers (only set once) this.setupShutdownHandlers(); } @@ -98,6 +112,7 @@ export class MCPDevice { // Initialize desktop integration await this.desktop.initialize(); this.desktop.onDisconnect((reason) => void this.handleLocalMcpLoss(reason)); + this.statusArbiter.report('child', true); console.log(`⏳ Connecting to Remote MCP ${this.baseServerUrl}`); const { supabaseUrl, anonKey } = await this.fetchSupabaseConfig(); @@ -105,6 +120,9 @@ export class MCPDevice { // Initialize Remote Channel this.remoteChannel.initialize(supabaseUrl, anonKey); + // Route channel health through the arbiter so a channel resubscribe + // can't mark the device online while the local child is dead. + this.remoteChannel.setChannelHealthReporter((ready) => this.statusArbiter.report('channel', ready)); // Load persisted configuration (deviceId, session) let session = await this.loadPersistedConfig(); @@ -163,6 +181,10 @@ export class MCPDevice { (payload: any) => this.handleNewToolCall(payload) ); + // Registration is done and a deviceId exists β€” persist the current + // arbiter status (earlier reports had their writes dropped). + this.statusArbiter.sync(); + console.log('βœ… Device ready:'); console.log(` - User: ${this.remoteChannel.user!.email}`); console.log(` - Device ID: ${this.deviceId}`); @@ -267,23 +289,50 @@ export class MCPDevice { * connected" until someone restarted the process by hand. */ private async handleLocalMcpLoss(reason: string) { - if (this.deviceId) { - await this.remoteChannel.setOnlineStatus(this.deviceId, 'offline') - .catch((e: any) => console.error('Failed to mark device offline:', e.message)); - } - // Recover proactively rather than waiting for the next tool call to // trigger the lazy restart: we just went offline, so no further calls // would be routed here and that wait would never end. + // + // Restart attempts use exponential backoff (base 2s, ceiling 5min) and + // never give up: a child that keeps dying is respawned ever more + // slowly instead of hammering mcp_devices with offline/online churn, + // and a transient restart failure (files mid-upgrade, resource + // exhaustion) self-heals on a later attempt instead of leaving the + // device offline until a human restarts the process. A death after a + // stable stretch restarts the ladder from the base delay. + if (this.recoveringLocalMcp || this.isShuttingDown) return; + this.recoveringLocalMcp = true; try { - await this.desktop.ensureReady(); - if (this.deviceId) { - await this.remoteChannel.setOnlineStatus(this.deviceId, 'online'); + this.statusArbiter.report('child', false); + + const baseMs = Number(process.env.DC_LOCAL_RESTART_BACKOFF_BASE_MS) || 2000; + const stableMs = Number(process.env.DC_LOCAL_RESTART_STABLE_UPTIME_MS) || 60000; + const ceilingMs = 300000; + if (Date.now() - this.localMcpStableSince > stableMs) { + this.localRestartAttempt = 0; } - console.log('♻️ Local Desktop Commander MCP restarted; device is online again'); - } catch (error: any) { - console.error(`❌ Could not restart local Desktop Commander MCP: ${error.message}`); - await captureRemote('remote_device_local_mcp_restart_failed', { error, reason }); + + while (!this.isShuttingDown) { + const delay = Math.min(baseMs * 2 ** this.localRestartAttempt, ceilingMs); + this.localRestartAttempt++; + // Jitter so a fleet-wide trigger doesn't synchronize retries. + await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.15)); + if (this.isShuttingDown) return; + try { + await this.desktop.ensureReady(); + this.localMcpStableSince = Date.now(); + this.statusArbiter.report('child', true); + console.log(`♻️ Local Desktop Commander MCP restarted (attempt ${this.localRestartAttempt}); device is online again`); + return; + } catch (error: any) { + console.error(`❌ Restart attempt ${this.localRestartAttempt} failed: ${error.message}`); + await captureRemote('remote_device_local_mcp_restart_failed', { + error, reason, attempt: this.localRestartAttempt, + }); + } + } + } finally { + this.recoveringLocalMcp = false; } } diff --git a/src/remote-device/remote-channel.ts b/src/remote-device/remote-channel.ts index 0f42478d..82562d4a 100644 --- a/src/remote-device/remote-channel.ts +++ b/src/remote-device/remote-channel.ts @@ -43,6 +43,28 @@ export class RemoteChannel { // Track last device status to prevent duplicate log messages private lastDeviceStatus: 'online' | 'offline' = 'offline'; + // When set (by MCPDevice), channel health flows through the device status + // arbiter instead of writing mcp_devices directly β€” the arbiter combines + // it with local-child health so a channel resubscribe can't mark a device + // whose child is dead back online. + private channelHealthReporter: ((ready: boolean) => void) | null = null; + + setChannelHealthReporter(reporter: (ready: boolean) => void) { + this.channelHealthReporter = reporter; + } + + private reportChannelHealth(ready: boolean) { + if (this.channelHealthReporter) { + this.channelHealthReporter(ready); + return; + } + // Legacy fallback when no arbiter is wired (standalone use). + if (this.deviceId) { + this.setOnlineStatus(this.deviceId, ready ? 'online' : 'offline') + .catch((e: any) => console.error('Failed to update device status:', e.message)); + } + } + // Track last channel state for debug logging private lastChannelState: string | null = null; @@ -228,22 +250,19 @@ export class RemoteChannel { const recovered = this.reconnectAttempt; this.reconnectAttempt = 0; console.log(`βœ… Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`); - // Update device status on successful connection - if (this.deviceId) { - this.setOnlineStatus(this.deviceId, 'online').catch(e => { - console.error('Failed to set online status:', e.message); - }); - } + // Report channel health on successful connection (the + // arbiter decides whether the device is truly online). + this.reportChannelHealth(true); resolve(); } else if (status === 'CHANNEL_ERROR') { // CHANNEL_ERROR is the only status carrying a real error message. console.error(`❌ Channel error: ${err?.message || 'unknown'} β€” ${this.connState()}`); - this.setOnlineStatus(this.deviceId!, 'offline'); + this.reportChannelHealth(false); captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { }); reject(err || new Error('Failed to initialize tool call channel subscription')); } else if (status === 'TIMED_OUT') { console.error(`⏱️ Channel subscription timed out, Reconnecting... β€” ${this.connState()}`); - this.setOnlineStatus(this.deviceId!, 'offline'); + this.reportChannelHealth(false); captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { }); reject(new Error('Tool call channel subscription timed out')); } else if (status === 'CLOSED') { @@ -251,7 +270,7 @@ export class RemoteChannel { // forever (which would wedge the re-entrancy guard / watchdog), and // mark the device offline like the other degraded states. console.warn(`⚠️ Channel closed β€” ${this.connState()}`); - this.setOnlineStatus(this.deviceId!, 'offline'); + this.reportChannelHealth(false); reject(new Error('Tool call channel closed during subscribe')); } }); diff --git a/test/test-remote-device-supervision.js b/test/test-remote-device-supervision.js new file mode 100644 index 00000000..33e8426e --- /dev/null +++ b/test/test-remote-device-supervision.js @@ -0,0 +1,227 @@ +/** + * Supervision tests for the remote-device local-child recovery path (PR #598 follow-ups). + * + * Five cases, each pinning a reviewed defect. All five are EXPECTED TO FAIL on + * the unfixed PR #598 head β€” run red first, then implement: + * + * 1. in-flight fail-fast β€” a callClientTool in flight when the child dies must + * reject promptly ("Connection closed"), not hang until the SDK's 60s + * request timeout. Broken by assigning transport.onclose/onerror AFTER + * client.connect() (the SDK only chains handlers that exist BEFORE). + * 2. restart backoff β€” repeated child deaths must space restart attempts out + * (exponential backoff), not respawn at a constant ~0.3s cadence that + * hammers mcp_devices with offline/online PATCH pairs. + * 3. retry until recovered β€” a restart attempt that fails transiently must be + * retried, not abandoned forever (device otherwise stays offline until a + * human restarts the process). + * 4. null-guard β€” child death in the window between ensureReady() resolving + * and callTool() must surface a descriptive Error, not a TypeError on a + * null client. + * 5. status arbiter β€” device status must be the AND of channel-ready and + * child-ready with transition-only writes; a channel resubscribe while the + * child is dead must NOT write 'online' (remote-channel.ts currently + * writes 'online' on every SUBSCRIBED, contradicting child supervision). + * + * Runs under `npm test` (imports compiled dist) or standalone: + * node test/test-remote-device-supervision.js + */ +import assert from 'node:assert'; + +process.env.DESKTOP_COMMANDER_DISABLE_TELEMETRY = '1'; +// Consumed by the (to-be-implemented) backoff so tests run the ladder fast. +// Base must dominate the ~300ms child respawn overhead or ratios get noisy. +process.env.DC_LOCAL_RESTART_BACKOFF_BASE_MS = '400'; +process.env.DC_LOCAL_RESTART_STABLE_UPTIME_MS = '5000'; + +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +const waitUntil = async (cond, ms) => { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > ms) return false; + await sleep(25); + } + return true; +}; + +/** Fresh MCPDevice with a stubbed network layer; records every status PATCH. */ +async function makeDevice() { + const { MCPDevice } = await import('../dist/remote-device/device.js'); + const device = new MCPDevice(); + const patches = []; + device.remoteChannel = { + setOnlineStatus: async (_id, status) => { patches.push({ t: Date.now(), status }); }, + }; + device.deviceId = 'test-device'; + await device.desktop.initialize(); + // Wire exactly as MCPDevice.initialize() does (arbiter only exists post-fix). + device.desktop.onDisconnect((reason) => void device.handleLocalMcpLoss(reason)); + device.statusArbiter?.report('child', true); + // Arbiter writes are queued on an async chain β€” let the setup write land + // before taking the baseline; cases measure from here. + await sleep(50); + patches.splice(0); + return { device, patches }; +} + +async function quietShutdown(target) { + try { await target.shutdown(); } catch { /* teardown best-effort */ } +} + +// 1 ──────────────────────────────────────────────────────────────────────────── +async function testInflightCallFailsFast() { + const { DesktopCommanderIntegration } = await import('../dist/remote-device/desktop-commander-integration.js'); + const integ = new DesktopCommanderIntegration(); + await integ.initialize(); + const childPid = integ.mcpTransport.pid; + + const t0 = Date.now(); + const inflight = integ + .callClientTool('start_process', { command: 'sleep 120', timeout_ms: 110000 }) + .then(() => ({ rejected: false }), (e) => ({ rejected: true, message: e.message })); + + await sleep(1000); + process.kill(childPid, 'SIGKILL'); + + // Guard well past the SDK's 60s request timeout so a hang still terminates. + const result = await Promise.race([inflight, sleep(75000).then(() => null)]); + const elapsed = Date.now() - t0; + await quietShutdown(integ); + + assert.ok(result && result.rejected, 'in-flight call must reject when the child dies'); + assert.ok(elapsed < 10000, + `in-flight call must fail fast on child death; took ${(elapsed / 1000).toFixed(1)}s (${result.message}) β€” ` + + `60s means the SDK's close handler was clobbered by a post-connect onclose assignment`); + console.log(`βœ“ in-flight call failed fast (${(elapsed / 1000).toFixed(1)}s: ${result.message})`); +} + +// 2 ──────────────────────────────────────────────────────────────────────────── +async function testRestartBackoffEscalates() { + const { device, patches } = await makeDevice(); + const CYCLES = 4; + + for (let i = 0; i < CYCLES; i++) { + const onlinesBefore = patches.filter(p => p.status === 'online').length; + process.kill(device.desktop.mcpTransport.pid, 'SIGKILL'); + const recovered = await waitUntil( + () => patches.filter(p => p.status === 'online').length > onlinesBefore, 20000); + assert.ok(recovered, `device must recover after kill #${i + 1}`); + } + await quietShutdown(device); + + const onlineTimes = patches.filter(p => p.status === 'online').map(p => p.t); + const gaps = onlineTimes.slice(1).map((t, i) => t - onlineTimes[i]); + assert.strictEqual(patches.length, CYCLES * 2, + `expected exactly ${CYCLES * 2} status writes (one offline/online pair per cycle), got ${patches.length}`); + for (let i = 1; i < gaps.length; i++) { + assert.ok(gaps[i] >= gaps[i - 1] * 1.4, + `restart cadence must back off for deaths in quick succession; ` + + `recovery gaps were ${gaps.map(g => g + 'ms').join(', ')} (constant cadence = mcp_devices PATCH storm)`); + } + console.log(`βœ“ restart backoff escalates (recovery gaps: ${gaps.map(g => g + 'ms').join(', ')})`); +} + +// 3 ──────────────────────────────────────────────────────────────────────────── +async function testRestartRetriesTransientFailure() { + const { device, patches } = await makeDevice(); + + // First two restart attempts fail (e.g. files mid-upgrade), third succeeds. + const realInit = device.desktop.initialize.bind(device.desktop); + let attempts = 0; + device.desktop.initialize = async () => { + if (attempts++ < 2) throw new Error('simulated transient restart failure'); + return realInit(); + }; + + process.kill(device.desktop.mcpTransport.pid, 'SIGKILL'); + const recovered = await waitUntil(() => patches.some(p => p.status === 'online'), 20000); + await quietShutdown(device); + + assert.ok(recovered, + 'a transiently failing restart must be retried until it succeeds β€” ' + + 'giving up after one attempt leaves the device offline until a human restarts it'); + assert.ok(attempts >= 3, `expected β‰₯3 initialize attempts, saw ${attempts}`); + console.log(`βœ“ restart retried through transient failures (${attempts} attempts)`); +} + +// 4 ──────────────────────────────────────────────────────────────────────────── +async function testCallClientToolNullGuard() { + const { DesktopCommanderIntegration } = await import('../dist/remote-device/desktop-commander-integration.js'); + const integ = new DesktopCommanderIntegration(); + await integ.initialize(); + + // Compress the race: the child dies right after the readiness check. + const realEnsure = integ.ensureReady.bind(integ); + integ.ensureReady = async () => { await realEnsure(); integ.mcpClient = null; }; + + let error = null; + try { await integ.callClientTool('get_config', {}); } catch (e) { error = e; } + await quietShutdown(integ); + + assert.ok(error, 'callClientTool must throw when the client is lost mid-call'); + assert.ok(!(error instanceof TypeError), + `lost client must surface a descriptive Error, not ${error.constructor.name}: ${error.message}`); + console.log(`βœ“ null client surfaces a descriptive error (${error.message})`); +} + +// 5 ──────────────────────────────────────────────────────────────────────────── +async function testStatusArbiterGatesOnBothHealthSignals() { + // Intended module for the single-writer status arbiter (does not exist yet on + // the unfixed build β€” dynamic import so this fails as a case, not at load). + const { DeviceStatusArbiter } = await import('../dist/remote-device/device-status-arbiter.js'); + + const writes = []; + const arbiter = new DeviceStatusArbiter({ + write: async (status) => { writes.push(status); }, + }); + + arbiter.report('channel', true); + arbiter.report('child', true); + await sleep(50); + assert.deepStrictEqual(writes, ['online'], 'both healthy β†’ exactly one online write'); + + arbiter.report('child', false); // child died + await sleep(50); + assert.deepStrictEqual(writes, ['online', 'offline'], 'child death β†’ offline'); + + arbiter.report('channel', true); // channel resubscribed while child dead + arbiter.report('channel', true); + await sleep(50); + assert.deepStrictEqual(writes, ['online', 'offline'], + 'channel SUBSCRIBED while the child is dead must NOT write online (and repeats must not re-write)'); + + arbiter.report('child', true); // child recovered + await sleep(50); + assert.deepStrictEqual(writes, ['online', 'offline', 'online'], 'recovery β†’ single online write'); + console.log('βœ“ status arbiter gates on channel AND child, transition-only writes'); +} + +// ────────────────────────────────────────────────────────────────────────────── +const CASES = [ + ['in-flight call fails fast on child death', testInflightCallFailsFast], + ['restart backoff escalates', testRestartBackoffEscalates], + ['restart retries transient failure', testRestartRetriesTransientFailure], + ['callClientTool null-guard', testCallClientToolNullGuard], + ['status arbiter gates online on both signals', testStatusArbiterGatesOnBothHealthSignals], +]; + +async function main() { + console.log('=== remote-device supervision ===\n'); + const filter = process.argv[2]; + const cases = filter ? CASES.filter(([name]) => name.includes(filter)) : CASES; + const failures = []; + for (const [name, fn] of cases) { + try { + await fn(); + } catch (err) { + failures.push([name, err.message]); + console.error(`βœ— ${name}\n ${err.message}`); + } + } + console.log(`\n${cases.length - failures.length}/${cases.length} passed`); + if (failures.length) process.exit(1); +} + +main().then(() => process.exit(0)).catch((err) => { + console.error('βœ— FATAL:', err.message); + process.exit(1); +}); diff --git a/test/test-spawn-error-no-crash.js b/test/test-spawn-error-no-crash.js index 53c498db..7ab7f6b4 100644 --- a/test/test-spawn-error-no-crash.js +++ b/test/test-spawn-error-no-crash.js @@ -51,17 +51,25 @@ async function testBogusShellDoesNotCrash() { console.log('βœ“ bogus shell returns an error without crashing the process'); } -async function testBogusExecutableDoesNotCrash() { - // No shell option: the command itself is the executable that fails to resolve. +async function testMissingCommandThroughShellDoesNotCrash() { + // NOTE: a falsy `shell` argument is coerced to the configured default shell + // (executeCommand: `shellToUse = config.defaultShell || true`), so this runs + // THROUGH a shell and exercises the exit-127 "command not found" path β€” it + // cannot produce a spawn 'error' event. The spawn-error path is covered by + // the bogus-shell case above. const result = await terminalManager.executeCommand( 'this-command-does-not-exist-4f2a', 3000, false ); await settle(); assert.strictEqual(uncaught, null, - `spawn failure escaped as an uncaught exception: ${uncaught && uncaught.message}`); + `missing command escaped as an uncaught exception: ${uncaught && uncaught.message}`); assert.ok(result, 'executeCommand must return a result, not hang'); - console.log('βœ“ bogus executable returns an error without crashing the process'); + assert.ok(result.pid > 0, + `command runs via the default shell, so a real pid is expected; got ${result.pid}`); + assert.ok(/not found|not recognized/i.test(result.output), + `expected the shell's command-not-found error, got ${JSON.stringify(result.output)}`); + console.log('βœ“ missing command via default shell returns exit-127 output without crashing'); } async function testHealthyCommandStillWorks() { @@ -78,7 +86,7 @@ async function testHealthyCommandStillWorks() { async function main() { console.log('=== spawn error handling ===\n'); await testBogusShellDoesNotCrash(); - await testBogusExecutableDoesNotCrash(); + await testMissingCommandThroughShellDoesNotCrash(); await testHealthyCommandStillWorks(); console.log('\nAll spawn-error tests passed.'); }