fix: offload process listing from main thread

Move native process enumeration into a worker so process watching does not block the main process, and add LF text-integrity checks for normalized files.
This commit is contained in:
Chubby Granny Chaser
2026-04-27 19:57:18 +01:00
parent bc83595fe8
commit 1a5525312b
35 changed files with 2816 additions and 2657 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+38
View File
@@ -0,0 +1,38 @@
name: Text Integrity
on:
pull_request:
push:
branches:
- main
- 'release/**'
jobs:
text-integrity:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Verify LF line endings
run: |
files_with_cr="$(git grep -Il $'\r' -- . || true)"
if [ -n "$files_with_cr" ]; then
echo "Tracked text files must use LF line endings. Files containing CR characters:"
echo "$files_with_cr"
exit 1
fi
- name: Verify LICENSE hash
run: |
expected="32619612c2e0223e86c4908747ec14bef64c3c423fee80910c1aa944769b66f9"
actual="$(sha256sum LICENSE | cut -d ' ' -f1)"
if [ "$actual" != "$expected" ]; then
echo "LICENSE hash changed."
echo "Expected: $expected"
echo "Actual: $actual"
exit 1
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.9.6",
"version": "3.9.7",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
+1 -1
View File
@@ -20,7 +20,7 @@ const closeGame = async (
shop: GameShop,
objectId: string
) => {
const processes = NativeAddon.listProcesses();
const processes = await NativeAddon.listProcesses();
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
+1 -1
View File
@@ -146,7 +146,7 @@ const cleanupStaleCompatibilityProcesses = async (
const defaultPrefixPath = Wine.getDefaultPrefixPathForGame(objectId);
if (defaultPrefixPath !== winePrefixPath) return;
const processes = NativeAddon.listProcesses();
const processes = await NativeAddon.listProcesses();
const stalePids = processes
.filter((runningProcess) => {
+158 -15
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { Worker } from "node:worker_threads";
import { app } from "electron";
import type { ProcessPayload } from "./download/types";
@@ -22,8 +23,89 @@ type HydraNativeModule = {
listProcesses: () => ProcessPayload[];
};
export type SystemProcessMap = {
processMap: Record<string, string[]>;
winePrefixMap: Record<string, string>;
linuxProcesses: Array<{
name: string;
cwd: string;
exe: string;
steamCompatDataPath: string | null;
}>;
};
// Runs in the worker thread (CJS context).
// "list" → posts back the raw ProcessPayload array (used by close-game, launch-game)
// "map" → posts back compact pre-built maps (used by the main loop's watchProcesses)
const WORKER_CODE = `
const { workerData, parentPort } = require('worker_threads');
const path = require('path');
if (process.platform === 'linux' && workerData.addonDir) {
process.env.LD_LIBRARY_PATH = process.env.LD_LIBRARY_PATH
? workerData.addonDir + ':' + process.env.LD_LIBRARY_PATH
: workerData.addonDir;
}
const addon = require(workerData.addonPath);
const platform = process.platform;
function buildMaps(processes) {
const processMap = Object.create(null);
const winePrefixMap = Object.create(null);
const linuxProcesses = [];
for (const proc of processes) {
const key = proc.name && proc.name.toLowerCase();
const value = platform === 'win32'
? proc.exe
: path.join(proc.cwd || '', proc.name || '');
if (!key || !value) continue;
const steamCompatDataPath = proc.environ && proc.environ.STEAM_COMPAT_DATA_PATH;
if (steamCompatDataPath) winePrefixMap[value] = steamCompatDataPath;
if (platform === 'linux') {
linuxProcesses.push({
name: key,
cwd: (proc.cwd || '').toLowerCase(),
exe: (proc.exe || '').toLowerCase(),
steamCompatDataPath: steamCompatDataPath ? steamCompatDataPath.toLowerCase() : null,
});
}
if (!processMap[key]) processMap[key] = [];
processMap[key].push(value);
}
return { processMap, winePrefixMap, linuxProcesses };
}
parentPort.on('message', (type) => {
try {
const processes = addon.listProcesses();
if (type === 'map') {
parentPort.postMessage({ type: 'map', result: buildMaps(processes) });
} else {
parentPort.postMessage({ type: 'list', result: processes });
}
} catch (_) {
if (type === 'map') {
parentPort.postMessage({ type: 'map', result: { processMap: {}, winePrefixMap: {}, linuxProcesses: [] } });
} else {
parentPort.postMessage({ type: 'list', result: [] });
}
}
});
`;
type PendingResolver =
| { type: "list"; resolve: (p: ProcessPayload[]) => void }
| { type: "map"; resolve: (m: SystemProcessMap) => void };
export class NativeAddon {
private static nativeModule: HydraNativeModule | null = null;
private static worker: Worker | null = null;
private static pendingResolvers: PendingResolver[] = [];
private static resolveAddonPath() {
if (app.isPackaged) {
@@ -61,6 +143,55 @@ export class NativeAddon {
return nativeModule;
}
private static getWorker(): Worker {
if (this.worker) return this.worker;
const addonPath = this.resolveAddonPath();
const addonDir = path.dirname(addonPath);
if (!fs.existsSync(addonPath)) {
throw new Error(`Hydra native addon not found at ${addonPath}`);
}
this.worker = new Worker(WORKER_CODE, {
eval: true,
workerData: { addonPath, addonDir },
});
this.worker.on("message", ({ result }) => {
const pending = this.pendingResolvers.shift();
if (!pending) return;
if (pending.type === "list") {
(pending.resolve as (p: ProcessPayload[]) => void)(
(result as ProcessPayload[]).filter(
(p): p is ProcessPayload =>
typeof p?.pid === "number" &&
typeof p?.name === "string" &&
p.name.length > 0
)
);
} else {
(pending.resolve as (m: SystemProcessMap) => void)(
result as SystemProcessMap
);
}
});
this.worker.on("error", (error) => {
logger.error("Process list worker error", error);
this.drainResolvers();
});
this.worker.on("exit", (code) => {
if (code !== 0)
logger.error(`Process list worker exited with code ${code}`);
this.worker = null;
this.drainResolvers();
});
return this.worker;
}
public static processProfileImage(
imagePath: string,
targetExtension = "webp"
@@ -88,24 +219,36 @@ export class NativeAddon {
}
}
public static listProcesses(): ProcessPayload[] {
private static drainResolvers() {
const drained = this.pendingResolvers.splice(0);
for (const pending of drained) {
if (pending.type === "list") pending.resolve([]);
else
pending.resolve({ processMap: {}, winePrefixMap: {}, linuxProcesses: [] });
}
}
public static listProcesses(): Promise<ProcessPayload[]> {
return new Promise((resolve) => {
try {
const response = this.load().listProcesses();
if (!Array.isArray(response)) {
throw new Error("Hydra native addon returned an invalid process list");
const worker = this.getWorker();
this.pendingResolvers.push({ type: "list", resolve });
worker.postMessage("list");
} catch {
resolve([]);
}
return response.filter((process): process is ProcessPayload => {
return (
typeof process?.pid === "number" &&
typeof process?.name === "string" &&
process.name.length > 0
);
});
} catch (error) {
logger.error("Failed to list processes via native addon", error);
return [];
}
public static getSystemProcessMap(): Promise<SystemProcessMap> {
return new Promise((resolve) => {
try {
const worker = this.getWorker();
this.pendingResolvers.push({ type: "map", resolve });
worker.postMessage("map");
} catch {
resolve({ processMap: {}, winePrefixMap: {}, linuxProcesses: [] });
}
});
}
}
+6 -30
View File
@@ -143,38 +143,14 @@ const findGamePathByProcess = async (
};
const getSystemProcessMap = async () => {
const processes = NativeAddon.listProcesses();
const { processMap: rawMap, winePrefixMap: rawWineMap, linuxProcesses } =
await NativeAddon.getSystemProcessMap();
const processMap = new Map<string, Set<string>>();
const winePrefixMap = new Map<string, string>();
const linuxProcesses: LinuxProcessInfo[] = [];
const processMap = new Map<string, Set<string>>(
Object.entries(rawMap).map(([k, v]) => [k, new Set(v)])
);
processes.forEach((process) => {
const key = process.name?.toLowerCase();
const value =
platform === "win32"
? process.exe
: path.join(process.cwd ?? "", process.name ?? "");
if (!key || !value) return;
const STEAM_COMPAT_DATA_PATH = process.environ?.STEAM_COMPAT_DATA_PATH;
if (STEAM_COMPAT_DATA_PATH) {
winePrefixMap.set(value, STEAM_COMPAT_DATA_PATH);
}
if (platform === "linux") {
linuxProcesses.push({
name: key,
cwd: (process.cwd ?? "").toLowerCase(),
exe: (process.exe ?? "").toLowerCase(),
steamCompatDataPath: STEAM_COMPAT_DATA_PATH?.toLowerCase() ?? null,
});
}
const currentSet = processMap.get(key) ?? new Set();
processMap.set(key, currentSet.add(value));
});
const winePrefixMap = new Map<string, string>(Object.entries(rawWineMap));
return { processMap, winePrefixMap, linuxProcesses };
};
@@ -93,6 +93,7 @@ export function SettingsDownloadSources() {
return () => clearInterval(intervalId);
}, [downloadSources]);
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
@@ -70,7 +70,7 @@ export function SettingsGeneral() {
setCanInstallCommonRedist(canInstall);
});
const interval = setInterval(() => {
const redistInterval = setInterval(() => {
window.electron.canInstallCommonRedist().then((canInstall) => {
setCanInstallCommonRedist(canInstall);
});
@@ -90,7 +90,7 @@ export function SettingsGeneral() {
);
return () => {
clearInterval(interval);
clearInterval(redistInterval);
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
File diff suppressed because one or more lines are too long