Skip to content

Commit 04f7998

Browse files
committed
v2.6.1 — Performance & Manifest Stability
1 parent 89a1278 commit 04f7998

18 files changed

Lines changed: 626 additions & 286 deletions

CHANGELOG.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,52 @@
11
# Changelog
2+
3+
## v2.6.1 — Performance & Manifest Stability
4+
5+
A comprehensive sweep addressing performance regressions, manifest corruption risk, and child-process lifecycle issues.
6+
7+
### Catalog & Game Data
8+
9+
- **Fixed: full catalog was re-downloaded and re-normalized on every online/offline flip.** The 6-hour TTL on `/api/games` was imported but never checked; `shouldRefreshGames` was hardwired to `connectivity.isOnline`. The entire catalog and all game entries were re-normalized (regex extraction, NFD, searchText) on every online transition and page mount. Now correctly gates on `isCatalogGamesStale()`, matching the stats TTL already in place. This alone cuts catalog churn by ~99% on the home screen.
10+
- **Fixed: catalog games were normalized twice on every persist.** `persistCatalogCache` pre-normalized games, then `setCatalogCache` normalized the same array again — expensive regex work (developer extraction, NFD searchText, spreads) running 2× per game per save. Now normalizes once. Combined with the TTL fix, main-thread catalog work drops ~95%.
11+
12+
### Manifests & Storage
13+
14+
- **Fixed: installed.json could be corrupted on crash mid-write.** `uc_writeJsonSync` overwrote the live file directly; a crash/power-loss/full-disk mid-write left a truncated manifest → game vanishes from library or loses saved executable path. All manifest writes now use atomic temp-file + rename. Same fix applied to the download engine's manifest snapshots. Plus: removed the torn-write fallback (direct overwrite on rename failure) that re-introduced corruption.
15+
- **Fixed: storage reservation over-counted during extraction.** `markExtracting()` was exported but never called, so every reservation held `downloadBytes + extractBytes` for its entire lifetime and falsely rejected concurrent downloads as out-of-space. Now wired at extraction start (both pipeline paths). Reservation space is correctly freed once the archive is on disk.
16+
17+
### Download Engine & Child Processes
18+
19+
- **Fixed: the aria2 daemon could be orphaned on app quit.** `stop()` relied on a post-quit `setTimeout(1500)` kill that Electron's `will-quit` never keeps alive. The daemon kept writing to disk and holding the RPC port. Now kills deterministically via `taskkill /T /F` on Windows (`spawnSync`, not async) and `SIGTERM` on Unix.
20+
- **Fixed: overlapping poll ticks could race manifest writes and double-fire completion.** The 700ms `_pollAria2` interval fired regardless of whether the previous async poll finished. Under RPC latency, ticks overlapped, issuing concurrent `tellStatus` calls and corrupting the manifest via simultaneous writes. Added re-entrancy guard: the poller skips if a tick is in-flight.
21+
- **Fixed: cancelled downloads left stale aria2 control files.** `cancel()` deleted the partial + `.crdownload` + resume backup but not the `.aria2 control file`, so a re-download resumed against stale segmented-download metadata and produced a corrupt file. Now also deletes the control file.
22+
- **Fixed: an unhandled error could crash the main process.** `terminateChildProcess` spawned `taskkill` with no `error` listener. If the binary wasn't launchable (e.g. not on PATH in a stripped environment), an unhandled `error` event hard-crashed the main process. Added the listener + fallback kill.
23+
24+
### Image & Resource Loading
25+
26+
- **Fixed: the image-failure cache used O(n log n) eviction on a hot path.** Every `<img>` onError during a CDN outage called `Array.from(cache.entries()).sort()` to drop the oldest entries. Now uses the Map's insertion-order iterator — O(n) and negligible on a 1024-entry cache.
27+
- **Fixed: uc-local:// image serving blocked the main thread.** The (already-async) protocol handler used `fs.existsSync` + `fs.readFileSync`, blocking the event loop on every local game image request. Now uses `fs.promises.access` + `readFile`. This unblocks the launcher UI while waiting for disk I/O during local asset loads.
28+
29+
### React Performance & Re-renders
30+
31+
- **Fixed: LibraryPage re-rendered ~5×/sec during any download.** The 2400-line page subscribed to the raw `downloads` array via `useDownloads()`, so every batched progress tick (200ms cadence) re-rendered the entire page and its 24-card grid. The library's download-derived logic (membership scan, failed-appids, status signatures) only needs `{appid, status}`, not byte counters. Now uses a narrow `useDownloadsSelector` with content-equality, keeping the page un-rendered except on real membership/status changes. `GameCard`'s memo is now effective.
32+
- **Fixed: GameDetailPage re-issued disk IPC on every download progress byte.** Two effects keyed on the raw `downloads` array re-ran `getInstalled`/`listInstalledByAppid` many times per second during active downloads. Now keys on a narrow per-appid status signature (`id:status`) string, so meaningful transitions re-trigger the effects but byte-progress ticks don't.
33+
- **Fixed: SearchPage filter sidebar remounted on every keystroke.** `FilterPanel` was a React component *defined inside the render body*, so its identity changed on every keystroke and the entire filter sidebar (genres + ~200 developer buttons) unmounted and remounted. Now invoked inline as `{FilterPanel()}` so it reconciles in place.
34+
- **Fixed: DownloadsPage running-games check issued N sequential IPC calls every 3 seconds.** With N installed games this stalled the whole polling thread. Now parallelizes with `Promise.all` + a Map lookup.
35+
- **Fixed: CollectionsPage re-ran expensive per-card loops on every parent render.** Membership scan, update-version check per installed appid, and cover-mosaic build recomputed for every collection card (search typing, menu toggles) even though they only depend on that collection + the stable lookup maps. Now memoized.
36+
37+
### Search & Sorting
38+
39+
- **Fixed: SearchPage sorted React state in place.** When no size/online filter narrowed the games array, `filtered` was the raw `games` state and `filtered.sort()` mutated React state directly. This can desync derived arrays and causes inconsistent renders. Now clones before sorting.
40+
- **Fixed: SearchPage random sort re-shuffled the grid when stats arrived.** The shuffle seed was `Date.now()` — so when `gameStats` landed (a separate fetch after games), the entire grid reshuffled and every card remounted. Now uses a deterministic content-derived seed, keeping the order stable across re-renders.
41+
42+
### Download UI & Progress
43+
44+
- **Fixed: progress flush timer could leak, allowing stale updates to fire post-unmount.** The batched-progress `setTimeout` was never cleared when the provider unmounted or the onUpdate effect re-subscribed, so a pending flush could call `setDownloads` after component unmount (silent error in dev, potential state corruption in prod). Now clears on cleanup. Also: drops stale pending progress entries when a status-changing update arrives, so a queued byte-update can't clobber newer state on the next flush.
45+
46+
### Type Safety
47+
48+
- **Fixed: 3 pre-existing TypeScript errors in DownloadsPage.** The `primaryStatsRef` type was missing the `phase` field (it was hand-written and had drifted from `computeGroupStats`'s return shape), producing `TS2339` errors on every `stats.phase` read. Now derives the ref type from `computeGroupStats` so it stays in sync.
49+
250
## v2.6.0 - Launch Reliability
351

452
### Game launching

electron/aria2-manager.cjs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const net = require('node:net')
2525
const crypto = require('node:crypto')
2626
const fs = require('node:fs')
2727
const path = require('node:path')
28-
const { spawn } = require('node:child_process')
28+
const { spawn, spawnSync } = require('node:child_process')
2929

3030
/** Resolve the aria2c binary path for this platform, or null if not found.
3131
* Looks first in the bundled assets dir (packaged + dev), then falls back to
@@ -186,15 +186,29 @@ class Aria2Manager {
186186

187187
stop() {
188188
this._ready = false
189-
if (this.proc) {
190-
try {
191-
// Ask aria2 to shut down cleanly so it flushes .aria2 control files
192-
// (needed for exact resume next launch); fall back to kill.
193-
this._rpc('aria2.forceShutdown', []).catch(() => {})
194-
} catch { /* ignore */ }
195-
const proc = this.proc
196-
this.proc = null
197-
setTimeout(() => { try { if (!proc.killed) proc.kill() } catch { /* ignore */ } }, 1500)
189+
this._startPromise = null
190+
const proc = this.proc
191+
this.proc = null
192+
if (!proc) return
193+
try {
194+
// Best-effort clean shutdown so aria2 flushes its .aria2 control files
195+
// (helps exact segmented resume next launch).
196+
this._rpc('aria2.forceShutdown', []).catch(() => {})
197+
} catch { /* ignore */ }
198+
// Then kill DETERMINISTICALLY. stop() runs inside Electron's 'will-quit',
199+
// which does not keep the event loop alive for a deferred timer — the old
200+
// setTimeout(..., 1500) kill never fired, orphaning aria2c (it kept holding
201+
// the RPC port and writing partial files to disk). On Windows kill the whole
202+
// process tree synchronously via taskkill; elsewhere SIGTERM. spawnSync
203+
// blocks will-quit only for the few ms taskkill needs.
204+
try {
205+
if (process.platform === 'win32' && proc.pid) {
206+
spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { windowsHide: true, timeout: 4000 })
207+
} else {
208+
proc.kill('SIGTERM')
209+
}
210+
} catch {
211+
try { proc.kill() } catch { /* ignore */ }
198212
}
199213
}
200214

