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>
This commit is contained in:
authored and GitHub committed 2026-10-03 11:48:06 +02:00
1 parent cb252940b2
commit d677fbaf8c
14 files changed
+702 -74

No files matched your search

@@ -0,0 +1,9 @@
---
type: perf
area: electron
---
On macOS and Linux the app shows your sources sooner after launch: looking up
the PATH from your login shell (used to find MPV and VLC) no longer freezes the
app while the shell starts, which took one to two seconds with a typical zsh
setup.
@@ -6,7 +6,10 @@ jest.mock('electron', () => ({
const mockEmbeddedMpvService = {
createSession: jest.fn(),
prepareAddon: jest.fn(),
getSupport: jest.fn(),
willProbeLinuxMpvExecutable: jest.fn(() => false),
forgetLinuxMpvExecutableProbe: jest.fn(),
setPaused: jest.fn(),
};
const mockSessionOptions = {
@@ -21,10 +24,20 @@ jest.mock('../services/embedded-mpv-native.service', () => ({
jest.mock('../services/embedded-mpv-session-options', () => ({
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
}));
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
let settleLookup: () => void = () => undefined;
const mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
jest.mock('../startup/login-shell-path', () => ({
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
whenLoginShellPathSettled: () => mockLookupSettled,
}));
import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_CREATE_SESSION,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SUPPORT,
} from '@iptvnator/shared/interfaces';
@@ -55,6 +68,91 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
mockEmbeddedMpvService.setPaused.mockReset();
});
describe('support checks and the login shell PATH', () => {
afterEach(() => {
mockWaitForLoginShellPath.mockClear();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
false
);
});
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
'%s waits for the lookup before the bare-name mpv probe',
async (channel) => {
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
let settle: (settled: boolean) => void = () => undefined;
mockWaitForLoginShellPath.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
settle = resolve;
})
);
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
});
mockEmbeddedMpvService.prepareAddon.mockReturnValue({
supported: true,
});
const support = getIpcMainHandler(channel)({});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
mockEmbeddedMpvService.getSupport
).not.toHaveBeenCalled();
expect(
mockEmbeddedMpvService.prepareAddon
).not.toHaveBeenCalled();
settle(true);
await expect(support).resolves.toEqual({ supported: true });
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
}
);
it('re-probes mpv once a lookup that ran out finally answers', async () => {
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: false,
});
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).resolves.toEqual({ supported: false });
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
settleLookup();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).toHaveBeenCalledTimes(1);
});
it('does not wait when no probe runs, nor for session calls', async () => {
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
engine: 'frame-copy',
});
mockEmbeddedMpvService.createSession.mockReturnValue({ id: 's' });
await getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({});
await getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION)(
{},
{ x: 0, y: 0, width: 1, height: 1 }
);
expect(mockWaitForLoginShellPath).not.toHaveBeenCalled();
});
});
it('creates a session with the options read from the settings mirror', async () => {
const session = { id: 'session-1', status: 'idle' };
mockEmbeddedMpvService.createSession.mockReturnValue(session);
@@ -33,6 +33,10 @@ import {
embeddedMpvNativeService,
} from '../services/embedded-mpv-native.service';
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
import {
waitForLoginShellPath,
whenLoginShellPathSettled,
} from '../startup/login-shell-path';
export default class EmbeddedMpvEvents {
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
@@ -64,9 +68,34 @@ function handleEmbeddedMpv<Args extends unknown[]>(
});
}
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => getService().getSupport());
/**
* On Linux, support (and prepare, which checks support first) may run
* `mpv --version` by bare name and cache the result, so it must see the
* login shell PATH. Only that probe waits; nothing else here spawns by name.
*/
async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
if (
!getService().willProbeLinuxMpvExecutable() ||
(await waitForLoginShellPath())
) {
return check();
}
// The lookup ran out of budget, so this probe sees the inherited PATH.
// Once the shell does answer, the result is probed again.
const result = check();
void whenLoginShellPathSettled().then(() =>
getService().forgetLinuxMpvExecutableProbe()
);
return result;
}
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
afterLoginShellPathIfProbing(() => getService().getSupport())
);
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () =>
afterLoginShellPathIfProbing(() => getService().prepareAddon())
);
handleEmbeddedMpv(
EMBEDDED_MPV_CREATE_SESSION,
@@ -4,6 +4,11 @@ jest.mock('electron', () => ({
},
}));
jest.mock('fs', () => {
const actual = jest.requireActual<typeof import('fs')>('fs');
return { ...actual, existsSync: jest.fn(actual.existsSync) };
});
jest.mock('child_process', () => ({
spawn: jest.fn(),
}));
@@ -26,6 +31,10 @@ jest.mock('../services/store.service', () => ({
},
}));
jest.mock('../startup/login-shell-path', () => ({
waitForLoginShellPath: jest.fn(() => Promise.resolve()),
}));
jest.mock('../services/stalker-playback-context.service', () => ({
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
}));
@@ -33,6 +42,7 @@ jest.mock('../services/stalker-playback-context.service', () => ({
import { ipcMain } from 'electron';
import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import {
MPV_PLAYER_PATH,
store,
@@ -53,6 +63,7 @@ import {
shouldUseMpvSocketBridge,
} from './player.events';
import { openVlcPlayer } from './vlc-session.service';
import { waitForLoginShellPath } from '../startup/login-shell-path';
function createPathExists(existingPaths: string[]) {
return (candidatePath: string) => existingPaths.includes(candidatePath);
@@ -461,6 +472,117 @@ describe('openVlcPlayer', () => {
});
});
describe('external player launch handlers', () => {
const pathKeys: Record<string, string> = {
OPEN_MPV_PLAYER: MPV_PLAYER_PATH,
OPEN_VLC_PLAYER: VLC_PLAYER_PATH,
};
function configurePlayerPath(channel: string, playerPath: string): void {
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
key === pathKeys[channel] ? playerPath : fallback
);
}
async function launchUntilSpawn(
channel: string,
wait: Promise<void>,
afterLaunch: () => Promise<void>
): Promise<void> {
(waitForLoginShellPath as jest.Mock).mockReturnValueOnce(wait);
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const launch = Promise.resolve(
getIpcMainHandler(channel)(
{},
'https://example.com/live.m3u8',
'Live'
)
).catch(() => undefined);
await afterLaunch();
proc.emit('error', new Error('spawn ENOENT'));
await launch;
} finally {
consoleErrorSpy.mockRestore();
}
}
beforeEach(() => {
(spawn as unknown as jest.Mock).mockReset();
(waitForLoginShellPath as jest.Mock).mockClear();
});
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
'%s spawns a bare player name only after the login shell PATH lookup settled',
async (channel) => {
configurePlayerPath(channel, 'player-on-shell-path');
let settle: () => void = () => undefined;
const wait = new Promise<void>((resolve) => {
settle = resolve;
});
await launchUntilSpawn(channel, wait, async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(spawn).not.toHaveBeenCalled();
settle();
await waitForSpawnCallCount(1);
});
}
);
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
'%s does not wait for a Flatpak host launch of a bare name',
async (channel) => {
const originalPlatform = process.platform;
const existsSync = fs.existsSync as unknown as jest.Mock;
existsSync.mockImplementation(
(candidate: unknown) => String(candidate) === '/.flatpak-info'
);
Object.defineProperty(process, 'platform', { value: 'linux' });
configurePlayerPath(channel, 'player-on-host-path');
try {
await launchUntilSpawn(
channel,
new Promise<void>(() => undefined),
async () => {
await waitForSpawnCallCount(1);
expect(waitForLoginShellPath).not.toHaveBeenCalled();
expect(
(spawn as unknown as jest.Mock).mock.calls[0][0]
).toBe('flatpak-spawn');
}
);
} finally {
existsSync.mockImplementation(
jest.requireActual<typeof import('fs')>('fs').existsSync
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
}
}
);
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
'%s starts a configured executable path without waiting',
async (channel) => {
configurePlayerPath(channel, '/opt/players/bin/player');
await launchUntilSpawn(
channel,
new Promise<void>(() => undefined),
async () => {
await waitForSpawnCallCount(1);
expect(waitForLoginShellPath).not.toHaveBeenCalled();
}
);
}
);
});
describe('buildVlcEnqueueCommands', () => {
it('clears the playlist and adds the URL with no extra options', () => {
expect(
@@ -1,16 +1,21 @@
import { ipcMain } from 'electron';
import {
CLOSE_EXTERNAL_PLAYER_SESSION,
type ExternalPlayerName,
PLAYBACK_SET_KEEP_AWAKE,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { setPlaybackKeepAwake } from '../services/playback-keep-awake.service';
import { waitForLoginShellPath } from '../startup/login-shell-path';
import {
MPV_PLAYER_PATH,
store,
VLC_PLAYER_PATH,
} from '../services/store.service';
import { normalizePlayerPathForStore } from './external-player-launch-context';
import {
normalizePlayerPathForStore,
resolveExternalPlayerLaunchContext as resolveLaunchContext,
} from './external-player-launch-context';
import {
externalPlayerSessions,
traceExternalPlayer,
@@ -34,6 +39,23 @@ export {
parseVlcRcPlaybackState,
} from './vlc-session.service';
/**
* A player resolved to a bare name (no configured path, no well-known
* install found) is looked up through PATH, so it waits for the login shell
* PATH; a path to an executable starts right away. So does a Flatpak host
* launch: `flatpak-spawn --host` resolves the name with the host's PATH,
* which the sandbox's login shell lookup cannot change.
*/
async function waitForPathIfBareName(
player: ExternalPlayerName,
configuredPath: string | undefined
): Promise<void> {
const context = resolveLaunchContext(player, configuredPath);
if (context.mode !== 'flatpak-host' && !/[\\/]/.test(context.playerPath)) {
await waitForLoginShellPath();
}
}
export default class PlayerEvents {
static bootstrapPlayerEvents(): Electron.IpcMain {
return ipcMain;
@@ -53,8 +75,9 @@ ipcMain.handle(
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) =>
openMpvPlayer({
) => {
await waitForPathIfBareName('mpv', store.get(MPV_PLAYER_PATH));
return openMpvPlayer({
url,
title,
thumbnail,
@@ -64,7 +87,8 @@ ipcMain.handle(
contentInfo,
startTime,
headers,
})
});
}
);
ipcMain.handle(
@@ -95,8 +119,9 @@ ipcMain.handle(
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) =>
openVlcPlayer({
) => {
await waitForPathIfBareName('vlc', store.get(VLC_PLAYER_PATH));
return openVlcPlayer({
url,
title,
thumbnail,
@@ -106,7 +131,8 @@ ipcMain.handle(
contentInfo,
startTime,
headers,
})
});
}
);
ipcMain.handle(
@@ -375,6 +375,60 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect(support.frameCopyAvailable).toBe(true);
});
it('predicts the bare-name mpv probe exactly when getSupport runs it', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockClear();
mockSpawnSync.mockReturnValue({ status: 0 });
mockRuntimeUsable();
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
service.getSupport();
expect(mockSpawnSync).toHaveBeenCalledWith(
'mpv',
['--version'],
expect.anything()
);
// Cached afterwards: no further probe, so no further wait.
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
});
it('probes mpv again after a missing or a found result is forgotten', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockClear();
mockSpawnSync.mockReturnValue({ status: 1 });
mockRuntimeUsable();
expect(service.getSupport().supported).toBe(false);
service.forgetLinuxMpvExecutableProbe();
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
mockSpawnSync.mockReturnValue({ status: 0 });
service.getSupport();
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
service.forgetLinuxMpvExecutableProbe();
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
});
it('predicts no probe for the frame-copy engine or native Wayland', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mockSpawnSync.mockClear();
mockRuntimeUsable();
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
delete process.env.WAYLAND_DISPLAY;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
service.getSupport();
expect(mockSpawnSync).not.toHaveBeenCalled();
});
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
// The helper links libmpv itself; the mpv-on-PATH probe only
// binds the native --wid engine.
@@ -377,6 +377,32 @@ export class EmbeddedMpvNativeService {
};
}
/**
* Whether `getSupport()` would now run the uncached Linux bare-name
* `mpv --version` probe, i.e. reach the native-engine checks: no
* frame-copy engine, a usable display server and the feature enabled.
* The IPC layer waits for the login shell PATH only then.
*/
willProbeLinuxMpvExecutable(): boolean {
return (
process.platform === 'linux' &&
this.cachedLinuxMpvExecutableReason === undefined &&
!this.isUnsupportedLinuxDisplayServer() &&
isEmbeddedMpvFeatureEnabled() &&
!this.isFrameCopyEngineActive()
);
}
/**
* Drops the cached `mpv --version` result, found or missing, so the next
* support check probes again. For a probe that ran before the login
* shell PATH arrived: that PATH may add mpv or leave out the directory
* the inherited one found it in.
*/
forgetLinuxMpvExecutableProbe(): void {
this.cachedLinuxMpvExecutableReason = undefined;
}
getSupport(): EmbeddedMpvSupport {
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
return {
@@ -6,7 +6,7 @@
* main.ts loads this module through a dynamic import inside the main
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
* electron-updater, fix-path) are evaluated while the renderer parses and
* electron-updater) are evaluated while the renderer parses and
* runs its own bundle instead of before the window can load at all.
*
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
@@ -51,6 +51,9 @@ import {
} from '../services/app-update-channel';
import { databaseWorkerClient } from '../services/database-worker-client';
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
import { scheduleDeferredFixPath } from './login-shell-path';
export { scheduleDeferredFixPath };
export interface DeferredEventsContext {
readonly appVersion: string;
@@ -126,35 +129,6 @@ export async function finishStartupAfterFirstLoad(): Promise<void> {
traceStartupPhase('reconcile-stale-recordings:done');
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
* so its module evaluation stays off the launch path as well.
*/
export function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
import('fix-path')
.then(({ default: fixPath }) => {
fixPath();
traceStartupPhase('fix-path:done');
})
.catch((error) => {
console.warn('fix-path failed:', error);
});
});
}
/** Tears down sessions and the DB worker; safe when nothing was started. */
export function shutdownDeferredServices(): void {
shutdownEmbeddedMpv();
@@ -0,0 +1,178 @@
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();
});
});
@@ -0,0 +1,124 @@
import { traceStartupPhase } from '../services/debug-trace';
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name when the
* app was started from Finder or a desktop launcher.
*
* The shell (`$SHELL -ilc env`) starts asynchronously. Its start-up cost
* depends on the user's shell profile and was about 1-2 s with a typical
* zsh setup; the synchronous `fix-path` used before blocked the main thread
* for all of it, right when the database worker's `ready` message and the
* renderer's first IPC calls were waiting, which delayed the first card of
* the launch journey by the same amount. The resulting PATH, and the
* fallback when the shell reports none, are the ones `fix-path` 5 produced.
*
* Runs after window creation and IPC handler registration. Idempotent:
* subsequent calls are no-ops. shell-path is imported here, on demand, so
* its module evaluation stays off the launch path as well.
*
* Handlers that spawn a binary by bare name (external players, the Linux
* `mpv --version` check of embedded MPV) await `waitForLoginShellPath()`
* first, so they see the same PATH the blocking lookup guaranteed.
*/
let loginShellPathScheduled = false;
let settleLoginShellPath: () => void = () => undefined;
const loginShellPathSettled: Promise<void> =
process.platform === 'win32'
? Promise.resolve()
: new Promise((resolve) => {
settleLoginShellPath = resolve;
});
/**
* 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;
let loginShellPathDeadline: number | null = null;
let loginShellPathHasSettled = process.platform === 'win32';
const settledOutcome = loginShellPathSettled.then(() => {
loginShellPathHasSettled = true;
return true;
});
/**
* Resolves once the login shell PATH lookup has finished (successfully or
* not), or when its budget is spent; immediately on Windows. Before the
* lookup is scheduled, waits end at a deadline shared from the first of
* them. `limitMs` caps a single wait. Resolves to whether
* the lookup had finished: false means the caller runs on the inherited
* PATH and should not keep a negative result.
*/
export function waitForLoginShellPath(
limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
): Promise<boolean> {
if (loginShellPathHasSettled) {
return Promise.resolve(true);
}
// Waits before the lookup is scheduled share one deadline too, set by
// the first of them; scheduling the lookup replaces it with its budget.
loginShellPathDeadline ??= Date.now() + LOGIN_SHELL_PATH_WAIT_LIMIT_MS;
const remainingMs = Math.min(limitMs, loginShellPathDeadline - Date.now());
if (remainingMs <= 0) {
return Promise.resolve(false);
}
let timer: NodeJS.Timeout | undefined;
const limit = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), remainingMs);
});
return Promise.race([settledOutcome, limit]).finally(() =>
clearTimeout(timer)
);
}
/** Resolves when the lookup finishes, however long it takes. */
export function whenLoginShellPathSettled(): Promise<void> {
return loginShellPathSettled;
}
export type ReadLoginShellPath = () => Promise<string | undefined>;
const readLoginShellPath: ReadLoginShellPath = async () => {
const { shellPath } = await import('shell-path');
return shellPath();
};
export async function hydratePathFromLoginShell(
readPath: ReadLoginShellPath = readLoginShellPath
): Promise<void> {
const shellPath = await readPath();
process.env.PATH =
shellPath ||
[
'./node_modules/.bin',
'/.nodebrew/current/bin',
'/usr/local/bin',
process.env.PATH,
].join(':');
}
export function scheduleDeferredFixPath(
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(() => {
traceStartupPhase('fix-path:done');
})
.catch((error) => {
console.warn('Login shell PATH lookup failed:', error);
})
.finally(() => settleLoginShellPath());
});
}
+7 -6
View File
@@ -184,12 +184,13 @@ export default class Main {
traceStartupPhase('bootstrap-events:done');
// Hydrate process.env.PATH from the user's login shell now — after
// the window has loaded and IPC handlers are live. Fire-and-forget
// (setImmediate) so it doesn't gate any user-visible work. Worst
// case: the user clicks an external player within the ~100 ms it
// takes to complete; the spawn would still find MPV/VLC at any of
// the well-known paths checked by getDefault*Path before falling
// back to bare-name PATH lookup.
// the window has loaded and IPC handlers are live. The shell runs
// asynchronously, so the main thread keeps serving the renderer
// while it starts (about 1-2 s with a typical zsh profile). Worst
// case: the user clicks an external player before it completes;
// the spawn would still find MPV/VLC at any of the well-known paths
// checked by getDefault*Path before falling back to bare-name PATH
// lookup.
module.scheduleDeferredFixPath();
}
}
+11
View File
@@ -421,6 +421,17 @@ process before the first card.
completion bumps `EpgSourceSettingsService.revision()`, the fence that
keeps XMLTV lookups from returning data of a removed source.
- The main thread must stay free between the load and the first card. The
renderer's first database read creates the database worker, and every
request waits until the main process has handled the worker's `ready`
message. Until #1784 the login-shell PATH lookup (`fix-path`) ran right
after `bootstrap-events:done` and spawned `$SHELL -ilc env` synchronously:
on a Mac with a typical zsh profile it held the main thread for about
1-2 s, the first `dbGetAppState` resolved about 2.3 s after spawn, and
everything after it (about 20 ms of IPC) waited. It now runs the shell
asynchronously (`startup/login-shell-path.ts`). On Linux runners bash
starts in tens of milliseconds, so the effect there is small.
Validation (#1716, Principle 3): deferring the download list, update status
and dashboard recent/favorites reads until after the first render took the
counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor
+1 -1
View File
@@ -133,7 +133,6 @@
"electron-conf": "1.3.0",
"electron-updater": "6.8.9",
"epg-parser": "^0.5.0",
"fix-path": "5.0.0",
"hls.js": "1.7.1",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2",
"marked": "18.0.11",
@@ -145,6 +144,7 @@
"rxjs": "7.8.2",
"saxes": "6.0.0",
"shaka-player": "5.2.4",
"shell-path": "3.1.0",
"video.js": "8.24.0",
"videojs-contrib-quality-levels": "4.1.0",
"videojs-quality-selector-hls": "1.1.1",
+4 -28
View File
@@ -145,9 +145,6 @@ importers:
epg-parser:
specifier: ^0.5.0
version: 0.5.0
fix-path:
specifier: 5.0.0
version: 5.0.0
hls.js:
specifier: 1.7.1
version: 1.7.1
@@ -181,6 +178,9 @@ importers:
shaka-player:
specifier: 5.2.4
version: 5.2.4
shell-path:
specifier: 3.1.0
version: 3.1.0
video.js:
specifier: 8.24.0
version: 8.24.0
@@ -4774,10 +4774,6 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-regex@6.3.0:
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
engines: {node: '>=12'}
@@ -6210,10 +6206,6 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fix-path@5.0.0:
resolution: {integrity: sha512-erEWGGCN7RIu1bXTCfNVpVBdm0f5mwcbeja+4QXiEZzIQukP401sbpu8gd3Ny1vS34YNeswyMO0TdT2tP5OlHA==}
engines: {node: '>=20'}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@@ -8864,7 +8856,6 @@ packages:
shaka-player@5.2.4:
resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==}
engines: {node: '>=18'}
shallow-clone@3.0.1:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
@@ -9050,10 +9041,6 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
@@ -14260,8 +14247,6 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-regex@6.3.0: {}
ansi-styles@4.3.0:
@@ -15972,11 +15957,6 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fix-path@5.0.0:
dependencies:
shell-path: 3.1.0
strip-ansi: 7.1.2
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
@@ -19620,7 +19600,7 @@ snapshots:
dependencies:
default-shell: 2.2.0
execa: 5.1.1
strip-ansi: 7.1.2
strip-ansi: 7.2.0
shell-path@3.1.0:
dependencies:
@@ -19812,10 +19792,6 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.3.0