Skip to content

Commit 7784f32

Browse files
AbirAbbasclaude
andauthored
fix(desktop): survive a CLI probe candidate that throws on spawn (#793)
spawn() can throw synchronously - on Windows it raises UNKNOWN when PATH resolves af to a non-PE file, e.g. the WSL Linux binary seen through the interop PATH of a dev launch. The throw happened inside probeCli's promise executor before any listeners attached, so the promise rejected, Promise.all in probeAll rejected with it, and app.whenReady's await initializeCli died before the tray or window were created: the app ran headless with no UI. Catch the throw and treat that candidate as not responding, matching probeCli's documented never-rejects contract. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9b685fe commit 7784f32

2 files changed

Lines changed: 26 additions & 2 deletions

File tree

desktop/src/main/cli.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
compareVersions,
66
effectiveMinVersion,
77
parseAfVersion,
8+
probeCli,
89
selectCli
910
} from './cli'
1011

@@ -157,3 +158,16 @@ describe('cliCandidates', () => {
157158
expect(sources).toEqual(['managed', 'managed', 'path'])
158159
})
159160
})
161+
162+
describe('probeCli', () => {
163+
it('resolves responds:false when spawn throws synchronously', async () => {
164+
// An empty command makes spawn() throw before any listeners attach —
165+
// the same shape as Windows throwing UNKNOWN for a non-PE binary on PATH.
166+
// probeCli must swallow it: a rejection here fails the whole probeAll and
167+
// the app never creates its tray or window.
168+
await expect(probeCli({ command: '', source: 'path' })).resolves.toMatchObject({
169+
responds: false,
170+
version: null
171+
})
172+
})
173+
})

desktop/src/main/cli.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
// No electron imports: the bundled path is injected by main, so probing,
2626
// selection, and provisioning stay unit-testable.
2727

28-
import { spawn } from 'node:child_process'
28+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2929
import { promises as fs } from 'node:fs'
3030
import { join } from 'node:path'
3131
import type { AgentActionResult, CliStatus } from '../shared/types'
@@ -139,7 +139,17 @@ export function probeCli(candidate: CliCandidate): Promise<ProbedCandidate> {
139139
resolve({ ...candidate, responds, version: responds ? parseAfVersion(output) : null })
140140
}
141141

142-
const child = spawn(candidate.command, ['version'], { windowsHide: true, env: childEnv() })
142+
// spawn() can throw synchronously (e.g. Windows UNKNOWN when the PATH
143+
// resolves `af` to a non-PE file such as a WSL Linux binary). Without the
144+
// try/catch that rejects this promise, and one bad candidate then fails
145+
// the whole probeAll — the app hangs before creating its tray or window.
146+
let child: ChildProcessWithoutNullStreams
147+
try {
148+
child = spawn(candidate.command, ['version'], { windowsHide: true, env: childEnv() })
149+
} catch {
150+
done(false)
151+
return
152+
}
143153
const timer = setTimeout(() => {
144154
child.kill()
145155
done(false)

0 commit comments

Comments
 (0)