From 6fd55053d4fcf905a954332dfbe62286ba4d7dd7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 6 Nov 2026 18:37:44 -0800 Subject: [PATCH] fix(overlay): identify Windows mpv windows through IPC PID (#330) --- .github/workflows/quality-gate.yml | 3 + changes/windows-overlay-diagnostics-docs.md | 4 + changes/windows-overlay-pid-attachment.md | 6 + docs-site/troubleshooting.md | 11 ++ docs/architecture/domains.md | 10 + e2e/playback.e2e.test.ts | 39 ++++ .../overlay-visibility-diagnostics.test.ts | 176 +++++++++++++++++ .../overlay-visibility-diagnostics.ts | 153 ++++++++++++++ src/core/services/overlay-visibility.test.ts | 28 +++ src/core/services/overlay-visibility.ts | 12 +- src/window-trackers/mpv-process-id.test.ts | 93 +++++++++ src/window-trackers/mpv-process-id.ts | 67 +++++++ src/window-trackers/mpv-socket-match.test.ts | 72 ------- src/window-trackers/mpv-socket-match.ts | 41 ---- src/window-trackers/win32.ts | 116 +---------- src/window-trackers/windows-helper.test.ts | 74 ++++++- src/window-trackers/windows-helper.ts | 112 +++++++---- src/window-trackers/windows-tracker.test.ts | 186 ++++++++++++++++++ src/window-trackers/windows-tracker.ts | 104 +++++++++- 19 files changed, 1038 insertions(+), 269 deletions(-) create mode 100644 changes/windows-overlay-diagnostics-docs.md create mode 100644 changes/windows-overlay-pid-attachment.md create mode 100644 src/core/services/overlay-visibility-diagnostics.test.ts create mode 100644 src/core/services/overlay-visibility-diagnostics.ts create mode 100644 src/window-trackers/mpv-process-id.test.ts create mode 100644 src/window-trackers/mpv-process-id.ts delete mode 100644 src/window-trackers/mpv-socket-match.test.ts delete mode 100644 src/window-trackers/mpv-socket-match.ts diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 9339ff36..8d3af230 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -32,6 +32,9 @@ jobs: - name: Verify native mpv process launch run: bun test src/main/runtime/mpv-process.test.ts + - name: Verify mpv PID lookup and Windows tracker + run: bun test src/window-trackers/mpv-process-id.test.ts src/window-trackers/windows-tracker.test.ts + - name: Verify POSIX launcher bootstrap run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts diff --git a/changes/windows-overlay-diagnostics-docs.md b/changes/windows-overlay-diagnostics-docs.md new file mode 100644 index 00000000..24b7bd3d --- /dev/null +++ b/changes/windows-overlay-diagnostics-docs.md @@ -0,0 +1,4 @@ +type: docs +area: overlay + +- Explain how to collect and interpret Windows overlay attachment and readiness warnings without enabling debug logging. diff --git a/changes/windows-overlay-pid-attachment.md b/changes/windows-overlay-pid-attachment.md new file mode 100644 index 00000000..c3d446b8 --- /dev/null +++ b/changes/windows-overlay-pid-attachment.md @@ -0,0 +1,6 @@ +type: fixed +area: overlay + +- Windows overlay attachment now identifies mpv through its IPC process ID, removing the dependency on PowerShell command-line lookups and allowing the IPC socket to be configured in `mpv.conf`. +- Revalidate cached Windows mpv process IDs and expire them after sustained IPC failures so PID reuse cannot keep the overlay attached to an unrelated window. +- Diagnose stalled Windows overlays at the default warn log level, including tracking/PID state, renderer readiness, opacity, native binding errors, and recovery, with repeated warnings rate limited. diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 75612edd..44f95916 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -51,6 +51,17 @@ When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMin - Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process). - Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors). - macOS: grant Accessibility permission. See [macOS](#macos). +- Windows: leave the affected mpv window open for at least five seconds, then collect the daily + app log from the directory above. No debug setting is required: a stalled overlay logs + `Windows overlay is not visible after startup or tracking loss` with its PID, native window + handle, tracking state, geometry, opacity, and renderer readiness. The `reason` distinguishes + a missing mpv window, an unready renderer, a hidden window, and a transparent window. + This warning waits until mpv has answered on its IPC socket, so a requested overlay with no + mpv running stays quiet. PID lookup failures and native ownership or transparency errors also + appear at `warn`, and a PID lookup failure is the only trace if mpv never answers. + Repeated visibility/native-operation warnings are limited to one every 30 seconds; recovery + is recorded too. These logs diagnose attachment and readiness, but cannot prove that pixels + were drawn correctly by the graphics driver. ## Overlay is on the wrong monitor or position diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 3138cda9..58a04bb3 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -27,6 +27,16 @@ Read when: you need to find the owner module for a behavior or test surface subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts` Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts` + Windows window tracking: `src/window-trackers/windows-tracker.ts` queries the target mpv IPC + socket for its PID through `mpv-process-id.ts`, then `win32.ts` matches native windows by PID. + Visible matches are revalidated every 10 seconds; a PID expires after 30 seconds without a + successful IPC response. Missing windows retry at one-second intervals. Custom executable + names remain supported because matching uses the IPC PID. + `src/core/services/overlay-visibility-diagnostics.ts` watches requested Windows overlays: + after five seconds hidden or transparent it logs a warn-level snapshot, repeating at most + every 30 seconds. Intentional hiding, minimization and having no mpv answer on the socket + yet suppress these warnings; an identified mpv whose window never matches does not. PID lookup + failures and native ownership/transparency failures also log at warn, including recovery. - MPV runtime and protocol: `src/core/services/mpv*.ts` mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state: diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index 79cd0737..75cb8ed9 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -114,6 +114,45 @@ test( }, ); +// The other tests read the renderer over CDP, which works even if the window never appears. +// Windows keeps the overlay hidden until the tracker identifies mpv's window. +// Bun's node:test ignores the `skip` option, so this picks `test.skip` instead. +const testOnWindows = process.platform === 'win32' ? test : test.skip; +testOnWindows('visible overlay window appears over mpv', { timeout: TEST_TIMEOUT_MS }, async () => { + const { session } = await useSession(); + await session.runAppCommand('--show-visible-overlay'); + const mpvPid = await session.mpv.command('get_property', 'pid'); + assert.equal(typeof mpvPid, 'number'); + const main = await session.mainProcess(); + await waitUntil( + () => + main.evaluate( + `(() => { + const electron = process.mainModule.require('electron'); + const overlay = electron.BrowserWindow.getAllWindows().find( + (w) => w.webContents.getURL().includes(${JSON.stringify(OVERLAY_PAGE)})); + if (!overlay?.isVisible() || overlay.getOpacity() !== 1 || + overlay.__subminerOverlayContentReady !== true) return false; + // The inspector's mainModule can be Electron's bootstrap module. Resolve app + // dependencies from the repository instead of that module's search paths. + const appRequire = process.mainModule.require('node:module').createRequire( + process.mainModule.require('node:path').resolve('package.json')); + const koffi = appRequire('koffi'); + const getWindow = koffi.load('user32.dll').func( + 'intptr __stdcall GetWindow(intptr hwnd, uint uCmd)'); + const handle = overlay.getNativeWindowHandle(); + const hwnd = handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0); + const owner = getWindow(hwnd, 4); // GW_OWNER + const win32 = appRequire( + process.mainModule.require('node:path').resolve('dist/window-trackers/win32.js')); + return win32.findMpvWindows(${JSON.stringify(mpvPid)}).matches.some( + (match) => match.hwnd === owner); + })()`, + ), + { description: 'the visible overlay to be fully revealed and owned by the target mpv window' }, + ); +}); + test( 'seeking to another cue replaces the line and deinflects verbs to their headword', { timeout: TEST_TIMEOUT_MS }, diff --git a/src/core/services/overlay-visibility-diagnostics.test.ts b/src/core/services/overlay-visibility-diagnostics.test.ts new file mode 100644 index 00000000..87fda855 --- /dev/null +++ b/src/core/services/overlay-visibility-diagnostics.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createOverlayVisibilityDiagnostics, + readOverlayVisibilitySnapshot, + type OverlayVisibilitySnapshot, +} from './overlay-visibility-diagnostics'; + +type SnapshotArgs = Parameters[0]; + +function snapshotFor( + options: { + tracking?: boolean; + targetPid?: number | null; + minimized?: boolean; + window?: Record; + noTracker?: boolean; + } & Pick & { + visibleOverlayVisible?: boolean; + } = {}, +) { + const tracker = { + isTracking: () => options.tracking ?? false, + getGeometry: () => null, + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => options.minimized ?? false, + getDiagnosticState: () => ({ targetPid: options.targetPid ?? null, socketPath: '/tmp/mpv' }), + }; + return readOverlayVisibilitySnapshot({ + mainWindow: { + isDestroyed: () => false, + isVisible: () => false, + getOpacity: () => 0, + ...options.window, + } as unknown as SnapshotArgs['mainWindow'], + windowTracker: options.noTracker ? null : (tracker as unknown as SnapshotArgs['windowTracker']), + visibleOverlayVisible: options.visibleOverlayVisible ?? true, + modalActive: options.modalActive, + suspendVisibleOverlay: options.suspendVisibleOverlay, + }); +} + +function harness() { + let now = 0; + let callback: (() => void) | null = null; + let delay = 0; + const warnings: Array<{ message: string; details: Record }> = []; + let snapshot: OverlayVisibilitySnapshot | null = { + expectedVisible: true, + visible: false, + opacity: 0, + contentReady: false, + tracking: false, + targetPid: 4242, + pidLookupPending: false, + }; + const diagnostics = createOverlayVisibilityDiagnostics({ + now: () => now, + warn: (message, details) => warnings.push({ message, details }), + schedule: (fn, ms) => { + callback = fn; + delay = ms; + return { unref() {} } as ReturnType; + }, + cancel: () => { + callback = null; + }, + }); + const update = () => diagnostics.update(() => snapshot); + return { + warnings, + update, + setSnapshot: (value: typeof snapshot) => { + snapshot = value; + }, + tick: () => { + assert.ok(callback, 'watchdog must be scheduled'); + now += delay; + callback(); + }, + scheduled: () => callback !== null, + }; +} + +test('visibility watchdog warns without more events and rate limits repeated polls', () => { + const h = harness(); + h.update(); + assert.equal(h.warnings.length, 0); + for (let i = 0; i < 100; i += 1) h.update(); + h.tick(); + assert.equal(h.warnings.length, 1); + assert.equal(h.warnings[0]?.details.reason, 'mpv-window-not-tracked'); + assert.equal(h.warnings[0]?.details.targetPid, 4242); + assert.equal(h.warnings[0]?.details.elapsedMs, 5_000); + for (let i = 0; i < 100; i += 1) h.update(); + assert.equal(h.warnings.length, 1); + h.tick(); + assert.equal(h.warnings[1]?.details.elapsedMs, 35_000); +}); + +test('visibility watchdog distinguishes renderer readiness, hidden windows and opacity', () => { + for (const [contentReady, visible, opacity, reason] of [ + [false, false, 0, 'renderer-not-ready'], + [true, false, 0, 'window-hidden'], + [true, true, 0, 'window-transparent'], + ] as const) { + const h = harness(); + h.setSnapshot({ expectedVisible: true, tracking: true, contentReady, visible, opacity }); + h.update(); + h.tick(); + assert.equal(h.warnings[0]?.details.reason, reason); + } +}); + +test('visibility watchdog reports recovery only after warning and stops', () => { + const h = harness(); + h.update(); + h.tick(); + h.setSnapshot({ + expectedVisible: true, + visible: true, + opacity: 1, + contentReady: true, + tracking: true, + }); + h.tick(); + assert.match(h.warnings[1]!.message, /recovered/); + assert.equal(h.scheduled(), false); + h.update(); + assert.equal(h.warnings.length, 2); +}); + +test('visibility watchdog stays quiet after fast startup, intentional hide or window destruction', () => { + for (const snapshot of [ + { expectedVisible: true, visible: true, opacity: 1, contentReady: true, tracking: true }, + { expectedVisible: false, visible: false, opacity: 0, contentReady: false, tracking: false }, + null, + ]) { + const h = harness(); + h.update(); + h.setSnapshot(snapshot); + h.tick(); + assert.deepEqual(h.warnings, []); + assert.equal(h.scheduled(), false); + } +}); + +test('overlay snapshot expects visibility only once an mpv has been identified', () => { + // Requested before any mpv exists: auto_start_overlay, tray or --show-visible-overlay. + assert.equal(snapshotFor()?.expectedVisible, false); + assert.equal(snapshotFor({ noTracker: true })?.expectedVisible, false); + // mpv answered on the socket but its window never matched, so tracking never started. + assert.equal(snapshotFor({ targetPid: 4242 })?.expectedVisible, true); + // A tracker with no target socket matches windows without ever learning a PID. + assert.equal(snapshotFor({ tracking: true })?.expectedVisible, true); +}); + +test('overlay snapshot does not expect an intentionally hidden overlay', () => { + const identified = { targetPid: 4242, tracking: true }; + assert.equal(snapshotFor(identified)?.expectedVisible, true); + assert.equal(snapshotFor({ ...identified, modalActive: true })?.expectedVisible, false); + assert.equal(snapshotFor({ ...identified, suspendVisibleOverlay: true })?.expectedVisible, false); + assert.equal(snapshotFor({ ...identified, minimized: true })?.expectedVisible, false); + assert.equal( + snapshotFor({ ...identified, visibleOverlayVisible: false })?.expectedVisible, + false, + ); +}); + +test('overlay snapshot carries tracker state, defaults opacity and ends with the window', () => { + const snapshot = snapshotFor({ targetPid: 4242, window: { getOpacity: undefined } }); + assert.equal(snapshot?.targetPid, 4242); + assert.equal(snapshot?.socketPath, '/tmp/mpv'); + assert.equal(snapshot?.opacity, 1); + assert.equal(snapshotFor({ window: { isDestroyed: () => true } }), null); +}); diff --git a/src/core/services/overlay-visibility-diagnostics.ts b/src/core/services/overlay-visibility-diagnostics.ts new file mode 100644 index 00000000..314c6ad4 --- /dev/null +++ b/src/core/services/overlay-visibility-diagnostics.ts @@ -0,0 +1,153 @@ +import type { BrowserWindow } from 'electron'; +import type { BaseWindowTracker } from '../../window-trackers'; +import { createLogger } from '../../logger'; +import { + OVERLAY_WINDOW_CONTENT_READY_FLAG, + OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG, +} from './overlay-window-flags'; + +const log = createLogger('overlay:visibility'); + +export type OverlayVisibilitySnapshot = { + expectedVisible: boolean; + visible: boolean; + opacity: number; + contentReady: boolean; + tracking: boolean; + [key: string]: unknown; +}; + +// One watchdog per window, independent of further visibility events: a renderer +// that never becomes ready must still leave evidence at the default warn level. +export function createOverlayVisibilityDiagnostics(deps: { + warn: (message: string, details: Record) => void; + now: () => number; + schedule: (callback: () => void, delayMs: number) => ReturnType; + cancel: (timer: ReturnType) => void; +}) { + let timer: ReturnType | null = null; + let readSnapshot: () => OverlayVisibilitySnapshot | null = () => null; + let stalledAtMs: number | null = null; + let warned = false; + let lastWarningAtMs = 0; + + const check = () => { + if (timer !== null) deps.cancel(timer); + timer = null; + const snapshot = readSnapshot(); + if (!snapshot?.expectedVisible) { + stalledAtMs = null; + warned = false; + return; + } + if (snapshot.visible && snapshot.opacity > 0 && snapshot.contentReady) { + if (warned) { + deps.warn('Windows overlay visibility recovered', { + ...snapshot, + elapsedMs: deps.now() - stalledAtMs!, + }); + } + stalledAtMs = null; + warned = false; + return; + } + stalledAtMs ??= deps.now(); + const elapsedMs = deps.now() - stalledAtMs; + if (elapsedMs >= 5_000 && (!warned || deps.now() - lastWarningAtMs >= 30_000)) { + const reason = !snapshot.tracking + ? 'mpv-window-not-tracked' + : !snapshot.contentReady + ? 'renderer-not-ready' + : !snapshot.visible + ? 'window-hidden' + : 'window-transparent'; + deps.warn('Windows overlay is not visible after startup or tracking loss', { + ...snapshot, + reason, + elapsedMs, + }); + warned = true; + lastWarningAtMs = deps.now(); + } + timer = deps.schedule( + check, + warned ? 30_000 - (deps.now() - lastWarningAtMs) : 5_000 - elapsedMs, + ); + timer.unref?.(); + }; + + return { + update(reader: () => OverlayVisibilitySnapshot | null) { + readSnapshot = reader; + // Frequent polls must not postpone a warning or bypass its rate limit. + check(); + }, + }; +} + +const diagnosticsByWindow = new WeakMap< + BrowserWindow, + ReturnType +>(); + +type OverlayVisibilityWatchArgs = { + mainWindow: BrowserWindow; + windowTracker: BaseWindowTracker | null; + visibleOverlayVisible: boolean; + modalActive?: boolean; + suspendVisibleOverlay?: boolean; +}; + +export function readOverlayVisibilitySnapshot( + args: OverlayVisibilityWatchArgs, +): OverlayVisibilitySnapshot | null { + const window = args.mainWindow; + if (window.isDestroyed()) return null; + const tracker = args.windowTracker as + | (BaseWindowTracker & { + getDiagnosticState?: () => { targetPid?: number | null } & Record; + }) + | null; + const trackerState = tracker?.getDiagnosticState?.(); + const flags = window as unknown as Record; + const minimized = tracker?.isTargetWindowMinimized?.() ?? false; + const tracking = tracker?.isTracking() ?? false; + // The overlay may be requested with no mpv to attach to (auto_start_overlay, tray, + // --show-visible-overlay), so it only counts as stalled once an mpv has answered on the + // socket. That still covers an mpv whose window never matches, which never starts tracking. + const mpvIdentified = tracking || (trackerState?.targetPid ?? null) !== null; + return { + ...trackerState, + expectedVisible: + args.visibleOverlayVisible && + mpvIdentified && + !args.modalActive && + !args.suspendVisibleOverlay && + !minimized, + visible: window.isVisible(), + opacity: window.getOpacity?.() ?? 1, + contentReady: flags[OVERLAY_WINDOW_CONTENT_READY_FLAG] === true, + documentLoaded: flags[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === true, + rendererLoading: window.webContents?.isLoading?.() ?? null, + tracking, + trackerInitialized: tracker !== null, + geometry: tracker?.getGeometry() ?? null, + focused: tracker?.isTargetWindowFocused?.() ?? false, + minimized, + }; +} + +export function watchWindowsOverlayVisibility(args: OverlayVisibilityWatchArgs): void { + const window = args.mainWindow; + let diagnostics = diagnosticsByWindow.get(window); + if (!diagnostics) { + diagnostics = createOverlayVisibilityDiagnostics({ + warn: (message, details) => log.warn(message, details), + now: Date.now, + schedule: setTimeout, + cancel: clearTimeout, + }); + diagnosticsByWindow.set(window, diagnostics); + } + diagnostics.update(() => readOverlayVisibilitySnapshot(args)); +} diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 5e1b8009..9b856c82 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -589,6 +589,34 @@ runScenarios([ }, ]); +test('only a live Windows overlay reads visibility diagnostics', () => { + const diagnosticReads = (platform: Platform, overrides: Partial = {}): number => { + let reads = 0; + createHarness({ platform, tracker: tracked() }).run({ + windowTracker: { + isTracking: () => true, + getGeometry: () => GEOMETRY, + getDiagnosticState: () => { + reads += 1; + return { targetPid: 4242 }; + }, + } as unknown as VisibilityArgs['windowTracker'], + ...overrides, + }); + return reads; + }; + + assert.equal(diagnosticReads('windows'), 1); + assert.equal(diagnosticReads('linux'), 0); + assert.equal(diagnosticReads('macos'), 0); + assert.equal( + diagnosticReads('windows', { + mainWindow: { isDestroyed: () => true } as unknown as VisibilityArgs['mainWindow'], + }), + 0, + ); +}); + test('Windows visible overlay restores opacity and rebinds after the deferred reveal delay', async () => { const h = createHarness({ platform: 'windows', tracker: tracked() }); assertOutcome(h.run(), { opacity: 0, zOrderSyncs: 1 }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 5d540b21..f184d363 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -3,6 +3,7 @@ import { BaseWindowTracker } from '../../window-trackers'; import { WindowGeometry } from '../../types'; import { applyOverlayClickThrough } from './overlay-click-through'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; +import { watchWindowsOverlayVisibility } from './overlay-visibility-diagnostics'; const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48; const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap< @@ -65,7 +66,7 @@ function isOverlayWindowContentReady(window: BrowserWindow): boolean { ); } -export function updateVisibleOverlayVisibility(args: { +function applyVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; modalActive?: boolean; forceMousePassthrough?: boolean; @@ -453,6 +454,15 @@ export function updateVisibleOverlayVisibility(args: { args.syncOverlayShortcuts(); } +export function updateVisibleOverlayVisibility( + args: Parameters[0], +): void { + applyVisibleOverlayVisibility(args); + if (args.isWindowsPlatform && args.mainWindow && !args.mainWindow.isDestroyed()) { + watchWindowsOverlayVisibility({ ...args, mainWindow: args.mainWindow }); + } +} + export function setVisibleOverlayVisible(options: { visible: boolean; setVisibleOverlayVisibleState: (visible: boolean) => void; diff --git a/src/window-trackers/mpv-process-id.test.ts b/src/window-trackers/mpv-process-id.test.ts new file mode 100644 index 00000000..415b597e --- /dev/null +++ b/src/window-trackers/mpv-process-id.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { queryMpvProcessId } from './mpv-process-id'; + +// Runs a stand-in mpv IPC server that hands each request line to `onRequest`. +async function withFakeMpv( + onRequest: (socket: net.Socket, request: string) => void, + run: (socketPath: string) => Promise, +): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mpv-pid-')); + const socketPath = + process.platform === 'win32' + ? path.win32.join(String.raw`\\.\pipe`, path.basename(dir)) + : path.join(dir, 'mpv.sock'); + const server = net.createServer((socket) => { + socket.once('data', (data) => onRequest(socket, data.toString())); + }); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + await run(socketPath); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('queryMpvProcessId asks mpv for pid and returns the answer', async () => { + let request = ''; + await withFakeMpv( + (socket, line) => { + request = line; + socket.write('{"data":4242,"request_id":1,"error":"success"}\n'); + }, + async (socketPath) => { + assert.equal(await queryMpvProcessId(socketPath), 4242); + }, + ); + assert.deepEqual(JSON.parse(request), { command: ['get_property', 'pid'], request_id: 1 }); +}); + +test('queryMpvProcessId skips events and malformed lines, across split chunks', async () => { + await withFakeMpv( + (socket) => { + socket.write('{"event":"idle"}\nnot json\n{"data":42'); + setTimeout(() => socket.write('42,"request_id":1,"error":"success"}\n'), 10); + }, + async (socketPath) => { + assert.equal(await queryMpvProcessId(socketPath), 4242); + }, + ); +}); + +test('queryMpvProcessId rejects when mpv refuses the property', async () => { + await withFakeMpv( + (socket) => socket.write('{"request_id":1,"error":"property unavailable"}\n'), + async (socketPath) => { + await assert.rejects(queryMpvProcessId(socketPath), /no process ID/); + }, + ); +}); + +test('queryMpvProcessId rejects when nothing is listening', async () => { + const missing = + process.platform === 'win32' + ? path.win32.join(String.raw`\\.\pipe`, `mpv-pid-missing-${process.pid}`) + : path.join(os.tmpdir(), `mpv-pid-missing-${process.pid}.sock`); + await assert.rejects(queryMpvProcessId(missing)); +}); + +test('queryMpvProcessId rejects when mpv hangs up or never answers', async () => { + await withFakeMpv( + (socket) => socket.end(), + async (socketPath) => { + await assert.rejects(queryMpvProcessId(socketPath), /closed before mpv answered/); + }, + ); + + const held: net.Socket[] = []; + await withFakeMpv( + (socket) => held.push(socket), + async (socketPath) => { + await assert.rejects(queryMpvProcessId(socketPath, 20), /no answer within 20ms/); + held.forEach((socket) => socket.destroy()); + }, + ); +}); diff --git a/src/window-trackers/mpv-process-id.ts b/src/window-trackers/mpv-process-id.ts new file mode 100644 index 00000000..cda35c55 --- /dev/null +++ b/src/window-trackers/mpv-process-id.ts @@ -0,0 +1,67 @@ +import net from 'node:net'; + +const PID_REQUEST_ID = 1; +const PID_QUERY_TIMEOUT_MS = 5_000; + +// This runs inside a socket callback, where a parse error would be an uncaught exception. +function parseIpcLine(line: string): unknown { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +// Asks the mpv listening on `socketPath` for its process ID over a short-lived IPC connection. +export function queryMpvProcessId( + socketPath: string, + timeoutMs = PID_QUERY_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let buffered = ''; + + const fail = (message: string): void => { + socket.destroy(); + reject(new Error(message)); + }; + + socket.setEncoding('utf8'); + socket.setTimeout(timeoutMs, () => fail(`no answer within ${timeoutMs}ms`)); + socket.once('error', (error) => fail(error.message)); + socket.once('close', () => fail('connection closed before mpv answered')); + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ command: ['get_property', 'pid'], request_id: PID_REQUEST_ID })}\n`, + ); + }); + + socket.on('data', (chunk: string) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop() ?? ''; + + // mpv also streams events on this connection, so skip to the line answering the request. + for (const line of lines) { + const message = parseIpcLine(line); + if ( + typeof message !== 'object' || + message === null || + !('request_id' in message) || + message.request_id !== PID_REQUEST_ID + ) { + continue; + } + + const pid = 'data' in message ? message.data : undefined; + if (typeof pid === 'number') { + socket.destroy(); + resolve(pid); + } else { + fail(`mpv returned no process ID: ${line}`); + } + return; + } + }); + }); +} diff --git a/src/window-trackers/mpv-socket-match.test.ts b/src/window-trackers/mpv-socket-match.test.ts deleted file mode 100644 index e35e5eb6..00000000 --- a/src/window-trackers/mpv-socket-match.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - filterMpvPollResultBySocketPath, - matchesMpvSocketPathInCommandLine, -} from './mpv-socket-match'; -import type { MpvPollResult } from './win32'; - -function createPollResult(commandLines: Array): MpvPollResult { - return { - matches: commandLines.map((commandLine, index) => ({ - hwnd: index + 1, - bounds: { x: index * 10, y: 0, width: 1280, height: 720 }, - area: 1280 * 720, - isForeground: index === 0, - commandLine, - })), - focusState: true, - windowState: 'visible', - }; -} - -test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => { - assert.equal( - matchesMpvSocketPathInCommandLine( - 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv', - '\\\\.\\pipe\\subminer-a', - ), - true, - ); - assert.equal( - matchesMpvSocketPathInCommandLine( - 'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv', - '\\\\.\\pipe\\subminer-b', - ), - true, - ); - assert.equal( - matchesMpvSocketPathInCommandLine( - 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv', - '\\\\.\\pipe\\subminer-b', - ), - false, - ); -}); - -test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => { - const result = filterMpvPollResultBySocketPath( - createPollResult([ - 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv', - 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv', - null, - ]), - '\\\\.\\pipe\\subminer-b', - ); - - assert.deepEqual( - result.matches.map((match) => match.hwnd), - [2], - ); - assert.equal(result.windowState, 'visible'); -}); - -test('matchesMpvSocketPathInCommandLine rejects socket-path prefix matches', () => { - assert.equal( - matchesMpvSocketPathInCommandLine( - 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-10 video.mkv', - '\\\\.\\pipe\\subminer-1', - ), - false, - ); -}); diff --git a/src/window-trackers/mpv-socket-match.ts b/src/window-trackers/mpv-socket-match.ts deleted file mode 100644 index c8d893b9..00000000 --- a/src/window-trackers/mpv-socket-match.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { MpvPollResult } from './win32'; - -function escapeRegex(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function matchesMpvSocketPathInCommandLine( - commandLine: string, - targetSocketPath: string, -): boolean { - if (!commandLine || !targetSocketPath) { - return false; - } - - const escapedSocketPath = escapeRegex(targetSocketPath); - return new RegExp( - `(?:^|\\s)--input-ipc-server(?:=|\\s+)(?:"${escapedSocketPath}"|${escapedSocketPath})(?=\\s|$)`, - 'i', - ).test(commandLine); -} - -export function filterMpvPollResultBySocketPath( - result: MpvPollResult, - targetSocketPath?: string | null, -): MpvPollResult { - if (!targetSocketPath) { - return result; - } - - const matches = result.matches.filter( - (match) => - typeof match.commandLine === 'string' && - matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath), - ); - - return { - matches, - focusState: matches.some((match) => match.isForeground), - windowState: matches.length > 0 ? 'visible' : 'not-found', - }; -} diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts index 9468eaa5..c396f450 100644 --- a/src/window-trackers/win32.ts +++ b/src/window-trackers/win32.ts @@ -1,6 +1,4 @@ -import { execFile } from 'node:child_process'; import koffi from 'koffi'; -import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match'; const user32 = koffi.load('user32.dll'); const dwmapi = koffi.load('dwmapi.dll'); @@ -129,7 +127,6 @@ export interface MpvWindowMatch { bounds: WindowBounds; area: number; isForeground: boolean; - commandLine?: string | null; } export interface MpvPollResult { @@ -200,102 +197,9 @@ function pruneExpiredProcessNames(nowMs: number): void { } } -type ProcessCommandLineCacheEntry = - | { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean } - | { state: 'pending' } - | { state: 'failed'; retryAtMs: number; backoffMs: number }; - -const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000; -const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000; -// A process command line never changes, so a resolved entry only has to expire to survive -// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would -// otherwise match the wrong window forever). Longer than the process-name TTL because each -// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh -// runs, so expiry never interrupts window matching. -const COMMAND_LINE_CACHE_TTL_MS = 60_000; -const processCommandLineCache = new Map(); - -function queryProcessCommandLine( - pid: number, - onResult: (commandLine: string | null) => void, -): void { - execFile( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`, - ], - { - encoding: 'utf8', - windowsHide: true, - timeout: 1500, - }, - (error, stdout) => { - const output = error ? '' : stdout.trim(); - onResult(output.length > 0 ? output : null); - }, - ); -} - -// Resolves a process command line via a background PowerShell lookup. Returns null until the -// first lookup completes; the caller's next poll picks up the cached result. Failures are -// negative-cached with exponential backoff: the synchronous version of this lookup could -// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with -// the forward:true mouse hook) stalled mouse input system-wide. -function getProcessCommandLineByPid(pid: number): string | null { - const entry = processCommandLineCache.get(pid); - const nowMs = Date.now(); - - if (entry?.state === 'resolved') { - if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) { - entry.refreshInFlight = true; - queryProcessCommandLine(pid, (commandLine) => { - processCommandLineCache.set(pid, { - state: 'resolved', - // A failed refresh is usually a transient query error rather than a dead process - // (a gone process owns no window, so it is never looked up again). Keep the last - // known command line and re-check after the next TTL. - commandLine: commandLine ?? entry.commandLine, - expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS, - refreshInFlight: false, - }); - }); - } - return entry.commandLine; - } - - if (entry?.state === 'pending') return null; - if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null; - - const nextBackoffMs = - entry?.state === 'failed' - ? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS) - : COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS; - processCommandLineCache.set(pid, { state: 'pending' }); - queryProcessCommandLine(pid, (commandLine) => { - if (commandLine !== null) { - processCommandLineCache.set(pid, { - state: 'resolved', - commandLine, - expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS, - refreshInFlight: false, - }); - } else { - processCommandLineCache.set(pid, { - state: 'failed', - retryAtMs: Date.now() + nextBackoffMs, - backoffMs: nextBackoffMs, - }); - } - }); - return null; -} - -export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult { +// With a target PID only that process's windows match, whatever its executable is named. +// Without one, any process named mpv matches. +export function findMpvWindows(targetPid?: number | null): MpvPollResult { const foregroundHwnd = GetForegroundWindow(); const matches: MpvWindowMatch[] = []; let hasMinimized = false; @@ -310,15 +214,10 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult const pidValue = pid[0]!; if (pidValue === 0) return true; - const processName = getCachedProcessNameByPid(pidValue); - if (!processName || processName.toLowerCase() !== 'mpv') return true; - - let commandLine: string | null = null; - if (targetSocketPath) { - commandLine = getProcessCommandLineByPid(pidValue); - if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) { - return true; - } + if (targetPid != null) { + if (pidValue !== targetPid) return true; + } else if (getCachedProcessNameByPid(pidValue)?.toLowerCase() !== 'mpv') { + return true; } if (IsIconic(hwnd)) { @@ -337,7 +236,6 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult bounds, area: bounds.width * bounds.height, isForeground, - commandLine, }); return true; diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts index 88a0c4ab..f83da01a 100644 --- a/src/window-trackers/windows-helper.test.ts +++ b/src/window-trackers/windows-helper.test.ts @@ -1,8 +1,80 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { findWindowsMpvTargetWindowHandle } from './windows-helper'; +import { createWindowsOverlayOperations, findWindowsMpvTargetWindowHandle } from './windows-helper'; import type { MpvPollResult } from './win32'; +function overlayOperationsHarness() { + let nowMs = 0; + let failing = true; + const warnings: Array<{ message: string; operation: unknown }> = []; + const nativeCall = () => { + if (failing) throw new Error('native failure'); + }; + const operations = createWindowsOverlayOperations({ + loadWin32: () => ({ + setOverlayOwner: nativeCall, + ensureOverlayTransparency: nativeCall, + bindOverlayAboveMpv: nativeCall, + clearOverlayOwner: nativeCall, + }), + warn: (message, details) => warnings.push({ message, operation: details.operation }), + now: () => nowMs, + }); + return { + operations, + warnings, + setNow: (value: number) => { + nowMs = value; + }, + setFailing: (value: boolean) => { + failing = value; + }, + }; +} + +for (const [operation, call] of [ + ['set-owner', (ops) => ops.setOwner(1, 2)], + ['transparency', (ops) => ops.ensureTransparency(1)], + ['bind-above-mpv', (ops) => ops.bindAboveMpv(1, 2)], + ['clear-owner', (ops) => ops.clearOwner(1)], +] as const satisfies ReadonlyArray< + readonly [string, (ops: ReturnType) => boolean] +>) { + test(`${operation} reports failure, rate limits warnings and records recovery`, () => { + const h = overlayOperationsHarness(); + const failed = { message: 'Windows native overlay operation failed', operation }; + + assert.equal(call(h.operations), false); + assert.deepEqual(h.warnings, [failed]); + + h.setNow(29_999); + assert.equal(call(h.operations), false); + assert.deepEqual(h.warnings, [failed]); + + h.setNow(30_000); + assert.equal(call(h.operations), false); + assert.deepEqual(h.warnings, [failed, failed]); + + h.setFailing(false); + const recovered = { message: 'Windows native overlay operation recovered', operation }; + assert.equal(call(h.operations), true); + assert.deepEqual(h.warnings, [failed, failed, recovered]); + + assert.equal(call(h.operations), true); + assert.equal(h.warnings.length, 3); + }); +} + +test('native overlay operations rate limit independently', () => { + const h = overlayOperationsHarness(); + h.operations.setOwner(1, 2); + h.operations.clearOwner(1); + assert.deepEqual( + h.warnings.map((warning) => warning.operation), + ['set-owner', 'clear-owner'], + ); +}); + test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => { const result: MpvPollResult = { matches: [ diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts index 5a9ef40f..1b714ef6 100644 --- a/src/window-trackers/windows-helper.ts +++ b/src/window-trackers/windows-helper.ts @@ -17,11 +17,85 @@ */ import type { MpvPollResult } from './win32'; +import { createLogger } from '../logger'; + +const log = createLogger('overlay:windows'); + +type OverlayWin32 = Pick< + typeof import('./win32'), + 'setOverlayOwner' | 'ensureOverlayTransparency' | 'bindOverlayAboveMpv' | 'clearOverlayOwner' +>; + +// These run on every visibility update, so each operation warns at most every 30 seconds +// while it keeps failing, and once more when it recovers. +export function createWindowsOverlayOperations(deps: { + loadWin32: () => OverlayWin32; + warn: (message: string, details: Record) => void; + now: () => number; +}) { + const failures = new Map(); + + const run = ( + operation: string, + handles: { overlayHwnd: number; mpvHwnd?: number }, + action: (win32: OverlayWin32) => void, + ): boolean => { + try { + action(deps.loadWin32()); + if (failures.delete(operation)) { + deps.warn('Windows native overlay operation recovered', { operation, ...handles }); + } + return true; + } catch (error) { + const lastWarning = failures.get(operation); + if (lastWarning === undefined || deps.now() - lastWarning >= 30_000) { + deps.warn('Windows native overlay operation failed', { + operation, + ...handles, + error: error instanceof Error ? error.message : String(error), + }); + failures.set(operation, deps.now()); + } + return false; + } + }; + + return { + setOwner: (overlayHwnd: number, mpvHwnd: number): boolean => + run('set-owner', { overlayHwnd, mpvHwnd }, (win32) => { + win32.setOverlayOwner(overlayHwnd, mpvHwnd); + }), + ensureTransparency: (overlayHwnd: number): boolean => + run('transparency', { overlayHwnd }, (win32) => { + win32.ensureOverlayTransparency(overlayHwnd); + }), + bindAboveMpv: (overlayHwnd: number, mpvHwnd: number): boolean => + run('bind-above-mpv', { overlayHwnd, mpvHwnd }, (win32) => { + win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd); + win32.ensureOverlayTransparency(overlayHwnd); + }), + clearOwner: (overlayHwnd: number): boolean => + run('clear-owner', { overlayHwnd }, (win32) => { + win32.clearOverlayOwner(overlayHwnd); + }), + }; +} function loadWin32(): typeof import('./win32') { return require('./win32') as typeof import('./win32'); } +const overlayOperations = createWindowsOverlayOperations({ + loadWin32, + warn: (message, details) => log.warn(message, details), + now: Date.now, +}); + +export const setWindowsOverlayOwner = overlayOperations.setOwner; +export const ensureWindowsOverlayTransparency = overlayOperations.ensureTransparency; +export const bindWindowsOverlayAboveMpv = overlayOperations.bindAboveMpv; +export const clearWindowsOverlayOwner = overlayOperations.clearOwner; + export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null { const poll = result ?? loadWin32().findMpvWindows(); const focused = poll.matches.find((match) => match.isForeground); @@ -31,44 +105,6 @@ export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number return best?.hwnd ?? null; } -export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean { - try { - loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd); - return true; - } catch { - return false; - } -} - -export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean { - try { - loadWin32().ensureOverlayTransparency(overlayHwnd); - return true; - } catch { - return false; - } -} - -export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean { - try { - const win32 = loadWin32(); - win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd); - win32.ensureOverlayTransparency(overlayHwnd); - return true; - } catch { - return false; - } -} - -export function clearWindowsOverlayOwner(overlayHwnd: number): boolean { - try { - loadWin32().clearOverlayOwner(overlayHwnd); - return true; - } catch { - return false; - } -} - export function getWindowsForegroundProcessName(): string | null { try { return loadWin32().getForegroundProcessName(); diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts index 24afefb0..d7080ab7 100644 --- a/src/window-trackers/windows-tracker.test.ts +++ b/src/window-trackers/windows-tracker.test.ts @@ -265,3 +265,189 @@ test('WindowsWindowTracker keeps tracking through repeated transient minimized r assert.equal(tracker.isTargetWindowMinimized(), false); assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); }); + +const SOCKET_PATH = String.raw`\\.\pipe\subminer-socket`; +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +function pollOnce(tracker: WindowsWindowTracker): void { + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); +} + +test('WindowsWindowTracker follows the process that answers on the target socket', async () => { + const queriedSockets: string[] = []; + const polledPids: Array = []; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + queryMpvProcessId: async (socketPath) => { + queriedSockets.push(socketPath); + return 4242; + }, + pollMpvWindows: (targetPid) => { + polledPids.push(targetPid); + return mpvVisible(); + }, + }); + + pollOnce(tracker); + assert.equal(tracker.isTracking(), false); + + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(queriedSockets, [SOCKET_PATH]); + assert.deepEqual(polledPids, [4242]); +}); + +test('WindowsWindowTracker keeps polling while a process lookup is pending', async () => { + let queries = 0; + let answer: (pid: number) => void = () => {}; + let now = 1_000; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + now: () => now, + queryMpvProcessId: () => { + queries += 1; + return new Promise((resolve) => { + answer = resolve; + }); + }, + pollMpvWindows: () => mpvVisible(), + }); + + for (let poll = 0; poll < 120; poll += 1) { + pollOnce(tracker); + now += 250; + } + assert.equal(tracker.isTracking(), false); + assert.equal(queries, 1); + + answer(4242); + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); +}); + +test('WindowsWindowTracker retries a failed lookup at a limited rate', async () => { + let queries = 0; + let now = 1_000; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + now: () => now, + queryMpvProcessId: async () => { + queries += 1; + if (queries === 1) throw new Error('connect ENOENT'); + return 4242; + }, + pollMpvWindows: () => mpvVisible(), + }); + + pollOnce(tracker); + await flushPromises(); + now += 250; + pollOnce(tracker); + await flushPromises(); + assert.equal(queries, 1); + + now += 1_000; + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(queries, 2); + assert.equal(tracker.isTracking(), true); +}); + +test('WindowsWindowTracker moves to the new process when mpv restarts on the same socket', async () => { + let currentPid = 1111; + let now = 1_000; + let queries = 0; + const polledPids: Array = []; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + now: () => now, + queryMpvProcessId: async () => { + queries += 1; + return currentPid; + }, + // Only the live process still owns a window. + pollMpvWindows: (targetPid) => { + polledPids.push(targetPid); + return targetPid === currentPid ? mpvVisible() : mpvNotFound; + }, + }); + + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); + + currentPid = 2222; + now += 2_000; + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 0, y: 0, width: 1280, height: 720 }); + assert.equal(polledPids.at(-1), 2222); + assert.equal(queries, 2); +}); + +test('WindowsWindowTracker revalidates a PID even while its window remains visible', async () => { + let currentPid = 1111; + let now = 0; + const polledPids: Array = []; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + now: () => now, + queryMpvProcessId: async () => currentPid, + // Simulates PID reuse: the old PID still has a visible, unrelated window. + pollMpvWindows: (pid) => { + polledPids.push(pid); + return mpvVisible(); + }, + }); + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + currentPid = 2222; + now = 10_000; + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(polledPids.at(-1), 2222); +}); + +test('WindowsWindowTracker expires an unvalidated PID and recovers after IPC returns', async () => { + let available = true; + let now = 0; + const polledPids: Array = []; + const tracker = new WindowsWindowTracker(SOCKET_PATH, { + now: () => now, + queryMpvProcessId: async () => { + if (!available) throw new Error('IPC unavailable'); + return 4242; + }, + pollMpvWindows: (pid) => { + polledPids.push(pid); + return mpvVisible(); + }, + trackingLossGraceMs: 0, + maxConsecutiveMisses: 1, + }); + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); + available = false; + now = 10_000; + pollOnce(tracker); + await flushPromises(); + assert.equal(tracker.isTracking(), true); + polledPids.length = 0; + now = 30_000; + pollOnce(tracker); + await flushPromises(); + assert.equal(tracker.isTracking(), false); + assert.deepEqual(polledPids, []); + available = true; + now += 1_000; + pollOnce(tracker); + await flushPromises(); + pollOnce(tracker); + assert.equal(tracker.isTracking(), true); + assert.equal(polledPids.at(-1), 4242); +}); diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index 5b695c03..7cfeb1e6 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -19,27 +19,35 @@ import { BaseWindowTracker } from './base-tracker'; import type { WindowGeometry } from '../types'; import type { MpvPollResult } from './win32'; +import { queryMpvProcessId } from './mpv-process-id'; import { createLogger } from '../logger'; const log = createLogger('tracker').child('windows'); +const MPV_PID_LOOKUP_INTERVAL_MS = 1_000; +const MPV_PID_REVALIDATE_INTERVAL_MS = 10_000; +const MPV_PID_MAX_AGE_MS = 30_000; +const NO_MPV_WINDOW: MpvPollResult = { matches: [], focusState: false, windowState: 'not-found' }; + type WindowsTrackerDeps = { - pollMpvWindows?: () => MpvPollResult; + pollMpvWindows?: (targetPid: number | null) => MpvPollResult; + queryMpvProcessId?: (socketPath: string) => Promise; maxConsecutiveMisses?: number; trackingLossGraceMs?: number; minimizedTrackingLossGraceMs?: number; now?: () => number; }; -function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult { +function defaultPollMpvWindows(targetPid: number | null): MpvPollResult { const win32 = require('./win32') as typeof import('./win32'); - return win32.findMpvWindows(_targetMpvSocketPath); + return win32.findMpvWindows(targetPid); } export class WindowsWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private pollInFlight = false; - private readonly pollMpvWindows: () => MpvPollResult; + private readonly pollMpvWindows: (targetPid: number | null) => MpvPollResult; + private readonly queryMpvProcessId: (socketPath: string) => Promise; private readonly maxConsecutiveMisses: number; private readonly trackingLossGraceMs: number; private readonly minimizedTrackingLossGraceMs: number; @@ -51,12 +59,17 @@ export class WindowsWindowTracker extends BaseWindowTracker { private targetWindowMinimized = false; private readonly targetMpvSocketPath: string | null; private currentTargetWindowHwnd: number | null = null; + private targetMpvPid: number | null = null; + private pidLookupInFlight = false; + private lastPidLookupAtMs: number | null = null; + private lastPidConfirmedAtMs: number | null = null; + private pidLookupFailureLogged = false; constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { super(); this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null; - this.pollMpvWindows = - deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath)); + this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows; + this.queryMpvProcessId = deps.queryMpvProcessId ?? queryMpvProcessId; this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2)); this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500)); this.minimizedTrackingLossGraceMs = Math.max( @@ -86,6 +99,16 @@ export class WindowsWindowTracker extends BaseWindowTracker { return this.currentTargetWindowHwnd; } + getDiagnosticState() { + return { + socketPath: this.targetMpvSocketPath, + targetPid: this.targetMpvPid, + targetHwnd: this.currentTargetWindowHwnd, + pidLookupPending: this.pidLookupInFlight, + pidAgeMs: this.lastPidConfirmedAtMs === null ? null : this.now() - this.lastPidConfirmedAtMs, + }; + } + private maybeLogPollError(error: Error): void { const now = Date.now(); const fingerprint = error.message; @@ -142,12 +165,79 @@ export class WindowsWindowTracker extends BaseWindowTracker { }; } + // Asks the mpv on the target socket which process it is. Runs in the background so a slow + // answer never stalls the poll, which shares the main thread with the mouse hook. + private refreshTargetMpvPid(socketPath: string, intervalMs = MPV_PID_LOOKUP_INTERVAL_MS): void { + const nowMs = this.now(); + if ( + this.pidLookupInFlight || + (this.lastPidLookupAtMs !== null && nowMs - this.lastPidLookupAtMs < intervalMs) + ) { + return; + } + + this.pidLookupInFlight = true; + this.lastPidLookupAtMs = nowMs; + this.queryMpvProcessId(socketPath) + .then( + (pid) => { + this.targetMpvPid = pid; + this.lastPidConfirmedAtMs = this.now(); + if (this.pidLookupFailureLogged) { + log.warn('Windows mpv PID lookup recovered', this.getDiagnosticState()); + } + this.pidLookupFailureLogged = false; + }, + (error: unknown) => { + // The overlay stays hidden until mpv is identified, so leave a trace of why it isn't. + if (this.pidLookupFailureLogged) return; + this.pidLookupFailureLogged = true; + log.warn('Windows mpv PID lookup failed; overlay attachment may be unavailable', { + ...this.getDiagnosticState(), + error: error instanceof Error ? error.message : String(error), + }); + }, + ) + .finally(() => { + this.pidLookupInFlight = false; + }); + } + + private pollTargetWindows(): MpvPollResult { + const socketPath = this.targetMpvSocketPath; + if (!socketPath) return this.pollMpvWindows(null); + + // Windows can reuse a PID for an unrelated visible window. A cached match must + // remain tied to the process answering on our socket, even while it is visible. + if ( + this.lastPidConfirmedAtMs !== null && + this.now() - this.lastPidConfirmedAtMs >= MPV_PID_MAX_AGE_MS + ) { + this.targetMpvPid = null; + } + + if (this.targetMpvPid === null) { + this.refreshTargetMpvPid(socketPath); + return NO_MPV_WINDOW; + } + + const result = this.pollMpvWindows(this.targetMpvPid); + // A new mpv may have taken over the socket after the known process exited. + this.refreshTargetMpvPid( + socketPath, + result.windowState === 'not-found' + ? MPV_PID_LOOKUP_INTERVAL_MS + : MPV_PID_REVALIDATE_INTERVAL_MS, + ); + return result; + } + private pollGeometry(): void { if (this.pollInFlight) return; this.pollInFlight = true; try { - const result = this.pollMpvWindows(); + const result = this.pollTargetWindows(); const best = this.selectBestMatch(result); if (best) {