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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 22:41:49 +02:00
1 parent 4d88bb9d21
commit 565becf6dd
6 files changed
+154 -7

No files matched your search

@@ -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<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('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,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<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);
@@ -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<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);
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();
}
}
);
});
describe('buildVlcEnqueueCommands', () => {
it('clears the playlist and adds the URL with no extra options', () => {
expect(
@@ -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<string, string>
) =>
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<string, string>
) =>
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(
@@ -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);
@@ -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<void> =
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<void> {
let timer: NodeJS.Timeout | undefined;
const limit = new Promise<void>((resolve) => {
timer = setTimeout(resolve, limitMs);
});
return Promise.race([loginShellPathSettled, limit]).finally(() =>
clearTimeout(timer)
);
}
export type ReadLoginShellPath = () => Promise<string | undefined>;
@@ -55,6 +85,7 @@ export function scheduleDeferredFixPath(
})
.catch((error) => {
console.warn('Login shell PATH lookup failed:', error);
});
})
.finally(() => settleLoginShellPath());
});
}