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

@@ -123,20 +123,19 @@ function writeBackup(path: string): void {
} }
} }
function atomicWrite(path: string, contents: string): void { async function atomicWrite(path: string, contents: string): Promise<void> {
// Write to temp then rename (atomic on Windows for same directory). Retry a // Write to temp then rename (atomic on Windows for same directory). Retry a
// few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes // few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes
// hold a handle briefly during a Steam config rewrite). // hold a handle briefly during a Steam config rewrite).
//
// Раньше тут был busy-loop sleep — Steam-конфиги пишутся редко, но из main
// process, и при попадании на занятый файл (Steam ещё держит handle) морозили
// весь UI на 250мс. Заменили на async setTimeout-sleep.
const tmp = path + '.exr.tmp' const tmp = path + '.exr.tmp'
const delays = [0, 50, 200] const delays = [0, 50, 200]
let lastErr: unknown let lastErr: unknown
for (const delay of delays) { for (const delay of delays) {
if (delay > 0) { if (delay > 0) await new Promise<void>((r) => setTimeout(r, delay))
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
}
try { try {
writeFileSync(tmp, contents, 'utf-8') writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path) renameSync(tmp, path)
@@ -148,11 +147,11 @@ function atomicWrite(path: string, contents: string): void {
throw lastErr throw lastErr
} }
function modifyLaunchOptions( async function modifyLaunchOptions(
configPath: string, configPath: string,
appId: string, appId: string,
fn: (current: string) => string | null fn: (current: string) => string | null
): boolean { ): Promise<boolean> {
let raw: string let raw: string
try { try {
raw = readFileSync(configPath, 'utf-8') raw = readFileSync(configPath, 'utf-8')
@@ -188,7 +187,7 @@ function modifyLaunchOptions(
writeBackup(configPath) writeBackup(configPath)
try { try {
atomicWrite(configPath, stringifyVdf(parsed)) await atomicWrite(configPath, stringifyVdf(parsed))
} catch { } catch {
return false return false
} }
@@ -225,7 +224,7 @@ async function applyOptionToAllConfigs(
): Promise<void> { ): Promise<void> {
const paths = await getLocalConfigPaths() const paths = await getLocalConfigPaths()
for (const p of paths) { for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => { await modifyLaunchOptions(p, appId, (current) => {
if (current.includes(option)) return current if (current.includes(option)) return current
return current.length > 0 ? `${current} ${option}` : option return current.length > 0 ? `${current} ${option}` : option
}) })
@@ -238,7 +237,7 @@ async function removeOptionFromAllConfigs(
): Promise<void> { ): Promise<void> {
const paths = await getLocalConfigPaths() const paths = await getLocalConfigPaths()
for (const p of paths) { for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => { await modifyLaunchOptions(p, appId, (current) => {
if (!current.includes(option)) return current if (!current.includes(option)) return current
return current return current
.split(/\s+/) .split(/\s+/)

View File

@@ -73,11 +73,26 @@ if (!gotLock) {
} }
}) })
app.on('before-quit', () => { // Перехватываем первый before-quit, чтобы дождаться `stopGamesRegistry`
// (закрывает GSI HTTP server со всеми pending connections). Без этого
// следующий запуск получает EADDRINUSE на port 4701 (TIME_WAIT), и
// GSI молча не работает. После cleanup'а — реально quit.
let quitting = false
app.on('before-quit', (e) => {
if (quitting) return
e.preventDefault()
quitting = true
stopScheduler() stopScheduler()
stopUpdater() stopUpdater()
void stopGamesRegistry() void (async () => {
try {
await stopGamesRegistry()
} catch (err) {
console.error('[index] stopGamesRegistry threw:', err)
}
flushNow() flushNow()
app.exit(0)
})()
}) })
app.on('activate', () => { app.on('activate', () => {

View File

@@ -56,9 +56,16 @@ import {
export function registerIpc(): void { export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => { ipcMain.handle(IPC.getState, () => {
// Накладываем актуальное значение autostart (источник истины — OS),
// но НЕ мутируем кэш. Раньше прямая мутация state.settings оставляла
// в RAM startWithWindows, отличающийся от persisted-disk-значения,
// и при следующем flush на диск шла OS-правда, а не пользовательский
// toggle. Сейчас возвращаем поверхностную копию.
const state = getState() const state = getState()
state.settings.startWithWindows = isAutostartEnabled() return {
return state ...state,
settings: { ...state.settings, startWithWindows: isAutostartEnabled() }
}
}) })
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {

View File

@@ -166,7 +166,8 @@ function load(): AppState {
const p = getStorePath() const p = getStorePath()
if (!existsSync(p)) { if (!existsSync(p)) {
const initial = makeInitial() const initial = makeInitial()
atomicWrite( // Cold path — sync write на инициализации (event-loop ещё не активен).
atomicWriteSync(
p, p,
JSON.stringify( JSON.stringify(
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial }, { __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 * Atomically write to `path` via a sibling .tmp file + rename. Retries a few
* times on transient EBUSY/EPERM (AV/OneDrive holding the file). * 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` const tmp = `${path}.tmp`
let lastErr: unknown let lastErr: unknown
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) { for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
@@ -246,7 +255,6 @@ function atomicWrite(path: string, contents: string): void {
return return
} catch (e) { } catch (e) {
lastErr = e lastErr = e
// best-effort cleanup of the stale .tmp
try { try {
if (existsSync(tmp)) unlinkSync(tmp) if (existsSync(tmp)) unlinkSync(tmp)
} catch { } catch {
@@ -254,29 +262,63 @@ function atomicWrite(path: string, contents: string): void {
} }
const delay = WRITE_RETRY_DELAYS[i] const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break 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 const until = Date.now() + delay
while (Date.now() < until) { while (Date.now() < until) {
/* spin */ /* 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 if (!cache) return
// Persist the schema version alongside the state so future migrations know // Persist the schema version alongside the state so future migrations know
// where to pick up from. The renderer never reads this key. // where to pick up from. The renderer never reads this key.
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache } 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 { function scheduleWrite(): void {
if (pendingWrite) return if (pendingWrite) return
pendingWrite = setTimeout(() => { pendingWrite = setTimeout(() => {
pendingWrite = null pendingWrite = null
flush() void flush()
}, WRITE_DEBOUNCE_MS) }, WRITE_DEBOUNCE_MS)
// Don't keep the event loop alive solely for a pending write — `before-quit` // 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. // calls `flushNow()` and we explicitly want the process to exit on schedule.
@@ -389,7 +431,9 @@ export function flushNow(): void {
clearTimeout(pendingWrite) clearTimeout(pendingWrite)
pendingWrite = null pendingWrite = null
} }
flush() // before-quit вызывает нас когда event-loop уже на пути к выходу — async
// promise не успеет resolved, поэтому sync.
flushSync()
} }
export function getChallenges(): Challenge[] { export function getChallenges(): Challenge[] {

View File

@@ -1,11 +1,51 @@
import * as Lucide from 'lucide-react' // Explicit-named imports — НЕ wildcard. Wildcard `* as Lucide` ломает
// tree-shaking: в bundle попадает вся библиотека (~500KB minified, 1500+
// иконок). Сейчас в bundle только 18 ICON_CHOICES.
import {
Activity,
Dumbbell,
StretchHorizontal,
PersonStanding,
Heart,
Footprints,
Hand,
Eye,
Brain,
Bike,
Waves,
Wind,
Sun,
Coffee,
Apple,
GlassWater,
BookOpen,
Sparkles
} from 'lucide-react'
import type { LucideProps } from 'lucide-react' import type { LucideProps } from 'lucide-react'
import { ICON_CHOICES, type IconName } from './icon-choices' import { ICON_CHOICES, type IconName } from './icon-choices'
// Re-export для обратной совместимости с импортёрами icon.tsx.
export { ICON_CHOICES, type IconName } export { ICON_CHOICES, type IconName }
const ICON_SET = new Set<string>(ICON_CHOICES) const ICON_MAP: Record<IconName, React.ComponentType<LucideProps>> = {
Activity,
Dumbbell,
StretchHorizontal,
PersonStanding,
Heart,
Footprints,
Hand,
Eye,
Brain,
Bike,
Waves,
Wind,
Sun,
Coffee,
Apple,
GlassWater,
BookOpen,
Sparkles
}
/** /**
* Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set — * Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set —
@@ -17,15 +57,12 @@ export function Icon({
name, name,
...props ...props
}: { name: string } & LucideProps): JSX.Element { }: { name: string } & LucideProps): JSX.Element {
if (!ICON_SET.has(name)) { const Cmp = ICON_MAP[name as IconName]
if (!Cmp) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn(`[Icon] unknown icon name "${name}" — falling back`) console.warn(`[Icon] unknown icon name "${name}" — falling back`)
} }
return <Lucide.Activity {...props} /> return <Activity {...props} />
} }
const Cmp = (
Lucide as unknown as Record<string, React.ComponentType<LucideProps>>
)[name]
if (!Cmp) return <Lucide.Activity {...props} />
return <Cmp {...props} /> return <Cmp {...props} />
} }