fix(electron): bound login shell PATH waits by the lookup's own budget

Replace the latch on the first expired wait with a deadline set when the
lookup starts (10 s). Every wait ends when the lookup settles or the
deadline passes: a launch retried while the shell is still within its
budget waits for the PATH again, and once the budget is spent no launch
waits, so a hung shell still delays at most the first seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-03 08:58:27 +02:00
1 parent 0589156941
commit 92127a81ac
2 files changed
+45 -16

No files matched your search

@@ -79,18 +79,41 @@ describe('login shell PATH', () => {
resolveShell('/opt/homebrew/bin');
await wait;
expect(process.env.PATH).toBe('/opt/homebrew/bin');
});
// A shell that never returns cannot block a launch forever.
it('ends every wait at the lookup budget, so a hung shell delays no later launch', async () => {
const stuck = loadModule();
stuck.scheduleDeferredFixPath(() => new Promise(() => undefined));
await expect(stuck.waitForLoginShellPath(5)).resolves.toBeUndefined();
// Later spawns do not wait out the limit again (the test would time
stuck.scheduleDeferredFixPath(() => new Promise(() => undefined), 20);
await expect(stuck.waitForLoginShellPath()).resolves.toBeUndefined();
// Past the budget a later launch goes at once (the test would time
// out on this one otherwise).
await expect(
stuck.waitForLoginShellPath(60_000)
).resolves.toBeUndefined();
});
it('lets a launch retried within the budget wait for the PATH again', async () => {
let resolveShell: (path: string) => void = () => undefined;
const module = loadModule();
module.scheduleDeferredFixPath(
() =>
new Promise((resolve) => {
resolveShell = resolve;
}),
60_000
);
await flushScheduled();
// The first launch gives up on its own, shorter limit...
await module.waitForLoginShellPath(5);
expect(process.env.PATH).toBe('/usr/bin:/bin');
// ...a retry still waits, and sees the PATH once the shell answers.
const retry = module.waitForLoginShellPath();
resolveShell('/opt/homebrew/bin');
await retry;
expect(process.env.PATH).toBe('/opt/homebrew/bin');
});
it('lets spawns go immediately on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await expect(
@@ -30,29 +30,33 @@ const loginShellPathSettled: Promise<void> =
settleLoginShellPath = resolve;
});
/** Upper bound for a waiting spawn when the login shell never returns. */
/**
* Time budget of the lookup, counted from its start. A shell that has not
* answered by then is treated as hung: waits end at this shared deadline,
* so a broken profile cannot delay every launch by the full budget, while a
* launch retried within the budget still waits for the PATH.
*/
export const LOGIN_SHELL_PATH_WAIT_LIMIT_MS = 10_000;
// Set once a wait has run out: a shell that hung that long is not waited
// for again, so one broken profile delays one launch, not every launch.
let loginShellPathWaitExpired = false;
let loginShellPathDeadline: number | null = null;
/**
* Resolves once the login shell PATH lookup has finished (successfully or
* not), at the latest after `limitMs`; immediately on Windows and after a
* previous wait has run out.
* not), or when its budget is spent; immediately on Windows. Before the
* lookup is scheduled, a wait lasts at most `limitMs`.
*/
export function waitForLoginShellPath(
limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
): Promise<void> {
if (loginShellPathWaitExpired) {
const remainingMs =
loginShellPathDeadline === null
? limitMs
: Math.min(limitMs, loginShellPathDeadline - Date.now());
if (remainingMs <= 0) {
return Promise.resolve();
}
let timer: NodeJS.Timeout | undefined;
const limit = new Promise<void>((resolve) => {
timer = setTimeout(() => {
loginShellPathWaitExpired = true;
resolve();
}, limitMs);
timer = setTimeout(resolve, remainingMs);
});
return Promise.race([loginShellPathSettled, limit]).finally(() =>
clearTimeout(timer)
@@ -81,13 +85,15 @@ export async function hydratePathFromLoginShell(
}
export function scheduleDeferredFixPath(
readPath: ReadLoginShellPath = readLoginShellPath
readPath: ReadLoginShellPath = readLoginShellPath,
budgetMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
): void {
if (loginShellPathScheduled || process.platform === 'win32') {
return;
}
loginShellPathScheduled = true;
loginShellPathDeadline = Date.now() + budgetMs;
setImmediate(() => {
hydratePathFromLoginShell(readPath)
.then(() => {