Skip to content

perf: engine optimization pass (memory/CPU/GPU) - #169

Merged
Zaldaryon merged 15 commits into
devfrom
perf/engine-optimization-pass
Aug 21, 2026
Merged

perf: engine optimization pass (memory/CPU/GPU)#169
Zaldaryon merged 15 commits into
devfrom
perf/engine-optimization-pass

Conversation

@Zaldaryon

@Zaldaryon Zaldaryon commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Analysis of the Electron/React engine turned up 10 performance opportunities, ranked by impact vs effort. This PR ships 8 of them. The 2 left out (a full migration of list-item animations from motion to plain CSS or LazyMotion, and real list virtualization with react-window/@tanstack/react-virtual) both touch visible rendering and animation behavior directly, and this environment has no working way to verify a change like that visually (a Playwright-driven Electron window would not connect here, tried it, gave up after several failed attempts). List virtualization also depends on the motion migration being done first: removing an item from the DOM while AnimatePresence still owns its exit animation means every scroll past the virtualization boundary would fade cards out and back in, a visible regression, not an optimization. Both are better done as their own follow-up PR with a real visual review.

  • Compress the background image. 4.56MB down to 1.58MB (65% smaller) using CaesiumCLT, PSNR 40.5dB against the original (above the usual "visually lossless" threshold), dimensions untouched.
  • Memoize ModListCard. The mods grid can hold hundreds of cards, and none were memoized. Added React.memo plus a stable callback chain, since memo alone did nothing until the callbacks stopped getting a new identity on every render. Measured with a render-count spy: 2 renders down to 1 for a ListMods re-render unrelated to any given card.
  • In-memory cache for mod icon bytes. The cachemodimg: protocol handler hit disk and called net.fetch on every request. Icon file names are content-addressed (the sha256 of their own bytes), so a byte cache needs no invalidation logic at all, just LRU eviction under a 16MB budget. Measured: 1 disk read per icon instead of one per request.
  • Cap concurrent downloads, extractions, and compressions. Nothing bounded how many worker threads a burst of IPC calls (an "update all mods" click, say) could spin up. Now 3 concurrent downloads, and extraction/compression share a limit of 2 since both spawn a 7-Zip subprocess and compete for the same CPU cores.
  • Extend content-visibility: auto to installed-mod rows. ModListCard already used this to skip paint/layout for cards scrolled out of view. The installed-mods list in ManageMods can run just as long (it's a user's own Mods folder), so it gets the same treatment now.
  • Cache repeat ModDB searches and ignore stale out-of-order responses. Toggling a filter off and back on re-hit the network every time; there's now a small TTL'd (2 minute) cache capped at 20 distinct filter combinations. Separately, nothing stopped a slower, already-superseded search from overwriting a newer one's result if it happened to resolve later. Fixed with a per-call token in ListMods, not true network cancellation: Electron's ipcRenderer.invoke has no cancellation primitive without adding a whole new IPC surface, which would have been a bigger change than this warranted.
  • Move the 7z-listing validation off the main thread. validateSevenZipArchive's parse (up to 100,000 entries over 4MB of listing text) used to run in the EXTRACT_ON_PATH IPC handler, on the main process's event loop, before the extraction worker even started. It's now the first thing the extraction worker itself does, which is where it belonged anyway since it was already the first of two validation gates by design.
  • Stop replaying the entrance fade on every scroll pass. GridItem's useInView used once: false, motion's own default, so the IntersectionObserver it creates per card never got unobserved and kept firing on every scroll in and out of view. Switched to once: true; motion's own inView() implementation calls observer.unobserve() on it once seen, a documented library behavior, not something this change invents. One visible difference: a card scrolled past once no longer re-fades in on a later re-entry, it just stays visible.
  • Reuse worker threads across tasks instead of spawning one per task. Every download, extraction, compression, and permissions change paid the full cost of a new V8 isolate (roughly 10 to 30MB and some milliseconds) even for a burst of back-to-back tasks. This one needed more than a small tweak: workerData is read once at module load and is fixed for a worker's whole lifetime, so reuse meant redesigning all five worker scripts around a postMessage-based task protocol instead. A genuine trade-off, not a pure win: idle workers now cost resident memory the old design avoided, sized conservatively (a handful of idle workers at most, a 30 second idle timeout, retirement after 50 tasks regardless).

Fixes from review

