perf+fix: sprint B — async I/O, before-quit, immutable getState, lucide tree-shake

#2  atomicWrite spin-loop → async setTimeout. Раньше при retry на
    EBUSY/EPERM (антивирус, OneDrive) main process замораживался на
    50/200/800ms × до 3 итераций ≈ секунда залипания UI. Сейчас async
    sleep — event-loop живёт. Сохранён atomicWriteSync для flushNow
    (вызывается из before-quit когда event-loop уже умирает).
    Аналогичный фикс в games/steam-launch-options.ts.
#5  before-quit теперь дожидается stopGamesRegistry через
    e.preventDefault() + app.exit(0). Раньше GSI HTTP server не успевал
    closeAllConnections до exit, и следующий запуск получал
    EADDRINUSE на port 4701 (TIME_WAIT) — GSI молча не работал.
#10 IPC.getState возвращает поверхностную копию settings вместо мутации
    кэша. Раньше startWithWindows писалось напрямую в state.settings,
    разъезжаясь с persisted-disk-значением до следующего mutation.
#19 lib/icon.tsx: `import * as Lucide` (wildcard, ~500KB в bundle,
    1500+ иконок) → explicit named imports + ICON_MAP. В bundle
    остаются только 18 ICON_CHOICES.
This commit is contained in:
AnRil
2026-05-22 01:15:31 +07:00
parent a41dce511b
commit 4745f5e091
5 changed files with 137 additions and 35 deletions

View File

@@ -166,7 +166,8 @@ function load(): AppState {
const p = getStorePath()
if (!existsSync(p)) {
const initial = makeInitial()
atomicWrite(
// Cold path — sync write на инициализации (event-loop ещё не активен).
atomicWriteSync(
p,
JSON.stringify(
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
@@ -235,8 +236,16 @@ export function clearHistory(beforeTs?: number): number {
/**
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
*
* Async version (используется debounced scheduleWrite/flush) — раньше был
* busy-loop `while (Date.now() < until)`, который морозил весь main process
* на retry-delay (до 800мс). При активном AV это превращалось в видимое
* залипание UI. Сейчас sleep через setTimeout-promise.
*
* Для процесса-выхода используется `atomicWriteSync` — там event-loop уже
* не работает, async sleep не сработает.
*/
function atomicWrite(path: string, contents: string): void {
async function atomicWrite(path: string, contents: string): Promise<void> {
const tmp = `${path}.tmp`
let lastErr: unknown
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
@@ -246,7 +255,6 @@ function atomicWrite(path: string, contents: string): void {
return
} catch (e) {
lastErr = e
// best-effort cleanup of the stale .tmp
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
@@ -254,29 +262,63 @@ function atomicWrite(path: string, contents: string): void {
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// Synchronous sleep — write path is short and called outside the hot loop.
await new Promise<void>((r) => setTimeout(r, delay))
}
}
console.error('[store] atomic write failed after retries:', lastErr)
}
/**
* Синхронный вариант для use-cases где event loop уже не работает
* (process exit в `before-quit`). При retry — короткий sync sleep, потому
* что иначе мы дропнем pending write при exit'е.
*/
function atomicWriteSync(path: string, contents: string): void {
const tmp = `${path}.tmp`
let lastErr: unknown
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
try {
writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path)
return
} catch (e) {
lastErr = e
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
/* ignore */
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// Event-loop остановлен, async sleep не вернётся — приходится spin.
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
}
}
console.error('[store] atomic write failed after retries:', lastErr)
console.error('[store] atomic sync write failed after retries:', lastErr)
}
function flush(): void {
async function flush(): Promise<void> {
if (!cache) return
// Persist the schema version alongside the state so future migrations know
// where to pick up from. The renderer never reads this key.
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
}
function flushSync(): void {
if (!cache) return
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2))
}
function scheduleWrite(): void {
if (pendingWrite) return
pendingWrite = setTimeout(() => {
pendingWrite = null
flush()
void flush()
}, WRITE_DEBOUNCE_MS)
// Don't keep the event loop alive solely for a pending write — `before-quit`
// calls `flushNow()` and we explicitly want the process to exit on schedule.
@@ -389,7 +431,9 @@ export function flushNow(): void {
clearTimeout(pendingWrite)
pendingWrite = null
}
flush()
// before-quit вызывает нас когда event-loop уже на пути к выходу — async
// promise не успеет resolved, поэтому sync.
flushSync()
}
export function getChallenges(): Challenge[] {