Files
iptvnator/apps/electron-backend/src/app/startup/login-shell-path.spec.ts
T
d677fbaf8c perf(electron): look up the login shell PATH without blocking the main thread (#1784)
* perf(electron): look up the login shell PATH without blocking the main thread

fix-path ran $SHELL -ilc env synchronously right after the first load.
With a typical zsh profile that held the main thread for 1-2 s, while the
database worker's ready message and the renderer's first IPC calls waited,
so the launch journey's first card came that much later. Use shell-path's
async shellPath() with fix-path's fallback, so the resulting PATH is the
same and the main thread stays free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(perf): name the PR that made the login shell PATH lookup async

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): let bare-name player spawns wait for the login shell PATH

With the lookup now asynchronous, an external player launched (or the
Linux embedded MPV support check, which runs and caches a bare
`mpv --version`) within the first seconds could see the inherited PATH.
The OPEN_MPV_PLAYER / OPEN_VLC_PLAYER handlers and every embedded MPV
handler now await waitForLoginShellPath() (settled lookup, at most 10 s,
immediate on Windows), restoring the guarantee the blocking lookup gave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): wait for the login shell PATH only for bare-name spawns

External players wait only when they resolve to a bare name (no
configured path, no well-known install found); a path to an executable
starts at once. Embedded MPV waits only on Linux and only for support and
prepare, which run the cached bare-name `mpv --version` check; sessions
and controls never wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): wait for the login shell PATH only before the mpv probe

Embedded MPV support and prepare waited on every Linux call, although
getSupport() returns before the bare-name `mpv --version` probe for the
frame-copy engine, native Wayland, a disabled feature or a cached result.
willProbeLinuxMpvExecutable() now gates the wait on the probe actually
running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): stop waiting for a login shell that already timed out once

After the first wait for a hung `$SHELL -ilc env` runs out, later
bare-name player launches and Linux mpv probes proceed at once instead
of each waiting the full limit again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

* fix(electron): re-probe a missing mpv once a late login shell answers

When the PATH lookup runs out of budget, the Linux embedded MPV support
check probes `mpv --version` with the inherited PATH and caches a
missing result for the rest of the session. waitForLoginShellPath() now
reports whether the lookup settled; after a timed-out wait the handler
forgets a cached "missing" once the lookup finishes, so the next support
check probes again with the login shell PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): share one deadline among waits before the PATH lookup starts

A bare-name launch that waited before the lookup was scheduled started
its own 10 s limit, so while startup was stuck every retry paid the full
delay again. The first early wait now sets the shared deadline; the
lookup still replaces it with its own budget when it starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): re-probe mpv after a late login shell PATH either way

A Linux mpv probe that ran on the inherited PATH can be wrong in both
directions: the login shell PATH may add mpv or drop the directory the
inherited one found it in. forgetLinuxMpvExecutableProbe() now clears a
found result as well as a missing one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): skip the login shell PATH wait for Flatpak host launches

In Flatpak, players start through `flatpak-spawn --host`, which resolves
the name with the host's PATH; the sandbox's login shell lookup cannot
change it. The launch handlers now decide from the same launch context
the player uses: no wait in flatpak-host mode, otherwise only for a bare
player name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:48:06 +02:00

179 lines
6.5 KiB
TypeScript

