From 00c0259bbcdca97ab2126b7c29813eed2a3a256a Mon Sep 17 00:00:00 2001 From: chelsealong Date: Thu, 10 Sep 2026 05:08:38 +0000 Subject: [PATCH] fix(desktop): force-settle the login-shell PATH probe past its timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execFile's `timeout` only SIGTERMs the direct shell child. A profile that spawns a daemon (e.g. Powerlevel10k's gitstatusd under a non-TTY GUI launch) can leave a grandchild holding the stdout pipe open, so the execFile callback never fires and runProbe's promise hangs forever — pinning desktop boot at "Resolving Hermes backend" indefinitely. Add a hard deadline that force-resolves the probe past its timeout and kills the whole process group (shell + any daemons it spawned) so a hung profile can never park boot. Fixes #107109 --- apps/desktop/electron/shell-path.test.ts | 15 +++++++++++ apps/desktop/electron/shell-path.ts | 32 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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) }