mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
perf(electron): look up the login shell PATH without blocking the main thread
fix-path ran $SHELL -ilc env synchronously right after the first load. With a typical zsh profile that held the main thread for 1-2 s, while the database worker's ready message and the renderer's first IPC calls waited, so the launch journey's first card came that much later. Use shell-path's async shellPath() with fix-path's fallback, so the resulting PATH is the same and the main thread stays free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
adb4889b0f
commit
004b0a3a98
8 files changed
+198
-65
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
On macOS and Linux the app shows your sources sooner after launch: looking up
|
||||
the PATH from your login shell (used to find MPV and VLC) no longer freezes the
|
||||
app while the shell starts, which took one to two seconds with a typical zsh
|
||||
setup.
|
||||
@@ -6,7 +6,7 @@
|
||||
* main.ts loads this module through a dynamic import inside the main
|
||||
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
|
||||
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
|
||||
* electron-updater, fix-path) are evaluated while the renderer parses and
|
||||
* electron-updater) are evaluated while the renderer parses and
|
||||
* runs its own bundle instead of before the window can load at all.
|
||||
*
|
||||
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
|
||||
@@ -51,6 +51,9 @@ import {
|
||||
} from '../services/app-update-channel';
|
||||
import { databaseWorkerClient } from '../services/database-worker-client';
|
||||
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
|
||||
import { scheduleDeferredFixPath } from './login-shell-path';
|
||||
|
||||
export { scheduleDeferredFixPath };
|
||||
|
||||
export interface DeferredEventsContext {
|
||||
readonly appVersion: string;
|
||||
@@ -126,35 +129,6 @@ export async function finishStartupAfterFirstLoad(): Promise<void> {
|
||||
traceStartupPhase('reconcile-stale-recordings:done');
|
||||
}
|
||||
|
||||
let fixPathScheduled = false;
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name.
|
||||
*
|
||||
* Runs after window creation + IPC handler registration so the 50-300 ms
|
||||
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
|
||||
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
|
||||
* so its module evaluation stays off the launch path as well.
|
||||
*/
|
||||
export function scheduleDeferredFixPath(): void {
|
||||
if (fixPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fixPathScheduled = true;
|
||||
setImmediate(() => {
|
||||
import('fix-path')
|
||||
.then(({ default: fixPath }) => {
|
||||
fixPath();
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('fix-path failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Tears down sessions and the DB worker; safe when nothing was started. */
|
||||
export function shutdownDeferredServices(): void {
|
||||
shutdownEmbeddedMpv();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const shellPath = jest.fn<Promise<string | undefined>, []>();
|
||||
const shellPathSync = jest.fn<string | undefined, []>();
|
||||
|
||||
jest.mock('shell-path', () => ({ shellPath, shellPathSync }));
|
||||
jest.mock('../services/debug-trace', () => ({
|
||||
traceStartupPhase: jest.fn(),
|
||||
}));
|
||||
|
||||
type LoginShellPathModule = typeof import('./login-shell-path');
|
||||
|
||||
function loadModule(): LoginShellPathModule {
|
||||
let loaded: LoginShellPathModule | undefined;
|
||||
jest.isolateModules(() => {
|
||||
loaded = jest.requireActual('./login-shell-path');
|
||||
});
|
||||
return loaded as LoginShellPathModule;
|
||||
}
|
||||
|
||||
async function flushScheduled(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('login shell PATH', () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
shellPath.mockReset();
|
||||
shellPathSync.mockReset();
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.PATH = originalPath;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the login shell asynchronously, never through the blocking API', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
shellPath.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
loadModule().scheduleDeferredFixPath();
|
||||
await flushScheduled();
|
||||
|
||||
// The shell is still starting, and the main thread is free.
|
||||
expect(shellPath).toHaveBeenCalledTimes(1);
|
||||
expect(shellPathSync).not.toHaveBeenCalled();
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
|
||||
resolveShell('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
await flushScheduled();
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
it('falls back to the paths fix-path used when the shell reports none', async () => {
|
||||
await loadModule().hydratePathFromLoginShell(async () => undefined);
|
||||
|
||||
expect(process.env.PATH).toBe(
|
||||
'./node_modules/.bin:/.nodebrew/current/bin:/usr/local/bin:/usr/bin:/bin'
|
||||
);
|
||||
});
|
||||
|
||||
it('runs once, and not at all on Windows', async () => {
|
||||
const read = jest.fn(async () => '/custom/bin');
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(read);
|
||||
module.scheduleDeferredFixPath(read);
|
||||
await flushScheduled();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const windowsRead = jest.fn(async () => '/custom/bin');
|
||||
loadModule().scheduleDeferredFixPath(windowsRead);
|
||||
await flushScheduled();
|
||||
expect(windowsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current PATH when the lookup fails', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
// Expected failure.
|
||||
});
|
||||
loadModule().scheduleDeferredFixPath(async () => {
|
||||
throw new Error('shell exited');
|
||||
});
|
||||
await flushScheduled();
|
||||
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Login shell PATH lookup failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { traceStartupPhase } from '../services/debug-trace';
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name when the
|
||||
* app was started from Finder or a desktop launcher.
|
||||
*
|
||||
* The shell (`$SHELL -ilc env`) starts asynchronously. Its start-up cost
|
||||
* depends on the user's shell profile and was about 1-2 s with a typical
|
||||
* zsh setup; the synchronous `fix-path` used before blocked the main thread
|
||||
* for all of it, right when the database worker's `ready` message and the
|
||||
* renderer's first IPC calls were waiting, which delayed the first card of
|
||||
* the launch journey by the same amount. The resulting PATH, and the
|
||||
* fallback when the shell reports none, are the ones `fix-path` 5 produced.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
let loginShellPathScheduled = false;
|
||||
|
||||
export type ReadLoginShellPath = () => Promise<string | undefined>;
|
||||
|
||||
const readLoginShellPath: ReadLoginShellPath = async () => {
|
||||
const { shellPath } = await import('shell-path');
|
||||
return shellPath();
|
||||
};
|
||||
|
||||
export async function hydratePathFromLoginShell(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath
|
||||
): Promise<void> {
|
||||
const shellPath = await readPath();
|
||||
process.env.PATH =
|
||||
shellPath ||
|
||||
[
|
||||
'./node_modules/.bin',
|
||||
'/.nodebrew/current/bin',
|
||||
'/usr/local/bin',
|
||||
process.env.PATH,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function scheduleDeferredFixPath(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath
|
||||
): void {
|
||||
if (loginShellPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
loginShellPathScheduled = true;
|
||||
setImmediate(() => {
|
||||
hydratePathFromLoginShell(readPath)
|
||||
.then(() => {
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Login shell PATH lookup failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -184,12 +184,13 @@ export default class Main {
|
||||
traceStartupPhase('bootstrap-events:done');
|
||||
|
||||
// Hydrate process.env.PATH from the user's login shell now — after
|
||||
// the window has loaded and IPC handlers are live. Fire-and-forget
|
||||
// (setImmediate) so it doesn't gate any user-visible work. Worst
|
||||
// case: the user clicks an external player within the ~100 ms it
|
||||
// takes to complete; the spawn would still find MPV/VLC at any of
|
||||
// the well-known paths checked by getDefault*Path before falling
|
||||
// back to bare-name PATH lookup.
|
||||
// the window has loaded and IPC handlers are live. The shell runs
|
||||
// asynchronously, so the main thread keeps serving the renderer
|
||||
// while it starts (about 1-2 s with a typical zsh profile). Worst
|
||||
// case: the user clicks an external player before it completes;
|
||||
// the spawn would still find MPV/VLC at any of the well-known paths
|
||||
// checked by getDefault*Path before falling back to bare-name PATH
|
||||
// lookup.
|
||||
module.scheduleDeferredFixPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,17 @@ process before the first card.
|
||||
completion bumps `EpgSourceSettingsService.revision()`, the fence that
|
||||
keeps XMLTV lookups from returning data of a removed source.
|
||||
|
||||
- The main thread must stay free between the load and the first card. The
|
||||
renderer's first database read creates the database worker, and every
|
||||
request waits until the main process has handled the worker's `ready`
|
||||
message. Until #NNNN the login-shell PATH lookup (`fix-path`) ran right
|
||||
after `bootstrap-events:done` and spawned `$SHELL -ilc env` synchronously:
|
||||
on a Mac with a typical zsh profile it held the main thread for about
|
||||
1-2 s, the first `dbGetAppState` resolved about 2.3 s after spawn, and
|
||||
everything after it (about 20 ms of IPC) waited. It now runs the shell
|
||||
asynchronously (`startup/login-shell-path.ts`). On Linux runners bash
|
||||
starts in tens of milliseconds, so the effect there is small.
|
||||
|
||||
Validation (#1716, Principle 3): deferring the download list, update status
|
||||
and dashboard recent/favorites reads until after the first render took the
|
||||
counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor
|
||||
|
||||
+1
-1
@@ -127,7 +127,6 @@
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.5.0",
|
||||
"fix-path": "5.0.0",
|
||||
"hls.js": "1.7.1",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2",
|
||||
"marked": "18.0.11",
|
||||
@@ -139,6 +138,7 @@
|
||||
"rxjs": "7.8.2",
|
||||
"saxes": "6.0.0",
|
||||
"shaka-player": "5.2.4",
|
||||
"shell-path": "3.1.0",
|
||||
"video.js": "8.24.0",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-quality-selector-hls": "1.1.1",
|
||||
|
||||
Generated
+4
-28
@@ -145,9 +145,6 @@ importers:
|
||||
epg-parser:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
fix-path:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
hls.js:
|
||||
specifier: 1.7.1
|
||||
version: 1.7.1
|
||||
@@ -181,6 +178,9 @@ importers:
|
||||
shaka-player:
|
||||
specifier: 5.2.4
|
||||
version: 5.2.4
|
||||
shell-path:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
video.js:
|
||||
specifier: 8.24.0
|
||||
version: 8.24.0
|
||||
@@ -4774,10 +4774,6 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-regex@6.3.0:
|
||||
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6210,10 +6206,6 @@ packages:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
fix-path@5.0.0:
|
||||
resolution: {integrity: sha512-erEWGGCN7RIu1bXTCfNVpVBdm0f5mwcbeja+4QXiEZzIQukP401sbpu8gd3Ny1vS34YNeswyMO0TdT2tP5OlHA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
flat-cache@4.0.1:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -8864,7 +8856,6 @@ packages:
|
||||
|
||||
shaka-player@5.2.4:
|
||||
resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
shallow-clone@3.0.1:
|
||||
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
|
||||
@@ -9050,10 +9041,6 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -14260,8 +14247,6 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-regex@6.3.0: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -15972,11 +15957,6 @@ snapshots:
|
||||
locate-path: 6.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
fix-path@5.0.0:
|
||||
dependencies:
|
||||
shell-path: 3.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
@@ -19620,7 +19600,7 @@ snapshots:
|
||||
dependencies:
|
||||
default-shell: 2.2.0
|
||||
execa: 5.1.1
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
shell-path@3.1.0:
|
||||
dependencies:
|
||||
@@ -19812,10 +19792,6 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.3.0
|
||||
|
||||
Reference in new issue
Block a user