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:
4grayandClaude Opus 5.5 committed 2026-10-01 06:44:56 +02:00
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);
});
});
}
+7 -6
View File
@@ -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();
}
}
+11
View File
@@ -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
View File
@@ -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",
+4 -28
View File
@@ -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