From dcae8547e311b18c86b500cf25cf69e7682b6d4f Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 22:48:54 +0200 Subject: [PATCH] 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 --- .../app/events/embedded-mpv.events.spec.ts | 59 +++++++++--- .../src/app/events/embedded-mpv.events.ts | 23 ++++- .../src/app/events/player.events.spec.ts | 91 +++++++++++++------ .../src/app/events/player.events.ts | 30 +++++- 4 files changed, 150 insertions(+), 53 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 87cc743d2..7d23e105c 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 @@ -59,22 +59,53 @@ 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 }); + describe('support checks and the login shell PATH', () => { + const originalPlatform = process.platform; - 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(); + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + mockWaitForLoginShellPath.mockClear(); + }); - settle(); - await expect(support).resolves.toEqual({ supported: true }); + it('on Linux, checks support only after the lookup settled', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + 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('elsewhere, and for session calls, never waits', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + mockEmbeddedMpvService.getSupport.mockReturnValue({ + supported: true, + }); + mockEmbeddedMpvService.createSession.mockReturnValue({ id: 's' }); + + await getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({}); + Object.defineProperty(process, 'platform', { value: 'linux' }); + 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 () => { 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 3bb12024d..f40d20008 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -57,9 +57,6 @@ 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); @@ -68,9 +65,25 @@ function handleEmbeddedMpv( }); } -handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => getService().getSupport()); +/** + * On Linux, support (and prepare, which checks support first) runs + * `mpv --version` by bare name and caches the result, so it must see the + * login shell PATH. Nothing else here spawns by name. + */ +async function afterLinuxLoginShellPath(check: () => T): Promise { + if (process.platform === 'linux') { + await waitForLoginShellPath(); + } + return check(); +} -handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon()); +handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => + afterLinuxLoginShellPath(() => getService().getSupport()) +); + +handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => + afterLinuxLoginShellPath(() => 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 2e97a4c30..eeef68397 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -467,46 +467,79 @@ 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(); - (store.get as unknown as jest.Mock).mockImplementation( - (_key: string, fallback?: unknown) => fallback - ); + (waitForLoginShellPath as jest.Mock).mockClear(); }); it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])( - '%s spawns only after the login shell PATH lookup settled', + '%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; - (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); + 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); - proc.emit('error', new Error('spawn ENOENT')); - await launch; - } finally { - consoleErrorSpy.mockRestore(); - } + }); + } + ); + + 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(); + } + ); } ); }); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 416e9dbec..99236ca12 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -11,7 +11,12 @@ import { store, VLC_PLAYER_PATH, } from '../services/store.service'; -import { normalizePlayerPathForStore } from './external-player-launch-context'; +import { + getDefaultMpvPath, + getDefaultVlcPath, + normalizeCustomPlayerPath, + normalizePlayerPathForStore, +} from './external-player-launch-context'; import { externalPlayerSessions, traceExternalPlayer, @@ -35,6 +40,17 @@ 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. + */ +async function waitForPathIfBareName(playerPath: string): Promise { + if (!/[\\/]/.test(playerPath)) { + await waitForLoginShellPath(); + } +} + export default class PlayerEvents { static bootstrapPlayerEvents(): Electron.IpcMain { return ipcMain; @@ -55,8 +71,10 @@ ipcMain.handle( startTime?: number, headers?: Record ) => { - // A bare player name resolves through the login shell PATH. - await waitForLoginShellPath(); + await waitForPathIfBareName( + normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ?? + getDefaultMpvPath() + ); return openMpvPlayer({ url, title, @@ -100,8 +118,10 @@ ipcMain.handle( startTime?: number, headers?: Record ) => { - // A bare player name resolves through the login shell PATH. - await waitForLoginShellPath(); + await waitForPathIfBareName( + normalizeCustomPlayerPath(store.get(VLC_PLAYER_PATH)) ?? + getDefaultVlcPath() + ); return openVlcPlayer({ url, title,