fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289)

This commit is contained in:
sudacode committed 2026-10-11 01:02:02 -07:00
1 parent 9c351a8603
commit c18644392d
6 files changed
+360 -76

No files matched your search

+5
View File
@@ -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.
+109 -11
View File
@@ -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<string>((resolve) => {
pending.push({ command, args, resolve });
});
return { pending, runCommand };
}
function flush(): Promise<void> {
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 calls: string[] = [];
const tracker = new HyprlandWindowTracker(); const tracker = new HyprlandWindowTracker(undefined, async (command, args) => {
const privateTracker = tracker as unknown as { calls.push(`${command} ${args.join(' ')}`);
handleSocketEvent: (event: string) => void; return '[]';
pollGeometry: () => void; });
};
privateTracker.pollGeometry = () => {
calls.push('poll');
};
tracker.onWindowFocusChange = (focused) => { tracker.onWindowFocusChange = (focused) => {
calls.push(`focus:${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']);
}); });
+65 -37
View File
@@ -17,8 +17,13 @@
*/ */
import * as net from 'net'; import * as net from 'net';
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker'; import { BaseWindowTracker } from './base-tracker';
import {
createCoalescedTask,
execFileWithTimeout,
ProcessCommandLineCache,
type CommandRunner,
} from './linux-poll';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { WindowGeometry } from '../types'; import type { WindowGeometry } from '../types';
@@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean {
return value.trim().toLowerCase().includes('mpv'); return value.trim().toLowerCase().includes('mpv');
} }
export function selectHyprlandMpvWindow( function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] {
clients: HyprlandClient[], return clients.filter(
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = clients.filter(
(client) => (client) =>
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) && (isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
client.mapped !== false && client.mapped !== false &&
client.hidden !== true, client.hidden !== true,
); );
}
export function selectHyprlandMpvWindow(
clients: HyprlandClient[],
options: SelectHyprlandMpvWindowOptions,
): HyprlandClient | null {
const visibleMpvWindows = filterVisibleMpvWindows(clients);
if (!options.targetMpvSocketPath) { if (!options.targetMpvSocketPath) {
return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress); return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress);
@@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = []; private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
private eventSocket: net.Socket | null = null; private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null; private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private readonly commandLines: ProcessCommandLineCache;
private activeWindowAddress: string | null = null; 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(); super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.commandLines = new ProcessCommandLineCache(runCommand);
} }
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.stopped = false;
this.pollGeometry(); this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
void this.pollGeometry();
this.connectEventSocket(); this.connectEventSocket();
} }
stop(): void { stop(): void {
this.stopped = true;
if (this.pollInterval) { if (this.pollInterval) {
clearInterval(this.pollInterval); clearInterval(this.pollInterval);
this.pollInterval = null; this.pollInterval = null;
@@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
} }
} }
override refreshNow(): Promise<void> {
return this.pollGeometry();
}
private connectEventSocket(): void { private connectEventSocket(): void {
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE; const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
if (!hyprlandSig) { if (!hyprlandSig) {
@@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
if (name === 'activewindowv2') { if (name === 'activewindowv2') {
const wasFocused = this.isTargetWindowFocused(); const wasFocused = this.isTargetWindowFocused();
this.activeWindowAddress = data || null; this.activeWindowAddress = data || null;
this.pollGeometry(); void this.pollGeometry().then(() => {
if (this.isTargetWindowFocused() === wasFocused) { // Re-emit unchanged focus so the overlay still refreshes its z-order.
if (!this.stopped && this.isTargetWindowFocused() === wasFocused) {
this.onWindowFocusChange?.(this.isTargetWindowFocused()); this.onWindowFocusChange?.(this.isTargetWindowFocused());
} }
});
return; return;
} }
@@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => { this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
const pollTimeout = setTimeout(() => { const pollTimeout = setTimeout(() => {
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout); this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
this.pollGeometry(); void this.pollGeometry();
}, delayMs); }, delayMs);
return pollTimeout; return pollTimeout;
}); });
@@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
} }
} }
private pollGeometry(): void { private async pollGeometryOnce(): Promise<void> {
if (this.stopped) {
return;
}
let mpvWindow: HyprlandClient | null;
let monitors: HyprlandMonitor[] | null = null;
try { try {
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' }); const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients']));
const clients = parseHyprctlClients(output); mpvWindow = clients ? await this.findTargetWindow(clients) : null;
if (!clients) { if (mpvWindow) {
this.updateGeometry(null); monitors = await this.getHyprlandMonitors(mpvWindow);
}
} catch {
// hyprctl/ps not available, timed out, or failed - keep the last geometry
return;
}
if (this.stopped) {
return; return;
} }
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) { if (mpvWindow) {
const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress; const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress;
this.updateGeometry( this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused);
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
focused,
);
this.updateTargetWindowFocused(focused); this.updateTargetWindowFocused(focused);
} else { } else {
this.updateGeometry(null); this.updateGeometry(null);
} }
} catch (err) {
// hyprctl not available or failed - silent fail
}
} }
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { private async findTargetWindow(clients: HyprlandClient[]): Promise<HyprlandClient | null> {
// 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, { return selectHyprlandMpvWindow(clients, {
targetMpvSocketPath: this.targetMpvSocketPath, targetMpvSocketPath: this.targetMpvSocketPath,
activeWindowAddress: this.activeWindowAddress, 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<HyprlandMonitor[] | null> {
if (!isHyprlandFullscreenClient(client)) { if (!isHyprlandFullscreenClient(client)) {
return null; return null;
} }
try { try {
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' }); return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors']));
return parseHyprctlMonitors(output);
} catch { } catch {
return null; return null;
} }
} }
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: 'utf-8',
}).trim();
return commandLine || null;
}
} }
+113
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string>;
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<string> {
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<void>): () => Promise<void> {
let active: Promise<void> | null = null;
let queued: Promise<void> | null = null;
const run = (): Promise<void> => {
const current: Promise<void> = task().finally(() => {
if (active === current) {
active = null;
}
});
active = current;
return current;
};
return () => {
if (queued) {
return queued;
}
if (!active) {
return run();
}
const next = (): Promise<void> => {
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<number, string>();
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<ReadonlyMap<number, string>> {
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;
}
}
+23
View File
@@ -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 });
});
+45 -28
View File
@@ -16,8 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { execSync } from 'child_process';
import { BaseWindowTracker } from './base-tracker'; import { BaseWindowTracker } from './base-tracker';
import {
createCoalescedTask,
execFileWithTimeout,
ProcessCommandLineCache,
type CommandRunner,
} from './linux-poll';
interface SwayRect { interface SwayRect {
x: number; x: number;
@@ -38,24 +43,36 @@ interface SwayNode {
export class SwayWindowTracker extends BaseWindowTracker { export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | 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(); super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.commandLines = new ProcessCommandLineCache(runCommand);
} }
start(): void { start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.stopped = false;
this.pollGeometry(); this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
void this.pollGeometry();
} }
stop(): void { stop(): void {
this.stopped = true;
if (this.pollInterval) { if (this.pollInterval) {
clearInterval(this.pollInterval); clearInterval(this.pollInterval);
this.pollInterval = null; this.pollInterval = null;
} }
} }
override refreshNow(): Promise<void> {
return this.pollGeometry();
}
private collectMpvWindows(node: SwayNode): SwayNode[] { private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = []; const windows: SwayNode[] = [];
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') { if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
@@ -77,43 +94,46 @@ export class SwayWindowTracker extends BaseWindowTracker {
return windows; return windows;
} }
private findTargetSocketWindow(node: SwayNode): SwayNode | null { private async findTargetSocketWindow(node: SwayNode): Promise<SwayNode | null> {
const windows = this.collectMpvWindows(node); const windows = this.collectMpvWindows(node);
if (!this.targetMpvSocketPath) { if (!this.targetMpvSocketPath) {
return windows[0] || null; 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] : [])),
);
private isWindowForTargetSocket(node: SwayNode): boolean { return (
if (!node.pid) { windows.find((candidate) => {
return false; const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined;
} return commandLine !== undefined && this.matchesTargetSocket(commandLine);
}) || null
const commandLine = this.getWindowCommandLine(node.pid); );
if (!commandLine) {
return false;
} }
private matchesTargetSocket(commandLine: string): boolean {
return ( return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
); );
} }
private getWindowCommandLine(pid: number): string | null { private async pollGeometryOnce(): Promise<void> {
const commandLine = execSync(`ps -p ${pid} -o args=`, { if (this.stopped) {
encoding: 'utf-8', return;
}).trim();
return commandLine || null;
} }
private pollGeometry(): void { let mpvWindow: SwayNode | null;
try { try {
const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' }); const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode;
const tree: SwayNode = JSON.parse(output); mpvWindow = await this.findTargetSocketWindow(tree);
const mpvWindow = 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) { if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({ this.updateGeometry({
@@ -125,8 +145,5 @@ export class SwayWindowTracker extends BaseWindowTracker {
} else { } else {
this.updateGeometry(null); this.updateGeometry(null);
} }
} catch (err) {
// swaymsg not available or failed - silent fail
}
} }
} }