diff --git a/apps/desktop/electron/shell-path.test.ts b/apps/desktop/electron/shell-path.test.ts index e276783314..bbca038e16 100644 --- a/apps/desktop/electron/shell-path.test.ts +++ b/apps/desktop/electron/shell-path.test.ts @@ -137,6 +137,21 @@ test('ensureLoginShellPath is single-flight — concurrent callers share one she assert.equal(env.PATH, '/opt/homebrew/bin:/usr/bin') }) +test('applyLoginShellPath force-settles when a hung grandchild keeps the execFile callback from firing', async () => { + const env: any = { SHELL: '/bin/zsh', PATH: '/usr/bin' } + // Simulates a probe whose shell spawns a daemon (e.g. gitstatusd) that + // keeps stdout open: execFile's callback never fires. + const execFileFn = () => ({ pid: 424242, stdin: { end() {} } }) + + const start = Date.now() + const result = await applyLoginShellPath({ env, platform: 'linux', execFileFn, timeoutMs: 20 }) + const elapsed = Date.now() - start + + assert.equal(result.applied, false) + assert.equal(result.reason, 'unresolved') + assert.ok(elapsed < 4000, `expected the probe to force-settle well under the test timeout, took ${elapsed}ms`) +}) + test('ensureLoginShellPath never rejects', async () => { const execFileFn = () => { throw new Error('spawn EACCES') diff --git a/apps/desktop/electron/shell-path.ts b/apps/desktop/electron/shell-path.ts index 54923df074..6760af4037 100644 --- a/apps/desktop/electron/shell-path.ts +++ b/apps/desktop/electron/shell-path.ts @@ -70,10 +70,16 @@ function mergeLoginShellPath(loginPath, currentPath, { delimiter = ':' }: any = function runProbe(shell, flags, execFileFn, timeoutMs): Promise { return new Promise(resolve => { let settled = false + let hardTimer: ReturnType | null = null const finish = value => { if (!settled) { settled = true + + if (hardTimer) { + clearTimeout(hardTimer) + } + resolve(value) } } @@ -82,7 +88,7 @@ function runProbe(shell, flags, execFileFn, timeoutMs): Promise { const child = execFileFn( shell, [...flags, PROBE_COMMAND], - { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, detached: process.platform !== 'win32' }, (_error, stdout) => { // A profile script may exit nonzero after the sentinel already // printed — trust the sentinel, not the exit code. @@ -92,6 +98,30 @@ function runProbe(shell, flags, execFileFn, timeoutMs): Promise { // Interactive shells with a broken rc can block reading stdin. child?.stdin?.end?.() + + // execFile's own `timeout` only SIGTERMs the direct child; a profile + // that spawns a daemon (e.g. Powerlevel10k's gitstatusd) can leave a + // grandchild holding the stdout pipe open, so the callback above never + // fires and this promise would hang forever. Force-settle past the + // requested timeout and reap the whole process group so a hung + // profile can never park boot. + hardTimer = setTimeout(() => { + if (child?.pid && process.platform !== 'win32') { + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + // Group may already be gone. + } + } else { + try { + child?.kill?.('SIGKILL') + } catch { + // Already gone. + } + } + + finish(null) + }, timeoutMs + 1000) } catch { finish(null) }