From 565becf6dd3587fe1244bc1bdc883a881154f459 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 22:41:49 +0200 Subject: [PATCH] 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 --- .../app/events/embedded-mpv.events.spec.ts | 22 ++++++++ .../src/app/events/embedded-mpv.events.ts | 4 ++ .../src/app/events/player.events.spec.ts | 50 +++++++++++++++++++ .../src/app/events/player.events.ts | 19 ++++--- .../src/app/startup/login-shell-path.spec.ts | 33 ++++++++++++ .../src/app/startup/login-shell-path.ts | 33 +++++++++++- 6 files changed, 154 insertions(+), 7 deletions(-) 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..87cc743d2 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 @@ -21,6 +21,10 @@ jest.mock('../services/embedded-mpv-native.service', () => ({ jest.mock('../services/embedded-mpv-session-options', () => ({ readEmbeddedMpvSessionOptions: () => mockSessionOptions, })); +const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve()); +jest.mock('../startup/login-shell-path', () => ({ + waitForLoginShellPath: () => mockWaitForLoginShellPath(), +})); import { ipcMain } from 'electron'; import { @@ -55,6 +59,24 @@ describe('EmbeddedMpvEvents IPC handlers', () => { mockEmbeddedMpvService.setPaused.mockReset(); }); + it('checks support only after the login shell PATH lookup settled', async () => { + let settle: () => void = () => undefined; + mockWaitForLoginShellPath.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }) + ); + mockEmbeddedMpvService.getSupport.mockReturnValue({ supported: true }); + + const support = getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({}); + await new Promise((resolve) => setImmediate(resolve)); + // Linux caches the result of a bare-name `mpv --version`. + expect(mockEmbeddedMpvService.getSupport).not.toHaveBeenCalled(); + + settle(); + await expect(support).resolves.toEqual({ supported: true }); + }); + 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..3bb12024d 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,7 @@ import { embeddedMpvNativeService, } from '../services/embedded-mpv-native.service'; import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options'; +import { waitForLoginShellPath } from '../startup/login-shell-path'; export default class EmbeddedMpvEvents { static bootstrapEmbeddedMpvEvents(): Electron.IpcMain { @@ -56,6 +57,9 @@ function handleEmbeddedMpv( ): void { ipcMain.handle(channel, async (_event, ...args: unknown[]) => { try { + // Linux support checks run `mpv --version` by bare name and + // cache the result, so they must see the login shell PATH. + await waitForLoginShellPath(); return await handler(...(args as Args)); } catch (error) { console.error(`[Embedded MPV] ${channel} handler failed:`, error); 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..2e97a4c30 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -26,6 +26,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), })); @@ -53,6 +57,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 +466,51 @@ describe('openVlcPlayer', () => { }); }); +describe('external player launch handlers', () => { + beforeEach(() => { + (spawn as unknown as jest.Mock).mockReset(); + (store.get as unknown as jest.Mock).mockImplementation( + (_key: string, fallback?: unknown) => fallback + ); + }); + + it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])( + '%s spawns only after the login shell PATH lookup settled', + async (channel) => { + let settle: () => void = () => undefined; + (waitForLoginShellPath as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }) + ); + 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 new Promise((resolve) => setImmediate(resolve)); + expect(spawn).not.toHaveBeenCalled(); + + settle(); + await waitForSpawnCallCount(1); + proc.emit('error', new Error('spawn ENOENT')); + await launch; + } finally { + consoleErrorSpy.mockRestore(); + } + } + ); +}); + 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..416e9dbec 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -5,6 +5,7 @@ import { 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, @@ -53,8 +54,10 @@ ipcMain.handle( contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record - ) => - openMpvPlayer({ + ) => { + // A bare player name resolves through the login shell PATH. + await waitForLoginShellPath(); + return openMpvPlayer({ url, title, thumbnail, @@ -64,7 +67,8 @@ ipcMain.handle( contentInfo, startTime, headers, - }) + }); + } ); ipcMain.handle( @@ -95,8 +99,10 @@ ipcMain.handle( contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record - ) => - openVlcPlayer({ + ) => { + // A bare player name resolves through the login shell PATH. + await waitForLoginShellPath(); + return openVlcPlayer({ url, title, thumbnail, @@ -106,7 +112,8 @@ ipcMain.handle( contentInfo, startTime, headers, - }) + }); + } ); ipcMain.handle( 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 5563e70fa..9d5e2970e 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 @@ -60,6 +60,39 @@ describe('login shell PATH', () => { 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'); + + // A shell that never returns cannot block a launch forever. + const stuck = loadModule(); + stuck.scheduleDeferredFixPath(() => new Promise(() => undefined)); + await expect(stuck.waitForLoginShellPath(5)).resolves.toBeUndefined(); + }); + + it('lets spawns go immediately on Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await expect( + loadModule().waitForLoginShellPath(60_000) + ).resolves.toBeUndefined(); + }); + it('falls back to the paths fix-path used when the shell reports none', async () => { await loadModule().hydratePathFromLoginShell(async () => undefined); 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 921dbca68..c35fea1bf 100644 --- a/apps/electron-backend/src/app/startup/login-shell-path.ts +++ b/apps/electron-backend/src/app/startup/login-shell-path.ts @@ -16,8 +16,38 @@ import { traceStartupPhase } from '../services/debug-trace'; * 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; + }); + +/** Upper bound for a waiting spawn when the login shell never returns. */ +export const LOGIN_SHELL_PATH_WAIT_LIMIT_MS = 10_000; + +/** + * Resolves once the login shell PATH lookup has finished (successfully or + * not), at the latest after `limitMs`; immediately on Windows. + */ +export function waitForLoginShellPath( + limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS +): Promise { + let timer: NodeJS.Timeout | undefined; + const limit = new Promise((resolve) => { + timer = setTimeout(resolve, limitMs); + }); + return Promise.race([loginShellPathSettled, limit]).finally(() => + clearTimeout(timer) + ); +} export type ReadLoginShellPath = () => Promise; @@ -55,6 +85,7 @@ export function scheduleDeferredFixPath( }) .catch((error) => { console.warn('Login shell PATH lookup failed:', error); - }); + }) + .finally(() => settleLoginShellPath()); }); }