fix(electron): re-probe a missing mpv once a late login shell answers

When the PATH lookup runs out of budget, the Linux embedded MPV support
check probes `mpv --version` with the inherited PATH and caches a
missing result for the rest of the session. waitForLoginShellPath() now
reports whether the lookup settled; after a timed-out wait the handler
forgets a cached "missing" once the lookup finishes, so the next support
check probes again with the login shell PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-03 08:59:57 +02:00
1 parent 92127a81ac
commit b0a7554dc0
6 files changed
+110 -22

No files matched your search

@@ -9,6 +9,7 @@ const mockEmbeddedMpvService = {
prepareAddon: jest.fn(),
getSupport: jest.fn(),
willProbeLinuxMpvExecutable: jest.fn(() => false),
forgetMissingLinuxMpvExecutable: jest.fn(),
setPaused: jest.fn(),
};
const mockSessionOptions = {
@@ -23,9 +24,14 @@ jest.mock('../services/embedded-mpv-native.service', () => ({
jest.mock('../services/embedded-mpv-session-options', () => ({
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
}));
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve());
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
let settleLookup: () => void = () => undefined;
const mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
jest.mock('../startup/login-shell-path', () => ({
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
whenLoginShellPathSettled: () => mockLookupSettled,
}));
import { ipcMain } from 'electron';
@@ -77,9 +83,9 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
let settle: () => void = () => undefined;
let settle: (settled: boolean) => void = () => undefined;
mockWaitForLoginShellPath.mockReturnValueOnce(
new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => {
settle = resolve;
})
);
@@ -99,11 +105,37 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
mockEmbeddedMpvService.prepareAddon
).not.toHaveBeenCalled();
settle();
settle(true);
await expect(support).resolves.toEqual({ supported: true });
expect(
mockEmbeddedMpvService.forgetMissingLinuxMpvExecutable
).not.toHaveBeenCalled();
}
);
it('re-probes a missing mpv once a lookup that ran out finally answers', async () => {
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: false,
});
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).resolves.toEqual({ supported: false });
expect(
mockEmbeddedMpvService.forgetMissingLinuxMpvExecutable
).not.toHaveBeenCalled();
settleLookup();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
mockEmbeddedMpvService.forgetMissingLinuxMpvExecutable
).toHaveBeenCalledTimes(1);
});
it('does not wait when no probe runs, nor for session calls', async () => {
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
@@ -33,7 +33,10 @@ import {
embeddedMpvNativeService,
} from '../services/embedded-mpv-native.service';
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
import { waitForLoginShellPath } from '../startup/login-shell-path';
import {
waitForLoginShellPath,
whenLoginShellPathSettled,
} from '../startup/login-shell-path';
export default class EmbeddedMpvEvents {
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
@@ -71,10 +74,19 @@ function handleEmbeddedMpv<Args extends unknown[]>(
* login shell PATH. Only that probe waits; nothing else here spawns by name.
*/
async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
if (getService().willProbeLinuxMpvExecutable()) {
await waitForLoginShellPath();
if (
!getService().willProbeLinuxMpvExecutable() ||
(await waitForLoginShellPath())
) {
return check();
}
return check();
// The lookup ran out of budget, so this probe sees the inherited PATH.
// Once the shell does answer, a "missing" result is probed again.
const result = check();
void whenLoginShellPathSettled().then(() =>
getService().forgetMissingLinuxMpvExecutable()
);
return result;
}
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
@@ -394,6 +394,25 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
});
it('probes a missing mpv again after it is forgotten, but keeps a found one', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockClear();
mockSpawnSync.mockReturnValue({ status: 1 });
mockRuntimeUsable();
expect(service.getSupport().supported).toBe(false);
service.forgetMissingLinuxMpvExecutable();
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
mockSpawnSync.mockReturnValue({ status: 0 });
service.getSupport();
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
service.forgetMissingLinuxMpvExecutable();
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
});
it('predicts no probe for the frame-copy engine or native Wayland', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
@@ -393,6 +393,17 @@ export class EmbeddedMpvNativeService {
);
}
/**
* Drops a cached "mpv missing" result, so the next support check probes
* again (a found mpv stays cached). For a probe that ran before the
* login shell PATH arrived.
*/
forgetMissingLinuxMpvExecutable(): void {
if (this.cachedLinuxMpvExecutableReason) {
this.cachedLinuxMpvExecutableReason = undefined;
}
}
getSupport(): EmbeddedMpvSupport {
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
return {
@@ -84,12 +84,10 @@ describe('login shell PATH', () => {
it('ends every wait at the lookup budget, so a hung shell delays no later launch', async () => {
const stuck = loadModule();
stuck.scheduleDeferredFixPath(() => new Promise(() => undefined), 20);
await expect(stuck.waitForLoginShellPath()).resolves.toBeUndefined();
await expect(stuck.waitForLoginShellPath()).resolves.toBe(false);
// Past the budget a later launch goes at once (the test would time
// out on this one otherwise).
await expect(
stuck.waitForLoginShellPath(60_000)
).resolves.toBeUndefined();
await expect(stuck.waitForLoginShellPath(60_000)).resolves.toBe(false);
});
it('lets a launch retried within the budget wait for the PATH again', async () => {
@@ -110,15 +108,15 @@ describe('login shell PATH', () => {
// ...a retry still waits, and sees the PATH once the shell answers.
const retry = module.waitForLoginShellPath();
resolveShell('/opt/homebrew/bin');
await retry;
await expect(retry).resolves.toBe(true);
expect(process.env.PATH).toBe('/opt/homebrew/bin');
});
it('lets spawns go immediately on Windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await expect(
loadModule().waitForLoginShellPath(60_000)
).resolves.toBeUndefined();
await expect(loadModule().waitForLoginShellPath(60_000)).resolves.toBe(
true
);
});
it('falls back to the paths fix-path used when the shell reports none', async () => {
@@ -39,30 +39,46 @@ const loginShellPathSettled: Promise<void> =
export const LOGIN_SHELL_PATH_WAIT_LIMIT_MS = 10_000;
let loginShellPathDeadline: number | null = null;
let loginShellPathHasSettled = process.platform === 'win32';
const settledOutcome = loginShellPathSettled.then(() => {
loginShellPathHasSettled = true;
return true;
});
/**
* Resolves once the login shell PATH lookup has finished (successfully or
* not), or when its budget is spent; immediately on Windows. Before the
* lookup is scheduled, a wait lasts at most `limitMs`.
* lookup is scheduled, a wait lasts at most `limitMs`. Resolves to whether
* the lookup had finished: false means the caller runs on the inherited
* PATH and should not keep a negative result.
*/
export function waitForLoginShellPath(
limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
): Promise<void> {
): Promise<boolean> {
if (loginShellPathHasSettled) {
return Promise.resolve(true);
}
const remainingMs =
loginShellPathDeadline === null
? limitMs
: Math.min(limitMs, loginShellPathDeadline - Date.now());
if (remainingMs <= 0) {
return Promise.resolve();
return Promise.resolve(false);
}
let timer: NodeJS.Timeout | undefined;
const limit = new Promise<void>((resolve) => {
timer = setTimeout(resolve, remainingMs);
const limit = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), remainingMs);
});
return Promise.race([loginShellPathSettled, limit]).finally(() =>
return Promise.race([settledOutcome, limit]).finally(() =>
clearTimeout(timer)
);
}
/** Resolves when the lookup finishes, however long it takes. */
export function whenLoginShellPathSettled(): Promise<void> {
return loginShellPathSettled;
}
export type ReadLoginShellPath = () => Promise<string | undefined>;
const readLoginShellPath: ReadLoginShellPath = async () => {