fix(desktop): force-settle the login-shell PATH probe past its timeout

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
This commit is contained in:
chelsealong
2026-09-10 05:08:38 +00:00
committed by yoniebans
parent 8defaaad48
commit 00c0259bbc
2 changed files with 46 additions and 1 deletions
+15
View File
@@ -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')
+31 -1
View File
@@ -70,10 +70,16 @@ function mergeLoginShellPath(loginPath, currentPath, { delimiter = ':' }: any =
function runProbe(shell, flags, execFileFn, timeoutMs): Promise<string | null> {
return new Promise(resolve => {
let settled = false
let hardTimer: ReturnType<typeof setTimeout> | 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<string | null> {
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<string | null> {
// 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)
}