From c18644392dcc6f5c43d67192a1edac739fd16467 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 11 Oct 2026 01:01:36 -0700 Subject: [PATCH] fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289) --- changes/tracker-async-poll.md | 5 + src/window-trackers/hyprland-tracker.test.ts | 120 +++++++++++++++++-- src/window-trackers/hyprland-tracker.ts | 118 +++++++++++------- src/window-trackers/linux-poll.ts | 113 +++++++++++++++++ src/window-trackers/sway-tracker.test.ts | 23 ++++ src/window-trackers/sway-tracker.ts | 93 ++++++++------ 6 files changed, 378 insertions(+), 94 deletions(-) create mode 100644 changes/tracker-async-poll.md create mode 100644 src/window-trackers/linux-poll.ts create mode 100644 src/window-trackers/sway-tracker.test.ts diff --git a/changes/tracker-async-poll.md b/changes/tracker-async-poll.md new file mode 100644 index 00000000..21a116de --- /dev/null +++ b/changes/tracker-async-poll.md @@ -0,0 +1,5 @@ +type: fixed +area: overlay + +- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up. +- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll. diff --git a/src/window-trackers/hyprland-tracker.test.ts b/src/window-trackers/hyprland-tracker.test.ts index aaeace24..0683b9f6 100644 --- a/src/window-trackers/hyprland-tracker.test.ts +++ b/src/window-trackers/hyprland-tracker.test.ts @@ -179,21 +179,119 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful }); }); -test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', () => { +type PendingCommand = { + command: string; + args: string[]; + resolve: (output: string) => void; +}; + +// Runner whose commands stay pending until the test resolves them. +function makeDeferredRunner() { + const pending: PendingCommand[] = []; + const runCommand = (command: string, args: string[]) => + new Promise((resolve) => { + pending.push({ command, args, resolve }); + }); + return { pending, runCommand }; +} + +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', async () => { const calls: string[] = []; - const tracker = new HyprlandWindowTracker(); - const privateTracker = tracker as unknown as { - handleSocketEvent: (event: string) => void; - pollGeometry: () => void; - }; - privateTracker.pollGeometry = () => { - calls.push('poll'); - }; + const tracker = new HyprlandWindowTracker(undefined, async (command, args) => { + calls.push(`${command} ${args.join(' ')}`); + return '[]'; + }); tracker.onWindowFocusChange = (focused) => { calls.push(`focus:${focused}`); }; - privateTracker.handleSocketEvent('activewindowv2>>0xmpv'); + (tracker as unknown as { handleSocketEvent: (event: string) => void }).handleSocketEvent( + 'activewindowv2>>0xmpv', + ); + await flush(); - assert.deepEqual(calls, ['poll', 'focus:false']); + assert.deepEqual(calls, ['hyprctl -j clients', 'focus:false']); +}); + +test('HyprlandWindowTracker coalesces polls requested while hyprctl is still running', async () => { + const { pending, runCommand } = makeDeferredRunner(); + const tracker = new HyprlandWindowTracker(undefined, runCommand); + + const polls = [tracker.refreshNow(), tracker.refreshNow(), tracker.refreshNow()]; + await flush(); + assert.equal(pending.length, 1); + + pending[0]!.resolve(JSON.stringify([makeClient({ at: [10, 20] })])); + await flush(); + assert.equal(pending.length, 2); + + pending[1]!.resolve(JSON.stringify([makeClient({ at: [30, 40] })])); + await Promise.all(polls); + assert.equal(pending.length, 2); + assert.deepEqual(tracker.getGeometry(), { x: 30, y: 40, width: 1280, height: 720 }); +}); + +test('HyprlandWindowTracker ignores a poll that resolves after stop', async () => { + const { pending, runCommand } = makeDeferredRunner(); + const tracker = new HyprlandWindowTracker(undefined, runCommand); + let found = 0; + tracker.onWindowFound = () => { + found += 1; + }; + + const poll = tracker.refreshNow(); + tracker.stop(); + await flush(); + pending[0]!.resolve(JSON.stringify([makeClient()])); + await poll; + + assert.equal(found, 0); + assert.equal(tracker.getGeometry(), null); +}); + +test('HyprlandWindowTracker reads each mpv command line once and re-reads after the pid leaves', async () => { + let clients = [makeClient({ pid: 10 })]; + const psPids: string[] = []; + const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => { + if (command === 'hyprctl') { + return JSON.stringify(clients); + } + psPids.push(args[1]!); + return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n'; + }); + + await tracker.refreshNow(); + await tracker.refreshNow(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(psPids, ['10']); + + clients = []; + await tracker.refreshNow(); + clients = [makeClient({ pid: 10 })]; + await tracker.refreshNow(); + assert.deepEqual(psPids, ['10', '10']); +}); + +test('HyprlandWindowTracker still tracks mpv when ps fails for another pid, and retries that pid', async () => { + const psPids: string[] = []; + const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => { + if (command === 'hyprctl') { + return JSON.stringify([makeClient({ pid: 10 }), makeClient({ pid: 20 })]); + } + psPids.push(args[1]!); + if (args[1] === '20') { + throw new Error('ps exited 1'); + } + return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n'; + }); + + await tracker.refreshNow(); + assert.equal(tracker.isTracking(), true); + + await tracker.refreshNow(); + assert.deepEqual(psPids.sort(), ['10', '20', '20']); }); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 664db2fc..cad70d52 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -17,8 +17,13 @@ */ import * as net from 'net'; -import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; +import { + createCoalescedTask, + execFileWithTimeout, + ProcessCommandLineCache, + type CommandRunner, +} from './linux-poll'; import { createLogger } from '../logger'; import type { WindowGeometry } from '../types'; @@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean { return value.trim().toLowerCase().includes('mpv'); } -export function selectHyprlandMpvWindow( - clients: HyprlandClient[], - options: SelectHyprlandMpvWindowOptions, -): HyprlandClient | null { - const visibleMpvWindows = clients.filter( +function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] { + return clients.filter( (client) => (isMpvClassName(client.class) || isMpvClassName(client.initialClass)) && client.mapped !== false && client.hidden !== true, ); +} + +export function selectHyprlandMpvWindow( + clients: HyprlandClient[], + options: SelectHyprlandMpvWindowOptions, +): HyprlandClient | null { + const visibleMpvWindows = filterVisibleMpvWindows(clients); if (!options.targetMpvSocketPath) { return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress); @@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker { private pollTimeouts: Array> = []; private eventSocket: net.Socket | null = null; private readonly targetMpvSocketPath: string | null; + private readonly runCommand: CommandRunner; + private readonly commandLines: ProcessCommandLineCache; private activeWindowAddress: string | null = null; + private stopped = false; + // Interval ticks, focus events, and geometry bursts all funnel through here, + // so a slow hyprctl never has more than one poll in flight. + private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce()); - constructor(targetMpvSocketPath?: string) { + constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.runCommand = runCommand; + this.commandLines = new ProcessCommandLineCache(runCommand); } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); - this.pollGeometry(); + this.stopped = false; + this.pollInterval = setInterval(() => void this.pollGeometry(), 250); + void this.pollGeometry(); this.connectEventSocket(); } stop(): void { + this.stopped = true; if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; @@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } } + override refreshNow(): Promise { + return this.pollGeometry(); + } + private connectEventSocket(): void { const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE; if (!hyprlandSig) { @@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker { if (name === 'activewindowv2') { const wasFocused = this.isTargetWindowFocused(); this.activeWindowAddress = data || null; - this.pollGeometry(); - if (this.isTargetWindowFocused() === wasFocused) { - this.onWindowFocusChange?.(this.isTargetWindowFocused()); - } + void this.pollGeometry().then(() => { + // Re-emit unchanged focus so the overlay still refreshes its z-order. + if (!this.stopped && this.isTargetWindowFocused() === wasFocused) { + this.onWindowFocusChange?.(this.isTargetWindowFocused()); + } + }); return; } @@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker { this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => { const pollTimeout = setTimeout(() => { this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout); - this.pollGeometry(); + void this.pollGeometry(); }, delayMs); return pollTimeout; }); @@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } } - private pollGeometry(): void { - try { - const output = execSync('hyprctl -j clients', { encoding: 'utf-8' }); - const clients = parseHyprctlClients(output); - if (!clients) { - this.updateGeometry(null); - return; - } - const mpvWindow = this.findTargetWindow(clients); + private async pollGeometryOnce(): Promise { + if (this.stopped) { + return; + } + let mpvWindow: HyprlandClient | null; + let monitors: HyprlandMonitor[] | null = null; + try { + const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients'])); + mpvWindow = clients ? await this.findTargetWindow(clients) : null; if (mpvWindow) { - const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress; - this.updateGeometry( - resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)), - focused, - ); - this.updateTargetWindowFocused(focused); - } else { - this.updateGeometry(null); + monitors = await this.getHyprlandMonitors(mpvWindow); } - } catch (err) { - // hyprctl not available or failed - silent fail + } catch { + // hyprctl/ps not available, timed out, or failed - keep the last geometry + return; + } + if (this.stopped) { + return; + } + + if (mpvWindow) { + const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress; + this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused); + this.updateTargetWindowFocused(focused); + } else { + this.updateGeometry(null); } } - private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { + private async findTargetWindow(clients: HyprlandClient[]): Promise { + // Resolve command lines up front so window selection itself stays synchronous. + const commandLines = this.targetMpvSocketPath + ? await this.commandLines.lookup( + filterVisibleMpvWindows(clients).flatMap((client) => (client.pid ? [client.pid] : [])), + ) + : null; return selectHyprlandMpvWindow(clients, { targetMpvSocketPath: this.targetMpvSocketPath, activeWindowAddress: this.activeWindowAddress, - getWindowCommandLine: (pid) => this.getWindowCommandLine(pid), + getWindowCommandLine: (pid) => commandLines?.get(pid) ?? null, }); } - private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null { + private async getHyprlandMonitors(client: HyprlandClient): Promise { if (!isHyprlandFullscreenClient(client)) { return null; } try { - const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' }); - return parseHyprctlMonitors(output); + return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors'])); } catch { return null; } } - - private getWindowCommandLine(pid: number): string | null { - const commandLine = execSync(`ps -p ${pid} -o args=`, { - encoding: 'utf-8', - }).trim(); - return commandLine || null; - } } diff --git a/src/window-trackers/linux-poll.ts b/src/window-trackers/linux-poll.ts new file mode 100644 index 00000000..c485f9d4 --- /dev/null +++ b/src/window-trackers/linux-poll.ts @@ -0,0 +1,113 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { execFile } from 'child_process'; + +// Shared polling helpers for the Hyprland and Sway trackers, which poll a +// compositor CLI on a short interval from the Electron main thread. + +export type CommandRunner = (command: string, args: string[]) => Promise; + +const COMMAND_TIMEOUT_MS = 1500; + +// Runs a command off the main thread. The timeout (SIGKILL, so a wedged client +// can't ignore it) keeps a hung compositor from stalling the poll loop forever. +export function execFileWithTimeout(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS, killSignal: 'SIGKILL' }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +// Wraps an async task so overlapping calls coalesce: at most one run is in +// flight, and every call made during it shares a single follow-up run. The +// returned promise settles after a run that started no earlier than the call. +export function createCoalescedTask(task: () => Promise): () => Promise { + let active: Promise | null = null; + let queued: Promise | null = null; + + const run = (): Promise => { + const current: Promise = task().finally(() => { + if (active === current) { + active = null; + } + }); + active = current; + return current; + }; + + return () => { + if (queued) { + return queued; + } + if (!active) { + return run(); + } + const next = (): Promise => { + queued = null; + return run(); + }; + queued = active.then(next, next); + return queued; + }; +} + +// Caches process command lines by pid. A pid's argv doesn't change, so each +// pid costs one `ps` spawn. Pids missing from the latest lookup are evicted, +// which keeps the cache bounded and re-reads a pid if it gets reused. +export class ProcessCommandLineCache { + private readonly commandLines = new Map(); + + constructor(private readonly runCommand: CommandRunner) {} + + // A pid whose `ps` call fails (e.g. it exited mid-poll) is left out of the + // result instead of failing the lookup. Empty results aren't cached, so that + // pid is retried on the next poll. + async lookup(pids: readonly number[]): Promise> { + const wanted = new Set(pids); + for (const pid of this.commandLines.keys()) { + if (!wanted.has(pid)) { + this.commandLines.delete(pid); + } + } + + await Promise.all( + [...wanted] + .filter((pid) => !this.commandLines.has(pid)) + .map(async (pid) => { + const commandLine = ( + await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']).catch(() => '') + ).trim(); + if (commandLine) { + this.commandLines.set(pid, commandLine); + } + }), + ); + return this.commandLines; + } +} diff --git a/src/window-trackers/sway-tracker.test.ts b/src/window-trackers/sway-tracker.test.ts new file mode 100644 index 00000000..5199c01d --- /dev/null +++ b/src/window-trackers/sway-tracker.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SwayWindowTracker } from './sway-tracker'; + +test('SwayWindowTracker tracks the mpv window bound to the target socket', async () => { + const tree = { + nodes: [ + { pid: 10, app_id: 'mpv', rect: { x: 0, y: 0, width: 800, height: 600 } }, + { pid: 20, app_id: 'mpv', rect: { x: 100, y: 50, width: 1280, height: 720 } }, + ], + }; + const commandLines = new Map([ + ['10', 'mpv --input-ipc-server=/tmp/other.sock a.mkv'], + ['20', 'mpv --input-ipc-server=/tmp/subminer.sock b.mkv'], + ]); + const tracker = new SwayWindowTracker('/tmp/subminer.sock', async (command, args) => + command === 'swaymsg' ? JSON.stringify(tree) : (commandLines.get(args[1]!) ?? ''), + ); + + await tracker.refreshNow(); + + assert.deepEqual(tracker.getGeometry(), { x: 100, y: 50, width: 1280, height: 720 }); +}); diff --git a/src/window-trackers/sway-tracker.ts b/src/window-trackers/sway-tracker.ts index c63e5f1f..890e9f67 100644 --- a/src/window-trackers/sway-tracker.ts +++ b/src/window-trackers/sway-tracker.ts @@ -16,8 +16,13 @@ along with this program. If not, see . */ -import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; +import { + createCoalescedTask, + execFileWithTimeout, + ProcessCommandLineCache, + type CommandRunner, +} from './linux-poll'; interface SwayRect { x: number; @@ -38,24 +43,36 @@ interface SwayNode { export class SwayWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private readonly targetMpvSocketPath: string | null; + private readonly runCommand: CommandRunner; + private readonly commandLines: ProcessCommandLineCache; + private stopped = false; + private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce()); - constructor(targetMpvSocketPath?: string) { + constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.runCommand = runCommand; + this.commandLines = new ProcessCommandLineCache(runCommand); } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); - this.pollGeometry(); + this.stopped = false; + this.pollInterval = setInterval(() => void this.pollGeometry(), 250); + void this.pollGeometry(); } stop(): void { + this.stopped = true; if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } } + override refreshNow(): Promise { + return this.pollGeometry(); + } + private collectMpvWindows(node: SwayNode): SwayNode[] { const windows: SwayNode[] = []; if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') { @@ -77,56 +94,56 @@ export class SwayWindowTracker extends BaseWindowTracker { return windows; } - private findTargetSocketWindow(node: SwayNode): SwayNode | null { + private async findTargetSocketWindow(node: SwayNode): Promise { const windows = this.collectMpvWindows(node); if (!this.targetMpvSocketPath) { return windows[0] || null; } - return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null; + const commandLines = await this.commandLines.lookup( + windows.flatMap((window) => (window.pid ? [window.pid] : [])), + ); + return ( + windows.find((candidate) => { + const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined; + return commandLine !== undefined && this.matchesTargetSocket(commandLine); + }) || null + ); } - private isWindowForTargetSocket(node: SwayNode): boolean { - if (!node.pid) { - return false; - } - - const commandLine = this.getWindowCommandLine(node.pid); - if (!commandLine) { - return false; - } - + private matchesTargetSocket(commandLine: string): boolean { return ( commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) ); } - private getWindowCommandLine(pid: number): string | null { - const commandLine = execSync(`ps -p ${pid} -o args=`, { - encoding: 'utf-8', - }).trim(); - return commandLine || null; - } + private async pollGeometryOnce(): Promise { + if (this.stopped) { + return; + } - private pollGeometry(): void { + let mpvWindow: SwayNode | null; try { - const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' }); - const tree: SwayNode = JSON.parse(output); - const mpvWindow = this.findTargetSocketWindow(tree); + const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode; + mpvWindow = await this.findTargetSocketWindow(tree); + } catch { + // swaymsg/ps not available, timed out, or failed - keep the last geometry + return; + } + if (this.stopped) { + return; + } - if (mpvWindow && mpvWindow.rect) { - this.updateGeometry({ - x: mpvWindow.rect.x, - y: mpvWindow.rect.y, - width: mpvWindow.rect.width, - height: mpvWindow.rect.height, - }); - } else { - this.updateGeometry(null); - } - } catch (err) { - // swaymsg not available or failed - silent fail + if (mpvWindow && mpvWindow.rect) { + this.updateGeometry({ + x: mpvWindow.rect.x, + y: mpvWindow.rect.y, + width: mpvWindow.rect.width, + height: mpvWindow.rect.height, + }); + } else { + this.updateGeometry(null); } } }