Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/main/src/MainWindow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appendFileSync } from "node:fs";
import { appendFileSync, writeFileSync } from "node:fs";
import * as path from "path";
import { pathToFileURL } from "url";

Expand Down Expand Up @@ -177,6 +177,25 @@ class MainWindow {
reason: details.reason,
});

if (details.reason === "oom") {
// leave a marker for the relaunched renderer: an OOM during e.g. a large
// collection install would otherwise silently restart into the exact same
// crash. The collections extension picks this up on startup and warns the
// user instead of letting the install resume into a crash loop.
try {
writeFileSync(
path.join(app.getPath("userData"), "renderer-oom.json"),
JSON.stringify({
reason: details.reason,
exitCode: details.exitCode,
timestamp: Date.now(),
}),
);
} catch {
// diagnostics must never throw
}
}

// hard renderer crashes never reach the JS error handlers, so this
// is the only place they can be reported
if (!["clean-exit", "killed"].includes(details.reason)) {
Expand Down
44 changes: 44 additions & 0 deletions src/renderer/src/extensions/collections/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync, unlinkSync } from "node:fs";
import * as path from "path";
import { pathToFileURL } from "url";

Expand Down Expand Up @@ -1236,9 +1237,52 @@ async function checkVoteRequest(api: IExtensionApi): Promise<number> {
return TIME_BEFORE_VOTE - elapsed;
}

/**
* checks for the marker the main process writes when the renderer died with an
* out-of-memory crash (see MainWindow render-process-gone handler). If the previous
* session OOMed while a collection install was incomplete, resuming right away would
* very likely crash the same way again - so instead of silently restarting into a
* crash loop, surface a visible error and leave the collection paused until the user
* explicitly resumes.
*/
function checkRendererOomMarker(api: IExtensionApi) {
const markerPath = path.join(getVortexPath("userData"), "renderer-oom.json");
let marker: { timestamp?: number } | undefined;
try {
marker = JSON.parse(readFileSync(markerPath, "utf8"));
unlinkSync(markerPath);
} catch {
// no marker (the usual case) or unreadable - nothing to report
return;
}
Comment on lines +1249 to +1257

// ignore stale markers (e.g. crash long ago, unrelated to this session)
const MARKER_MAX_AGE_MS = 30 * 60 * 1000;
if (typeof marker?.timestamp !== "number" || Date.now() - marker.timestamp > MARKER_MAX_AGE_MS) {
return;
}

log("warn", "previous session ended in a renderer OOM crash", {
crashedAt: new Date(marker.timestamp).toISOString(),
});

api.sendNotification({
id: "renderer-oom-collection-install",
type: "error",
title: "Vortex ran out of memory",
message:
"The previous session crashed because Vortex ran out of memory. " +
"If this happened while installing a large collection, resuming it " +
"immediately may crash again - consider restarting Vortex before resuming, " +
"and please report the issue if it persists.",
});
}

