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:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user