diff --git a/.changes/electron-async-login-shell-path.md b/.changes/electron-async-login-shell-path.md new file mode 100644 index 000000000..521c2628e --- /dev/null +++ b/.changes/electron-async-login-shell-path.md @@ -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. diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts index 86bda42ff..b496fc0c2 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts @@ -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((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((resolve) => { + settle = resolve; + }) + ); + mockEmbeddedMpvService.getSupport.mockReturnValue({ + supported: true, + }); + mockEmbeddedMpvService.prepareAddon.mockReturnValue({ + supported: true, + }); + + const support = getIpcMainHandler(channel)({}); + await new Promise((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((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); diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index b863dd4f2..904bd9bb2 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -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( }); } -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(check: () => T): Promise { + 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, diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index be939905a..5250b813c 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -4,6 +4,11 @@ jest.mock('electron', () => ({ }, })); +jest.mock('fs', () => { + const actual = jest.requireActual('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 = { + 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, + afterLaunch: () => Promise + ): Promise { + (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((resolve) => { + settle = resolve; + }); + await launchUntilSpawn(channel, wait, async () => { + await new Promise((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(() => 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('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(() => undefined), + async () => { + await waitForSpawnCallCount(1); + expect(waitForLoginShellPath).not.toHaveBeenCalled(); + } + ); + } + ); +}); + describe('buildVlcEnqueueCommands', () => { it('clears the playlist and adds the URL with no extra options', () => { expect( diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index e38cef167..1dd8f0653 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -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 { + 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 - ) => - 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 - ) => - 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( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index c4b43007d..728af5580 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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. diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index b53e3be7d..10f30a806 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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 { diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts index 54d076987..497459152 100644 --- a/apps/electron-backend/src/app/startup/deferred-events.ts +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -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 { 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(); 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 new file mode 100644 index 000000000..d08d89c8b --- /dev/null +++ b/apps/electron-backend/src/app/startup/login-shell-path.spec.ts @@ -0,0 +1,178 @@ +const shellPath = jest.fn, []>(); +const shellPathSync = jest.fn(); + +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 { + 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(); + }); +}); diff --git a/apps/electron-backend/src/app/startup/login-shell-path.ts b/apps/electron-backend/src/app/startup/login-shell-path.ts new file mode 100644 index 000000000..7cc796eb4 --- /dev/null +++ b/apps/electron-backend/src/app/startup/login-shell-path.ts @@ -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 = + 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 { + 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((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 { + return loginShellPathSettled; +} + +export type ReadLoginShellPath = () => Promise; + +const readLoginShellPath: ReadLoginShellPath = async () => { + const { shellPath } = await import('shell-path'); + return shellPath(); +}; + +export async function hydratePathFromLoginShell( + readPath: ReadLoginShellPath = readLoginShellPath +): Promise { + 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()); + }); +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index c7b20c4b6..0fdc01291 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); } } diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 0b386a1ba..776e5c3dd 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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 diff --git a/package.json b/package.json index 92aa55fde..7c729b9b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd451bfe0..368fe28de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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