function once(api: IExtensionApi, collectionsCB: () => ICallbackMap) {
const { store } = api;

checkRendererOomMarker(api);

const applyCollectionModDefaults = new Debouncer(() => {
const gameMode = selectors.activeGameId(state());
const mods = getSafe(state(), ["persistent", "mods", gameMode], {});
Expand Down
82 changes: 72 additions & 10 deletions src/renderer/src/extensions/mod_management/InstallManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3461,10 +3461,19 @@ class InstallManager {
}
: undefined;
const stagingPath = installPathForGame(state, session.gameId);
// filter by session status FIRST: this runs on every 500ms poll tick and
// selectedOptionalRules performs a findModByRef scan over ALL installed mods per
// candidate rule. Doing that for the full rule set of a several-thousand-mod
// collection on every tick generated enough allocation churn to OOM the renderer;
// the cheap session-status lookup reduces the expensive scan to genuinely
// pending optionals (typically none or a handful).
const pendingRules = (collectionMod.rules ?? []).filter(
(rule) => session.mods[modRuleId(rule)]?.status === "pending",
);
const pending = selectedOptionalRules(
collectionMod.rules ?? [],
pendingRules,
state.persistent.mods[session.gameId] ?? {},
).filter((rule) => session.mods[modRuleId(rule)]?.status === "pending");
);

for (const rule of pending) {
const key = modRuleId(rule);
Expand Down Expand Up @@ -6697,33 +6706,63 @@ class InstallManager {
return res;
}

private updateModRule(
// computes the redux actions needed to bring the source mod's rule for `dep` in line with
// `reference`, without dispatching. `oldRule` is the rule the actions replace (also returned
// when no actions are needed because the rule is already up to date).
private updateModRuleActions(
api: IExtensionApi,
gameId: string,
sourceModId: string,
dep: IDependency,
reference: IModReference,
recommended: boolean,
): IModRule | undefined {
): { rule: IModRule | undefined; oldRule: IModRule | undefined; actions: Redux.Action[] } {
const state: IState = api.store.getState();
const rules: IModRule[] = getSafe(state.persistent.mods, [gameId, sourceModId, "rules"], []);
const oldRule = rules.find((iter) => referenceEqual(iter.reference, dep.reference));

const type = recommended ? "recommends" : "requires";

if (oldRule === undefined) {
return undefined;
return { rule: undefined, oldRule: undefined, actions: [] };
}

if (oldRule.type === type && referenceEqual(oldRule.reference, reference)) {
return oldRule;
return { rule: oldRule, oldRule, actions: [] };
}

const updatedRule: IModRule = { ...oldRule, type, reference };

api.store.dispatch(removeModRule(gameId, sourceModId, oldRule));
api.store.dispatch(addModRule(gameId, sourceModId, updatedRule));
return updatedRule;
return {
rule: updatedRule,
oldRule,
actions: [
removeModRule(gameId, sourceModId, oldRule),
addModRule(gameId, sourceModId, updatedRule),
],
};
}

private updateModRule(
api: IExtensionApi,
gameId: string,
sourceModId: string,
dep: IDependency,
reference: IModReference,
recommended: boolean,
): IModRule | undefined {
const { rule, actions } = this.updateModRuleActions(
api,
gameId,
sourceModId,
dep,
reference,
recommended,
);
if (actions.length > 0) {
batchDispatch(api.store, actions);
}
return rule;
}

private updateRules(
Expand All @@ -6733,11 +6772,34 @@ class InstallManager {
dependencies: IDependency[],
recommended: boolean,
): Promise<void> {
// collect all rule updates and dispatch them as ONE batch. Dispatching per dependency
// (2 actions each) made every dispatch clone the source mod's full rules array and push a
// separate diff through the persist pipeline - O(n²) state churn that contributed to
// renderer OOM on collections with thousands of mods.
const actions: Redux.Action[] = [];
// the state snapshot doesn't advance while collecting, so guard against emitting a second
// update for the same underlying rule (e.g. duplicate references in the dependency list)
const consumed = new Set<IModRule>();
dependencies.forEach((dep) => {
const updatedRef: IModReference = { ...dep.reference };
updatedRef.idHint = dep.mod?.id;
this.updateModRule(api, gameId, sourceModId, dep, updatedRef, recommended);
const result = this.updateModRuleActions(
api,
gameId,
sourceModId,
dep,
updatedRef,
recommended,
);
if (result.oldRule !== undefined && !consumed.has(result.oldRule)) {
consumed.add(result.oldRule);
actions.push(...result.actions);
}
});
if (actions.length > 0) {
log("debug", "batch updating dependency rules", { count: actions.length / 2 });
batchDispatch(api.store, actions);
}
return Promise.resolve();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Stress coverage for the dependency-matching hot path that OOMed the renderer on very
* large collections (~2850 required mods matched against ~3000 local archives - see the
* Cyberpunk 2077 "p0qfwm" crash loop). Simulates that shape synthetically: every
* reference is probed against every download (O(refs x downloads), ~9 million probes)
* and asserts that peak heap growth stays bounded - which it only does with the
* per-download lookup memoization in dependencies.ts.
*/
import { describe, expect, it, vi } from "vitest";

import { makeDownload } from "../../../test-utils/builders";
import type { IDownload } from "../../../types/IState";
import type { IModReference } from "../types/IMod";
import { findDownloadByRef, lookupFromDownload } from "./dependencies";

vi.mock("../../../util/log", () => ({ log: vi.fn() }));

const COUNT = 3000;
const GAME = "cyberpunk2077";

function makeFixture() {
const downloads: { [dlId: string]: IDownload } = {};
const references: IModReference[] = [];
for (let i = 0; i < COUNT; ++i) {
downloads[`dl-${i}`] = makeDownload({
id: `dl-${i}`,
state: "finished",
game: [GAME],
localPath: `mod-${i}.zip`,
size: 1000 + i,
fileMD5: `md5-${i}`,
modInfo: {
version: "1.0.0",
name: `Mod ${i}`,
referenceTag: `tag-${i}`,
},
});
references.push({
tag: `tag-${i}`,
gameId: GAME,
fileMD5: `md5-${i}`,
fileSize: 1000 + i,
} as IModReference);
}
return { downloads, references };
}

function heapUsed(): number {
if (typeof global.gc === "function") {
global.gc();
}
return process.memoryUsage().heapUsed;
}

describe("dependency matching at collection scale", () => {
it(`matches ${COUNT} references against ${COUNT} downloads with bounded heap growth`, () => {
const { downloads, references } = makeFixture();

// warm the per-download caches once so the measurement below reflects the
// steady-state cost of re-matching (what the install pipeline actually does
// repeatedly: gather, requeue scans, poll ticks)
Object.values(downloads).forEach((dl) => lookupFromDownload(dl));

const before = heapUsed();

let matched = 0;
for (const ref of references) {
if (findDownloadByRef(ref, downloads) !== undefined) {
++matched;
}
}

const after = heapUsed();
const growthMB = (after - before) / (1024 * 1024);

process.stdout.write(
`[stress] ${COUNT}x${COUNT} matching: heap growth ${growthMB.toFixed(1)} MB\n`,
);

expect(matched).toBe(COUNT);
// generous bound: without memoization this run allocates hundreds of MB of
// throw-away lookup objects; with it, growth stays in the low tens of MB
expect(growthMB).toBeLessThan(192);
Comment on lines +64 to +83
}, 120_000);

it("returns identity-stable lookup info across repeated probes", () => {
const { downloads } = makeFixture();
const values = Object.values(downloads);
for (const dl of values) {
expect(lookupFromDownload(dl)).toBe(lookupFromDownload(dl));
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
*/
import { describe, expect, it, vi } from "vitest";

import { makeMod, makeRule } from "../../../test-utils/builders";
import { makeDownload, makeMod, makeRule } from "../../../test-utils/builders";
import type { IMod } from "../types/IMod";
import { selectedOptionalRules } from "./dependencies";
import { findDownloadByRef, lookupFromDownload, selectedOptionalRules } from "./dependencies";

vi.mock("../../../util/log", () => ({ log: vi.fn() }));

Expand Down Expand Up @@ -38,3 +38,60 @@ describe("selectedOptionalRules", () => {
expect(selectedOptionalRules(undefined as unknown as [], {})).toEqual([]);
});
});

describe("lookupFromDownload", () => {
it("derives lookup info from the download", () => {
const download = makeDownload({
fileMD5: "abc",
localPath: "some file.zip",
size: 1234,
modInfo: { version: "1.2.3", name: "Some File", referenceTag: "tag-1" },
});
const lookup = lookupFromDownload(download);
expect(lookup).toMatchObject({
fileMD5: "abc",
fileName: "some file.zip",
fileSizeBytes: 1234,
version: "1.2.3",
logicalFileName: "Some File",
referenceTag: "tag-1",
});
});

it("memoizes per download object (same object -> same result, new object -> new result)", () => {
const download = makeDownload({ fileMD5: "abc" });
const first = lookupFromDownload(download);
// repeated matching of the same (immutable) download must not re-allocate
expect(lookupFromDownload(download)).toBe(first);

// a state change produces a NEW download object, which must be re-derived
const changed = { ...download, fileMD5: "def" };
const second = lookupFromDownload(changed);
expect(second).not.toBe(first);
expect(second.fileMD5).toBe("def");
});
});

describe("findDownloadByRef", () => {
it("still resolves downloads by reference tag after repeated (cached) probes", () => {
const downloads = {
dl1: makeDownload({
id: "dl1",
state: "finished",
game: ["skyrimse"],
modInfo: { referenceTag: "tag-a" },
}),
dl2: makeDownload({
id: "dl2",
state: "finished",
game: ["skyrimse"],
modInfo: { referenceTag: "tag-b" },
}),
};
const ref = { tag: "tag-b", gameId: "skyrimse" } as never;
const first = findDownloadByRef(ref, downloads);
const second = findDownloadByRef(ref, downloads);
expect(first).toBe("dl2");
expect(second).toBe("dl2");
});
});
Loading