diff --git a/apps/electron-backend/src/app/startup/login-shell-path.spec.ts b/apps/electron-backend/src/app/startup/login-shell-path.spec.ts index ea0d74148..19d68e483 100644 --- a/apps/electron-backend/src/app/startup/login-shell-path.spec.ts +++ b/apps/electron-backend/src/app/startup/login-shell-path.spec.ts @@ -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( diff --git a/apps/electron-backend/src/app/startup/login-shell-path.ts b/apps/electron-backend/src/app/startup/login-shell-path.ts index bd63070e3..d4c0ccea4 100644 --- a/apps/electron-backend/src/app/startup/login-shell-path.ts +++ b/apps/electron-backend/src/app/startup/login-shell-path.ts @@ -30,29 +30,33 @@ const loginShellPathSettled: Promise = 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 { - 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((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(() => {