electron/download-engine.cjs

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ class DownloadEngine extends EventEmitter {
280280
safeUnlink(dl.savePath)
281281
safeUnlink(dl.savePath + '.crdownload')
282282
safeUnlink(dl.savePath + RESUME_BACKUP_EXT)
283+
// Also remove aria2's segment control file. Without this, a future
284+
// download of the same file would resume against stale .aria2 control
285+
// data describing a file we just deleted, producing a corrupt result.
286+
safeUnlink(dl.savePath + '.aria2')
283287
}
284288

285289
dl.status = 'cancelled'
@@ -498,46 +502,57 @@ class DownloadEngine extends EventEmitter {
498502

499503
async _pollAria2() {
500504
if (!this.aria2 || !this.aria2.isReady()) return
501-
// Snapshot the gids we currently track so concurrent mutation is safe.
502-
const entries = []
503-
for (const dl of this.byId.values()) {
504-
if (dl._gid && dl.status !== 'completed' && dl.status !== 'failed' && dl.status !== 'cancelled') {
505-
entries.push(dl)
506-
}
507-
}
508-
if (entries.length === 0) { this._stopAria2PollerIfIdle(); return }
509-
for (const dl of entries) {
510-
let status
511-
try {
512-
status = await this.aria2.tellStatus(dl._gid)
513-
} catch (err) {
514-
// Transient RPC hiccup — leave state as-is and try again next tick.
515-
continue
505+
// Re-entrancy guard: the driving setInterval fires every 700ms regardless of
506+
// whether the previous (async) poll finished. Under RPC latency or many
507+
// concurrent gids a tick can exceed 700ms; overlapping ticks would issue
508+
// concurrent tellStatus calls and race manifest writes / double-fire
509+
// completion (_finishAria2) for the same gid. Skip if a poll is in flight.
510+
if (this._polling) return
511+
this._polling = true
512+
try {
513+
// Snapshot the gids we currently track so concurrent mutation is safe.
514+
const entries = []
515+
for (const dl of this.byId.values()) {
516+
if (dl._gid && dl.status !== 'completed' && dl.status !== 'failed' && dl.status !== 'cancelled') {
517+
entries.push(dl)
518+
}
516519
}
517-
const completed = Number(status.completedLength) || 0
518-
const total = Number(status.totalLength) || 0
519-
const speed = Number(status.downloadSpeed) || 0
520-
if (total > 0) dl.totalBytes = total
521-
if (completed > 0) dl.receivedBytes = completed
522-
523-
if (status.status === 'complete') {
524-
this._finishAria2(dl, 'complete')
525-
} else if (status.status === 'error') {
526-
const msg = status.errorMessage || `aria2 error ${status.errorCode || ''}`.trim()
527-
this._finishAria2(dl, 'error', msg)
528-
} else if (status.status === 'removed') {
529-
// We initiated this via cancel(); cleanup already handled there.
530-
this._gidToId.delete(dl._gid)
531-
dl._gid = null
532-
} else {
533-
// active / waiting / paused
534-
dl.status = status.status === 'paused' ? 'paused' : 'downloading'
535-
dl.speedBps = dl.status === 'paused' ? 0 : speed
536-
const remaining = total > 0 ? Math.max(0, total - completed) : 0
537-
dl.etaSeconds = speed > 0 && remaining > 0 ? Math.round(remaining / speed) : null
538-
this.emit('update', this._publicView(dl))
539-
this._writeManifestSnapshotThrottled(dl)
520+
if (entries.length === 0) { this._stopAria2PollerIfIdle(); return }
521+
for (const dl of entries) {
522+
let status
523+
try {
524+
status = await this.aria2.tellStatus(dl._gid)
525+
} catch (err) {
526+
// Transient RPC hiccup — leave state as-is and try again next tick.
527+
continue
528+
}
529+
const completed = Number(status.completedLength) || 0
530+
const total = Number(status.totalLength) || 0
531+
const speed = Number(status.downloadSpeed) || 0
532+
if (total > 0) dl.totalBytes = total
533+
if (completed > 0) dl.receivedBytes = completed
534+
535+
if (status.status === 'complete') {
536+
this._finishAria2(dl, 'complete')
537+
} else if (status.status === 'error') {
538+
const msg = status.errorMessage || `aria2 error ${status.errorCode || ''}`.trim()
539+
this._finishAria2(dl, 'error', msg)
540+
} else if (status.status === 'removed') {
541+
// We initiated this via cancel(); cleanup already handled there.
542+
this._gidToId.delete(dl._gid)
543+
dl._gid = null
544+
} else {
545+
// active / waiting / paused
546+
dl.status = status.status === 'paused' ? 'paused' : 'downloading'
547+
dl.speedBps = dl.status === 'paused' ? 0 : speed
548+
const remaining = total > 0 ? Math.max(0, total - completed) : 0
549+
dl.etaSeconds = speed > 0 && remaining > 0 ? Math.round(remaining / speed) : null
550+
this.emit('update', this._publicView(dl))
551+
this._writeManifestSnapshotThrottled(dl)
552+
}
540553
}
554+
} finally {
555+
this._polling = false
541556
}
542557
}
543558

@@ -641,11 +656,18 @@ class DownloadEngine extends EventEmitter {
641656
host: 'ucfiles',
642657
updatedAt: Date.now(),
643658
}
644-
// Atomic-ish write.
659+
// Atomic write: temp file + rename. The previous direct-overwrite fallback
660+
// on rename failure reintroduced exactly the torn-write corruption the
661+
// temp+rename was meant to prevent. If the rename fails, leave the
662+
// existing manifest intact (a stale-but-valid manifest is recoverable; a
663+
// half-written one is not) and clean up the temp file.
645664
const tmp = manifestPath + '.tmp'
646665
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2))
647-
try { fs.renameSync(tmp, manifestPath) } catch {
648-
try { fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) } catch { /* ignore */ }
666+
try {
667+
fs.renameSync(tmp, manifestPath)
668+
} catch (renameErr) {
669+
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
670+
this.log('warn', `[engine] manifest rename failed for ${dl.id}: ${renameErr?.message || renameErr}`)
649671
}
650672
} catch (err) {
651673
this.log('warn', `[engine] manifest write failed for ${dl.id}: ${err?.message || err}`)

0 commit comments

Comments
 (0)