diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts index e9d235011..9c551275f 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts @@ -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((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((resolve) => { + new Promise((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((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, diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index de4286ad7..dae18f1cb 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -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( * login shell PATH. Only that probe waits; nothing else here spawns by name. */ async function afterLoginShellPathIfProbing(check: () => T): Promise { - 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, () => diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 7b98e7d57..27b67ce48 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index dbccfd910..f5c3e7050 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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 { diff --git a/apps/electron-backend/src/app/startup/login-shell-path.spec.ts b/apps/electron-backend/src/app/startup/login-shell-path.spec.ts index 19d68e483..6ac78d8a6 100644 --- a/apps/electron-backend/src/app/startup/login-shell-path.spec.ts +++ b/apps/electron-backend/src/app/startup/login-shell-path.spec.ts @@ -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 () => { diff --git a/apps/electron-backend/src/app/startup/login-shell-path.ts b/apps/electron-backend/src/app/startup/login-shell-path.ts index d4c0ccea4..910d60b21 100644 --- a/apps/electron-backend/src/app/startup/login-shell-path.ts +++ b/apps/electron-backend/src/app/startup/login-shell-path.ts @@ -39,30 +39,46 @@ const loginShellPathSettled: Promise = 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 { +): Promise { + 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((resolve) => { - timer = setTimeout(resolve, remainingMs); + const limit = new Promise((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 { + return loginShellPathSettled; +} + export type ReadLoginShellPath = () => Promise; const readLoginShellPath: ReadLoginShellPath = async () => {