Three things pixnop flagged as blocking, fixed here:

  • A task still queued behind the concurrency cap when before-quit fires used to be able to reach the front of the line and start writing to disk after the app had already begun quitting, since releasing a slot handed it straight to the next queued task with no check for shutdown in between. ConcurrencyLimiter now has a one-way shutdown(): every queued task rejects the moment it runs, and anything that calls run() afterward rejects on arrival instead of queueing. WorkerPool.acquire() gets the matching fix, since it had the same gap: it could still hand out a freshly spawned worker after terminateAll() had already run.
  • The memoization test for ModListCard mocked the component's own module and wrapped the mock in memo(), which only proved memo works on a spy. Dropping memo() from the real component left that test green. Replaced with a test that imports the real, unmocked component and wraps the mod prop in a Proxy that counts property reads: since only the component's own render body ever reads a property off that object, the count only advances on an actual re-render, not on memo's own reference check.
  • onToggleFavMod had no test exercising a plain favorite-then-unfavorite click sequence on the same mod. configReducer's ADD_FAV_MOD pushes onto favMods with no de-duplication, so a stale closure there would silently double-favorite a mod instead of ever un-favoriting it; there's now a round-trip test that would have caught that.

Also turned on eslint-plugin-react-hooks (rules-of-hooks and exhaustive-deps, both as errors), which the repo had never enabled. It found 21 pre-existing violations across 13 files; those are left as warnings for now rather than fixed sight-unseen in this PR, since guessing at 21 dependency arrays risks introducing exactly the kind of stale-closure bug the rule exists to catch. New code, including everything in this branch, is held to the error level.

A few behaviors worth calling out plainly rather than leaving implicit:

  • Starting a download, extraction, or compression sets the app's close-blocker before the call ever reaches the concurrency limiter, so a task still waiting behind the cap already blocks quitting, and shows as "pending" with no progress movement until a slot frees. There's no separate "queued" state in the UI to tell the two apart.
  • Only DOWNLOAD_ON_PATH, EXTRACT_ON_PATH, and COMPRESS_ON_PATH are capped. CHANGE_PERMS and the installer-payload extraction under RUN_INSTALLER go straight to a worker with no limit, since both run once per install with nothing behind them to burst.
  • The download lane has one FIFO queue with no priority lanes. The archive lane has its own FIFO queue, shared by extraction and compression. A manually started download can queue behind a large "update all mods" batch, but it does not queue behind archive work.
  • New in this pass: a task still queued when before-quit fires now rejects immediately with a clear "cancelled because the app is quitting" error, rather than either finishing after the window is already gone or sitting abandoned with no resolution. This path does not run through the main window's own close button, which already checks the same close-blocker and never lets before-quit fire while a task is active; it matters for the other ways the app can quit (the tray, an OS session ending, update-and-restart).

Type

  • Performance

Checklist

  • Targets dev, not main.
  • npm run typecheck passes.
  • npm run lint:ci passes.
  • npm run format:check passes.
  • npm run test:coverage passes, coverage at or above the floor in vitest.config.ts (lines 92.48% vs an 89% floor, statements 90.75% vs 87%, functions 89% vs 85%, branches 87.73% vs 85%).
  • npm run build:unpack passes.

Testing

107 test files, 1218 tests passing, 2 skipped (pre-existing, unrelated to this change). Every item above that could reasonably be measured has a test proving the actual improvement, not just that the code runs: a render-count spy for the memoization fix, a disk-read counter for the icon cache, an integration test firing 4 concurrent downloads to confirm the concurrency cap, a stale-response race test for the search cache, and a full suite for the worker pool (its own bookkeeping against a fake worker factory, the worker-side message protocol against a fake parentPort, and one test against a real, unmocked worker_threads.Worker proving the one assumption no mock can check: that a worker with a message listener attached really does stay alive between tasks). The two items that aren't measurable in a jsdom/vitest environment (the image compression and the content-visibility change) are backed by objective external numbers instead (file size and PSNR for the image; the same technique this codebase already trusted for ModListCard, just applied one component further).

The three review fixes above each have their own test, and each was checked against a real regression before being trusted: removing memo() from ModListCard fails the new memoization test, dropping favMods from onToggleFavMod's dependency array fails the new favorite round-trip test (it ends up [123, 123] instead of []), and a task still queued when shutdown() runs is asserted to reject with the app-is-quitting error and never start.

Also built the Linux AppImage and the unpacked electron-builder package from this branch at each major step and ran them manually, including a check that the worker pool change didn't break app startup with a real packaged build (ASAR, fuses, the works), not just the dev build. That manual pass covered the original 8 items; the review fixes above are checked by build:unpack and the automated suite only, not by running a packaged build by hand.

