mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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());
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user