const shellPath = jest.fn<Promise<string | undefined>, []>();
const shellPathSync = jest.fn<string | undefined, []>();
jest.mock('shell-path', () => ({ shellPath, shellPathSync }));
jest.mock('../services/debug-trace', () => ({
traceStartupPhase: jest.fn(),
}));
type LoginShellPathModule = typeof import('./login-shell-path');
function loadModule(): LoginShellPathModule {
let loaded: LoginShellPathModule | undefined;
jest.isolateModules(() => {
loaded = jest.requireActual('./login-shell-path');
});
return loaded as LoginShellPathModule;
}
async function flushScheduled(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
describe('login shell PATH', () => {
const originalPath = process.env.PATH;
const originalPlatform = process.platform;
beforeEach(() => {
shellPath.mockReset();
shellPathSync.mockReset();
process.env.PATH = '/usr/bin:/bin';
Object.defineProperty(process, 'platform', { value: 'darwin' });
});
afterAll(() => {
process.env.PATH = originalPath;
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
});
it('reads the login shell asynchronously, never through the blocking API', async () => {
let resolveShell: (path: string) => void = () => undefined;
shellPath.mockReturnValue(
new Promise((resolve) => {
resolveShell = resolve;
})
);
loadModule().scheduleDeferredFixPath();
await flushScheduled();
// The shell is still starting, and the main thread is free.
expect(shellPath).toHaveBeenCalledTimes(1);
expect(shellPathSync).not.toHaveBeenCalled();
expect(process.env.PATH).toBe('/usr/bin:/bin');
resolveShell('/opt/homebrew/bin:/usr/bin:/bin');
await flushScheduled();
expect(process.env.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin');
});
it('lets spawns wait until the lookup settled, at most the limit', async () => {
let resolveShell: (path: string) => void = () => undefined;
const module = loadModule();
module.scheduleDeferredFixPath(
() =>
new Promise((resolve) => {
resolveShell = resolve;
})
);
let waited = false;
const wait = module.waitForLoginShellPath().then(() => {
waited = true;
});
await flushScheduled();
expect(waited).toBe(false);
resolveShell('/opt/homebrew/bin');
await wait;
expect(process.env.PATH).toBe('/opt/homebrew/bin');
});
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), 20);
await expect(stuck.waitForLoginShellPath()).resolves.toBe(false);
// 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.toBe(false);
});
it('lets waits before the lookup is scheduled share one deadline', async () => {
jest.useFakeTimers();
try {
const module = loadModule();
const first = module.waitForLoginShellPath();
await jest.advanceTimersByTimeAsync(6_000);
const retry = module.waitForLoginShellPath();
await jest.advanceTimersByTimeAsync(4_000);
// Both end at the first wait's deadline, not 10 s after the retry.
await expect(first).resolves.toBe(false);
await expect(retry).resolves.toBe(false);
await expect(module.waitForLoginShellPath()).resolves.toBe(false);
} finally {
jest.useRealTimers();
}
});
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 expect(retry).resolves.toBe(true);
expect(process.env.PATH).toBe('/opt/homebrew/bin');
});
it('lets spawns go immediately on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await expect(loadModule().waitForLoginShellPath(60_000)).resolves.toBe(
true
);
});
it('falls back to the paths fix-path used when the shell reports none', async () => {
await loadModule().hydratePathFromLoginShell(async () => undefined);
expect(process.env.PATH).toBe(
'./node_modules/.bin:/.nodebrew/current/bin:/usr/local/bin:/usr/bin:/bin'
);
});
it('runs once, and not at all on Windows', async () => {
const read = jest.fn(async () => '/custom/bin');
const module = loadModule();
module.scheduleDeferredFixPath(read);
module.scheduleDeferredFixPath(read);
await flushScheduled();
expect(read).toHaveBeenCalledTimes(1);
Object.defineProperty(process, 'platform', { value: 'win32' });
const windowsRead = jest.fn(async () => '/custom/bin');
loadModule().scheduleDeferredFixPath(windowsRead);
await flushScheduled();
expect(windowsRead).not.toHaveBeenCalled();
});
it('keeps the current PATH when the lookup fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
// Expected failure.
});
loadModule().scheduleDeferredFixPath(async () => {
throw new Error('shell exited');
});
await flushScheduled();
expect(process.env.PATH).toBe('/usr/bin:/bin');
expect(warn).toHaveBeenCalledWith(
'Login shell PATH lookup failed:',
expect.any(Error)
);
warn.mockRestore();
});
});