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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 22:48:54 +02:00
1 parent 565becf6dd
commit dcae8547e3
4 files changed
+150 -53

No files matched your search

@@ -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<void>((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<void>((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<void>((resolve) => {
settle = resolve;
})
);
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
});
const support = getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({});
await new Promise<void>((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 () => {
@@ -57,9 +57,6 @@ function handleEmbeddedMpv<Args extends unknown[]>(
): 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<Args extends unknown[]>(
});
}
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<T>(check: () => T): Promise<T> {
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,
@@ -467,46 +467,79 @@ 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();
(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<void>((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<void>((resolve) => {
settle = resolve;
});
await launchUntilSpawn(channel, wait, async () => {
await new Promise<void>((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<void>(() => undefined),
async () => {
await waitForSpawnCallCount(1);
expect(waitForLoginShellPath).not.toHaveBeenCalled();
}
);
}
);
});
@@ -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<void> {
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<string, string>
) => {
// 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<string, string>
) => {
// 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,