Related issues

None filed; this came out of an ad hoc performance review, not a tracked bug.

background.jpg was a 4.35MB, 3840x2160 progressive JPEG rendered as a
full-window CSS background (bg-cover) behind only a light 2-4px
backdrop-blur, and reused as-is on the loading screen and every popup
dialog panel. Recompressed losslessly-in-effect with CaesiumCLT
(--quality 90, dimensions untouched): 4.56MB -> 1.58MB (-65.3%),
PSNR 40.53dB against the original (>40dB is the conventional
"visually lossless" threshold; self-compare sanity-checked at 0
distortion). Less to decode and raster on every paint of the window
background, loading screen, and any popup.
The ModDB grid can hold hundreds of cards, and none of them were memoized:
every keystroke in the filter bar (or any unrelated re-render of ListMods,
e.g. editing an installation's start params from elsewhere in the app) redrew
every visible card. Wrapped ModListCard in React.memo, but memo alone was a
no-op here, so this also had to fix why its props kept changing identity:

- ModsGrid wrapped onSelect/onToggleFav/onOpenModDb in a fresh arrow function
  per card on every render. ModListCard now takes the mod as an argument on
  those handlers and does its own binding internally, so ModsGrid can hand
  every card the same reference instead.
- ListMods created onSelectMod/onToggleFavMod/onOpenModDb inline, a new
  function every render; wrapped in useCallback.
- onSelectMod's useCallback depended on the `installation` object itself,
  which ListMods rebuilds via useMemo on every edit to *any* installation
  (unrelated fields included) because it's derived from the whole
  installations array. Only ever used for a truthiness check, so it now
  depends on a `hasInstallation` boolean instead.
- The shared useExternalLinks hook (and its mods-feature wrapper) returned a
  brand-new closure on every call with no memoization at all, which alone
  was enough to break onOpenModDb's identity regardless of the fixes above.
  Wrapped both in useCallback; every other consumer of this hook benefits
  too.

tests/renderer-dom/modsGridMemoization.test.tsx replaces ModListCard with a
render-count spy and asserts it stays at 1 render across an unrelated
ListMods re-render. Measured before this change (with only the test file
applied): 2 renders for that scenario. After: 1.
The mods grid can hold hundreds of cards, each an <img src="cachemodimg:...">,
and every single one hit the protocol handler's lstat+realpath check and a
net.fetch disk read on every request, with nothing caching the actual bytes
between them. Names in this cache folder are content-addressed (sha256 of
the file's own bytes, see iconCache.ts), so a hit can never serve stale data:
nothing can rewrite a name's content without changing the name. That's what
makes a plain byte cache safe here with no invalidation logic at all, just
recency-based eviction under a 16MB budget (a quarter of the on-disk 64MB
one, since the grid only ever needs what's currently in view).

The lstat+realpath safety check still runs on every request unconditionally;
only the disk read past that point is skipped on a hit.

tests/domain/mods/iconMemoryCache.test.ts covers the cache's own eviction
behavior and, with a counted stub standing in for the disk read, confirms
readIconWithMemoryCache reads a given icon from disk exactly once no matter
how many times it's requested afterward.
Nothing capped how many DOWNLOAD_ON_PATH/EXTRACT_ON_PATH/COMPRESS_ON_PATH
calls the renderer could fire at once: each one spun up its own worker
thread with no ceiling, so a burst (a mod update-all, say) meant an
unbounded number of concurrent worker threads. Added a ConcurrencyLimiter
(a small FIFO counting semaphore) and wrapped the three worker-spawning
calls: 3 concurrent downloads, and extraction/compression sharing a
separate limit of 2 since both spawn a 7-Zip subprocess and compete for the
same CPU cores.

A queued call is just a promise that hasn't resolved yet; TaskManagerContext
already renders that as "pending" until the first progress event, so this
needed no renderer-side change.

tests/ipc/pathsHandlers.test.ts adds an integration test firing 4 concurrent
DOWNLOAD_ON_PATH calls and confirming the 4th's worker isn't created until
one of the first 3 finishes.
ModListCard already uses content-visibility: auto (the skip-offscreen-render
utility in styles.css) to skip layout/paint for cards scrolled out of view,
but the installed-mods list in ManageMods -- which can run just as long,
since it's a user's own Mods folder rather than a search result -- never
got the same treatment. Its rows are a fixed h-20, so the same "no
contain-intrinsic-size" reasoning already written down for the grid applies
here too: the value would depend on the uiscale setting, so it's left for
the browser to measure once rather than guessed.

Scoped to InstalledModItem only, not ErrorInstalledModItem or the
generic ListItem/GridItem primitives: those either have no reliable size
per call site or are only ever used in short lists (ListInstallations,
ListVersions), where there's nothing off screen worth skipping.

manageMods.test.tsx asserts the class lands on a rendered row. The actual
paint/layout saving itself isn't observable in jsdom -- it's the same
technique already trusted for ModListCard, just applied to the one other
component with the same long-list, fixed-row-height shape.
useQueryMods refetched from the network on every call even for the exact
same filters (toggling a filter off and back on, or leaving the Mods page
and returning, both re-ran the same request from scratch). Added a small
module-scoped, TTL'd (2 min) cache keyed by the exact request path the hook
already builds, capped at 20 distinct filter combinations.

Separately: ListMods' triggerQueryMods had no defense against two overlapping
calls (a filter changed again before the first request came back) -- whichever
resolved last won, regardless of which was asked for last, so a slow, already-
stale query could silently overwrite a filter the user had since moved past.
A per-call token now makes a superseded query's result a no-op instead.
True network-level cancellation isn't implemented: Electron's ipcRenderer.invoke
has no cancellation primitive without adding a whole new IPC surface (a cancel
channel, request ids tracked in main, wiring requestBoundedText to honor it),
which is a bigger change than this warranted -- the abandoned request still
completes in the main process, it just can no longer affect the UI.

tests/renderer-dom/useQueryMods.test.tsx covers the cache (hit, miss on
different filters, onFinish still firing on a hit). modsListMods.test.tsx
adds the out-of-order scenario directly: a slower "old" search and a faster
"new" one, asserting the newer one's result survives the older one finally
resolving after it.

The cache is module-scoped by design (every ListMods instance shares the
same ModDB catalog), which also means it outlives a single test unless
cleared -- clearQueryCache() is now called in modsListMods.test.tsx's
beforeEach so one test's cached response can't leak into another's
assertions.
validateArchive (archiveValidation.ts) ran on the main process's event loop,
in the EXTRACT_ON_PATH IPC handler, before the extraction worker was even
started. Its 7-Zip path (validateSevenZipArchive) spawns `7z l` and then
parses the listing synchronously: up to 100,000 entries over as much as 4MB
of text, real CPU work with no business blocking the main thread when a
worker thread is about to exist for this exact archive regardless.

Moved the call into runExtraction itself (workers/extraction.ts), as its
first step, before the temporary/target directories are even created. It
was already the first of two validation gates by design (the worker
re-validates the extracted tree in its own temp folder afterward); this
just moves gate one to sit where gate two already lived, both now inside
the worker instead of split across the main thread and the worker.

extractWorker.ts now forwards the actual rejection message instead of a
generic "Extraction failed" for every failure, validation included -- the
specific reasons (e.g. "Archive contains an unsafe entry") were previously
being swallowed for every failure past the point that used to be gate one,
which is a more informative error for a user or a bug report either way.

tests/ipc/extraction.test.ts's existing "cannot be read" case now asserts
against the more specific message and confirms the target directory is
never even created (previously it was created empty, since validation used
to run after that point). The pre-existing symlink-archive rejection test
needed no changes: its regex already matched either gate's wording.
@Zaldaryon
Zaldaryon requested a review from Pixnop August 20, 2026 13:03
GridItem's useInView call used once: false, motion/react's default. That
means the IntersectionObserver it creates per card (see node_modules/motion's
render/dom/viewport/index.mjs) never gets unobserved. It stays alive for the
card's whole lifetime, firing on every scroll in and out, and each firing
flips isInView back to false and then true again, replaying the entrance
fade every time a card re-enters the viewport, not just the first time.

Switched to once: true. Once a card has been seen, motion's own inView()
implementation calls observer.unobserve() on it, a real, documented
optimization in the library itself, not something this change invents. The
one visible behavior difference: a card that's been scrolled past once no
longer re-fades in on a later re-entry, it just stays visible. Nothing else
about the animation changes.

Scoped to GridItem only. List.tsx's ListItem doesn't use useInView at all,
so this doesn't apply there. A broader LazyMotion + `m` component migration
across all 19 files using motion/react was considered too (real bundle-size
win, well-documented technique) but skipped here: it's all-or-nothing across
every file for the bundle savings to materialize, and this pass has no
working way to visually verify a change that wide in this environment.

tests/renderer-dom/gridItemInView.test.tsx installs a controllable fake
IntersectionObserver (the one in setup.ts is a permanent no-op, so it can
never fire a callback to test against) and runs motion's real useInView/
inView code against it: confirms unobserve() gets called once a card is
first seen. Verified against the pre-fix code too: without once: true, the
same test's assertion fails with zero unobserve calls.

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this one verified hard, including live before/after measurement, because a +852 perf pass is where plausible-but-wrong hides best. The good news first, and it is genuinely good: zero Electron or Chromium flag changes, so the classic cargo-cult risk is absent; the content-visibility use on installed rows is sound (fixed-height element, no clipped descendants); moving validateArchive into the worker keeps defense in depth (removing gate one still fails the symlink archive at gate two, checked by mutation); the useCallback dependency arrays are all complete today; the PSNR claim on the background reproduces to the decimal; and the icon protocol's security ordering is right, the symlink check still runs before the cache. All gates pass with the exact numbers the body states.

Three changes before merge.

First, the queueing introduces a real shutdown hazard, verified in source. ConcurrencyLimiter.run releases its slot in a finally, so when before-quit's terminateActiveWorkers rejects the in-flight tasks, each rejection frees a slot, the queue drains, and createTrackedWorker spawns brand-new workers after the sweep already iterated the set. Since before-quit can preventDefault for the config flush, there is a live window where fresh downloads and extractions start writing to disk during shutdown, a hazard that did not exist before queueing. A shutdown flag the limiter checks before dequeuing (reject instead of run once quitting began) closes it in a few lines.

Second, the flagship optimizations are unprotected, shown by mutation: deleting the memo() on ModListCard leaves all 122 renderer-dom tests green (the memoization test mocks the card and wraps the spy in its own memo, so it pins the callback chain but not the export), and removing favMods from onToggleFavMod's deps also stays green while silently breaking un-favoriting, the correct-perf-wrong-data class. The single highest-leverage fix: add eslint-plugin-react-hooks with exhaustive-deps as an error. This PR makes the hot path depend on hand-written dep arrays; nothing currently catches a wrong one. A test that pins the memo through the real export (render, re-render parent with identical props, assert the card body executed once) covers the rest.

Third, the body needs to say what the queue changes for users, because three of the four behavior changes are user-visible: queued tasks hold the app-close blocker while pending (twenty mod updates now keep the window un-closable behind one slow download), the cap covers three of the five worker spawn sites (RUN_INSTALLER and CHANGE_PERMS stay uncapped, and the body reads as if the burst problem is solved), and one global FIFO means a game-version download queues behind mod downloads with no priority. State them or narrow the claims.

On the record as follow-ups rather than blockers: the module-scope icon memory cache is never cleared, so up to 16 MB stays resident in main forever after one grid visit, a strictly negative steady-state item in a memory-titled PR, and its 16 MB budget is only honored because a different file caps icons at 512 KB, a cross-file invariant neither documented nor tested; the cachemodimg protocol integration has zero tests; clearQueryCache belongs in global test setup rather than one file's beforeEach; and the background image kept its 3840x2160 dimensions, so the decode-and-raster framing in the commit overstates it, the real win is 3 MB off the installer while a 1440p re-export is where the memory actually is. Finally, measured dev-mode RSS and idle CPU show no detectable head-vs-base delta (renderer RSS varies 60 MB between identical runs), so the memory/CPU/GPU title over-promises what the body honestly does not claim.

…st.ts

FakeIntersectionObserver.instances[0] typechecks as possibly undefined
under this project's tsconfig, even right after asserting the array has
length 1: TypeScript does not narrow an index access from a separate
length check. Missed in the original commit because I only ran
typecheck:web there instead of the full typecheck (node + web + tests),
and tests run under their own tsconfig.
Every download, extraction, compression, and permissions change spun up a
fresh worker thread and terminated it right after, even for a burst of
back-to-back tasks (installing several mods in a row downloads, then
extracts, several times over). Each new Worker pays the full cost of a new
V8 isolate, roughly 10 to 30MB and some milliseconds, for no reason when
the same kind of work is about to run again seconds later.

This is a genuine trade-off, not a pure win: keeping idle workers alive
costs resident memory the old zero-idle-footprint design avoided. Sized
conservatively to keep that cost bounded: at most 3 idle download workers,
2 idle archive workers (extraction and compression share one limit since
both spawn a 7-Zip subprocess and compete for the same CPU cores), 1 idle
compression worker on top of that shared cap, and 0 for CHANGE_PERMS and
RUN_INSTALLER, which run once per install with no burst behind them and
would only pay for an idle isolate with nothing to show for it. An idle
worker not reused within 30 seconds is terminated. A worker is also
retired after 50 tasks regardless of reuse, a bound against any per-task
leak inside one isolate over a launcher session that runs for days.

The reason this needed more than a small tweak: workerData is read once,
at module load, and is fixed for a Worker's whole lifetime. Node has no
way to hand new workerData to an already-running worker. Reuse means the
five worker scripts (download, extract, compress, changeperms,
innoextract) had to stop reading their task out of workerData and instead
wait on parentPort for a message, run it, report the result, and go back
to waiting for the next one rather than exiting. That shared protocol now
lives in one place, workers/workerHost.ts's serveTasks, instead of being
duplicated five times: each shim is now a handler function plus a failure
describer, about eight lines each.

workerPool.ts is the pool itself, independent of Electron so it stays
testable with a fake worker factory. acquire() is synchronous on purpose:
nothing yields between taking a worker off the idle list and marking it
busy, which is what makes it impossible for two callers racing for the
same script to be handed the same worker, and impossible for an idle
timer to fire on a worker that was just taken.

runTrackedWorker in pathsHandlers.ts now asks a worker for reuse or
discards it depending on how a task actually ended, not always
terminating: a clean finish or a reported task failure both leave the
worker fit to serve another task (every logic module under
src/ipc/workers/ cleans up its own temp state in a finally block or its
own failure path), while a native worker error, an unexpected exit, or a
timeout all discard it, since the worker's state, or the abandoned task
still running inside it, can no longer be trusted. Messages now carry a
token so a reused worker's late message from an abandoned task, arriving
after a new task has already been dispatched to the same thread, gets
dropped instead of being mistaken for the new task's own progress or
result.

Tests: workerPool.test.ts covers the pool's own bookkeeping against a
fake worker factory (reuse, discard, the idle cap, idle timeout and its
cancellation on reuse, a worker dying while idle, retirement after
maxTasksPerWorker, per-script isolation). workerHost.test.ts covers the
worker-side protocol against a fake parentPort (token echo, a stray
progress report after settling, a task arriving while busy, a
synchronously throwing handler). Both existing pathsHandlers test files
are updated for the new acquireWorker-based mock shape and gained
disposition assertions (reuse vs discard) at each settle path, plus new
cases for the retire flag and a stale token being ignored.

One thing no mock can prove: that a real worker thread with a "message"
listener attached actually stays alive after finishing a task instead of
exiting, which is the Node-level guarantee the whole design depends on.
workerPoolLiveThread.test.ts checks it directly, no aliasing or workerHost
import, against a real worker_threads.Worker running a plain inline
script: post a task, get an answer, post a second task on the same
worker, get a second answer.

Verified end to end past the unit tests too: a full electron-vite build
plus electron-builder package, confirming workerHost.js comes out as its
own shared chunk that each of the five compiled worker entry files
requires, and a run of the packaged app confirms it starts cleanly with
the pool wired in from the first import.
…rkerPool

The config flush in before-quit calls event.preventDefault(), so quitting
is not instant. During that window a queued download, extraction, or
compression could still reach the front of ConcurrencyLimiter's queue and
start writing to disk, and WorkerPool.acquire() had no matching check, so
it could still spawn a fresh worker thread after terminateAll() had
already run. Both fixes give each object a one-way shuttingDown flag:
ConcurrencyLimiter now rejects every queued task the instant shutdown()
runs and rejects on arrival for anything after, wired to app's own
before-quit in pathsHandlers.ts; WorkerPool.acquire() now throws instead
of spawning once terminateAll() has already fired.

Found in review on PR #169.
The existing modsGridMemoization.test.tsx mocks ModListCard's whole
module and wraps its own mock function in memo() before asserting the
mock only rendered once, which only proves memo works on a spy: dropping
memo() from the real ModListCard.tsx leaves that suite green, since the
mock the test actually imports never changes. Confirmed by removing
memo() from the real export and watching that suite still pass.

This test imports the real, unmocked ModListCard and wraps the mod object
it renders in a Proxy that counts property reads. Only the component's
own function body ever reads a property off mod; memo's bailout check is
Object.is(prevProps.mod, nextProps.mod), comparing the Proxy reference
itself without touching a property on it. So the read count only advances
when ModListCard's body actually runs. A positive control test (toggling
isFav for real) proves the counter can detect a re-render at all, so the
main test cannot pass vacuously.

Verified against a real regression: removing memo() from ModListCard.tsx
fails this suite, restoring it passes again.

Found in review on PR #169.
configReducer's ADD_FAV_MOD pushes onto favMods without checking for an
existing entry, so onToggleFavMod only stays correct because it reads a
live favMods off its own useCallback dependency array rather than a stale
closure from the render that first mounted the button. Nothing exercised
clicking the same mod's favorite button twice in a row, so a regression
there (favoriting the same mod twice instead of toggling it back off)
had no test to catch it.

Verified against a real regression: dropping favMods from
onToggleFavMod's dependency array makes the second click push a
duplicate modid instead of removing it, and this test catches that
exact failure (favMods ends up [123, 123] instead of []).

Found in review on PR #169.
Neither rules-of-hooks nor exhaustive-deps was ever enforced here, so
onToggleFavMod's stale favMods closure and the earlier hasInstallation
fix could both have shipped without a lint error to catch them. A trial
run against the current tree turned up 21 pre-existing exhaustive-deps
violations across 13 files, all real gaps rather than false positives,
but each represents a design decision made before this rule existed and
fixing 21 dependency arrays sight-unseen risks introducing the exact
stale-closure bugs the rule exists to catch elsewhere. Those 13 files
keep exhaustive-deps at a warning; everywhere else, including new code,
it is an error.

ListMods.tsx is deliberately left off that list since it was already
being touched by this same PR: its 3 pre-existing violations plus the
scroll-listener cleanup's ref warning get inline disable comments with
reasons instead, matching the eslint-disable convention that already
exists elsewhere in this codebase.

Found in review on PR #169.
@Zaldaryon

Copy link
Copy Markdown
Collaborator Author

All three blockers are fixed, each in its own commit, each verified against a real regression rather than trusted on read:

  1. Shutdown race (3f12026): ConcurrencyLimiter now has a one-way shutdown(). Every queued task rejects the instant it runs, run() re-checks the flag after acquire() resolves (closing the microtask gap between resolving and the re-check), and release() refuses to dequeue once shutting down, so terminateActiveWorkers' own rejections can no longer hand a freed slot to a fresh task. Wired to before-quit in pathsHandlers.ts, right after the two limiters are constructed. WorkerPool.acquire() gets the matching one-liner: it throws instead of spawning once terminateAll() has already run. 4 new tests on the limiter (reject-while-queued, reject-on-arrival, the microtask-gap race, idempotent shutdown) and 1 on the pool.

  2. Unprotected memoization (5b425ef, bca7c69): the ModListCard test now imports the real, unmocked export and wraps the mod prop in a Proxy that counts property reads, since only the component's own render body ever touches a property on that object (memo's own check is Object.is on the reference, never a property read). Confirmed it fails when memo() is removed from the real file, confirmed it passes with the real code restored. Added a favorite-then-unfavorite round trip on ListMods too: confirmed it fails when favMods is dropped from onToggleFavMod's deps (ends up [123, 123] instead of [], exactly the silent double-favorite you called out), passes with the real deps array.

  3. eslint-plugin-react-hooks (695f473): on, rules-of-hooks and exhaustive-deps both as errors. A trial run against the current tree found 21 pre-existing violations across 13 files; those are down-scoped to a warning in .eslintrc.cjs rather than fixed sight-unseen in this PR (guessing at 21 dependency arrays risks introducing exactly the class of bug this rule exists to catch). ListMods.tsx itself is off that list since this PR already touches it: its 4 flagged spots (the scroll-listener cleanup's ref warning plus 3 effects) got inline disable comments with the actual reasoning, not blanket suppressions.

Body updated with the disclosure you asked for: queued tasks already hold the close-blocker before they reach the limiter (no separate "queued" state in the UI), only 3 of 5 worker spawn sites are capped (CHANGE_PERMS and RUN_INSTALLER's payload extraction aren't), and it's one global FIFO with no priority lanes. Also added a fourth note since it's a direct consequence of fix 1: a task still queued when before-quit fires now fails with a clear "cancelled because the app is quitting" error instead of either finishing late or hanging silently.

All gates green on the current head: typecheck, lint:ci (0 errors, 17 warnings, all inside the down-scoped list), format:check, test:coverage (107 files, 1218 passing, 2 skipped, coverage above floor on all four axes), build:unpack.

On the follow-ups, since you flagged them as non-blocking rather than required for this PR:

  • Icon memory cache never cleared, and the 16MB budget's dependence on the 512KB per-icon cap elsewhere being undocumented/untested: agreed, deferring. Worth its own pass rather than a rushed addition here.
  • Zero test coverage on the cachemodimg protocol integration: agreed, deferring for the same reason.
  • clearQueryCache belongs in global test setup, not one file's beforeEach: agreed, deferring, low risk either way but real cleanup.
  • Background image kept its 3840x2160 dimensions, so the real win there is installer size, not memory: fair, and reflected in how the body now describes that item. Not re-exporting at 1440p in this PR since that's a visible-quality call I don't want to make without the visual review this environment can't give me.
  • No detectable head-vs-base RSS/CPU delta in dev mode: acknowledged as a real limit on what this PR can claim, given the noise floor you measured. I haven't reworded the title; happy to if you'd rather it read narrower.

Not fixing any of these five in this PR. Flagging them in the private tracking doc as open follow-up work so they don't just evaporate once this merges.

@Pixnop Pixnop left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All three blockers are fixed and were verified by mutation rather than by reading: the post-acquire re-check kills its test when removed, both limiters' shutdown wiring is load-bearing (dropping either one fails its handler test), deleting the memo on ModListCard fails the new Proxy-counter test with a real number, and removing favMods from the deps fails both the round-trip test and lint:ci, so the belt and the braces both hold.

The worker pool that arrived with the rework got the same treatment and it holds up: twelve of fourteen mutations killed, no module-level state in any of the five worker logic paths (the load-bearing audit, since reuse is real), message correlation by token proven against a deliberately stale straggler, dead workers never returned to the pool, and the installer timeout plus tree-kill untouched. And I closed the one unknown the automated suite cannot reach: built this branch with build:unpack, launched the packaged binary, and drove two consecutive extractions through the pool over its real IPC. Both succeeded with correct trees on disk, so the rewritten worker loading and the postMessage protocol work under the production bundle, not just under vitest.

Three small corrections for a follow-up commit, none blocking. The dequeue-refusal in release() is unreachable by construction (shutdown() empties the queue and acquire() rejects on arrival), so the comment presenting it as the third load-bearing mechanism is wrong; keep the line as cheap hardening if you like, but the comment should say what is true. The terminateAll test acquires and releases the same worker, so it never actually holds an idle and a busy worker at once; acquiring two before releasing one makes it mean what its name says, and idle-worker termination at quit deserves a real pin since nothing else covers it. And the body's one global FIFO wording is now wrong in the other direction: there are two lanes that do not block each other; the head-of-line point stands within the download lane only.

For the record from both review rounds: WORKER_POOL_MAX_IDLE's stated invariant overshoots (extract and compress keep separate idle lists sharing one limiter, so idle can reach 3 where busy caps at 2), the two uncapped call sites now surface the pool's raw shutdown message rather than the localized one, the five worker shims sit at zero coverage, the module-scope icon memory cache still never clears, and the old mock-wrapping memoization test still passes without the memo, worth deleting so the next reader is not reassured by it.

@Zaldaryon
Zaldaryon merged commit e4feb4e into dev Aug 21, 2026
7 checks passed
@Zaldaryon
Zaldaryon deleted the perf/engine-optimization-pass branch August 21, 2026 12:50
Zaldaryon pushed a commit that referenced this pull request Aug 22, 2026
PR #169 recompressed this file but left it at 3840x2160, and the review
pointed out that the dimensions are where the memory actually is: Chromium
decodes to the intrinsic size, so every window paid for a 4K bitmap no
matter how small it was. The decoded ceiling drops from 31.6 MiB
(3840*2160*4) to 14.1 MiB (2560*1440*4).

Re-exported from the pre-#169 quality-99 original recovered from git
history rather than from the shipped quality-76 file, which avoids a
second generation of JPEG loss. Lanczos in linear light, then quality 92
with 4:4:4 chroma. Measured at 40.49 dB PSNR against an uncompressed
Lanczos downscale of that original, matching the 40.53 dB bar #169 held
itself to. File size 1,581,834 -> 1,381,147 bytes.

2560x1440 lands 1:1 on a maximized window on a 1440p panel and still has
headroom for a 1280-wide window at devicePixelRatio 2. A maximized window
on a 4K panel upscales 1.5x, which sits under the 2px backdrop-blur the
root div already applies over this image.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants