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:
@@ -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
|
||||
// few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes
|
||||
// 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 delays = [0, 50, 200]
|
||||
let lastErr: unknown
|
||||
for (const delay of delays) {
|
||||
if (delay > 0) {
|
||||
const until = Date.now() + delay
|
||||
while (Date.now() < until) {
|
||||
/* spin */
|
||||
}
|
||||
}
|
||||
if (delay > 0) await new Promise<void>((r) => setTimeout(r, delay))
|
||||
try {
|
||||
writeFileSync(tmp, contents, 'utf-8')
|
||||
renameSync(tmp, path)
|
||||
@@ -148,11 +147,11 @@ function atomicWrite(path: string, contents: string): void {
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
function modifyLaunchOptions(
|
||||
async function modifyLaunchOptions(
|
||||
configPath: string,
|
||||
appId: string,
|
||||
fn: (current: string) => string | null
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8')
|
||||
@@ -188,7 +187,7 @@ function modifyLaunchOptions(
|
||||
|
||||
writeBackup(configPath)
|
||||
try {
|
||||
atomicWrite(configPath, stringifyVdf(parsed))
|
||||
await atomicWrite(configPath, stringifyVdf(parsed))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -225,7 +224,7 @@ async function applyOptionToAllConfigs(
|
||||
): Promise<void> {
|
||||
const paths = await getLocalConfigPaths()
|
||||
for (const p of paths) {
|
||||
modifyLaunchOptions(p, appId, (current) => {
|
||||
await modifyLaunchOptions(p, appId, (current) => {
|
||||
if (current.includes(option)) return current
|
||||
return current.length > 0 ? `${current} ${option}` : option
|
||||
})
|
||||
@@ -238,7 +237,7 @@ async function removeOptionFromAllConfigs(
|
||||
): Promise<void> {
|
||||
const paths = await getLocalConfigPaths()
|
||||
for (const p of paths) {
|
||||
modifyLaunchOptions(p, appId, (current) => {
|
||||
await modifyLaunchOptions(p, appId, (current) => {
|
||||
if (!current.includes(option)) return current
|
||||
return current
|
||||
.split(/\s+/)
|
||||
|
||||
@@ -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()
|
||||
stopUpdater()
|
||||
void stopGamesRegistry()
|
||||
flushNow()
|
||||
void (async () => {
|
||||
try {
|
||||
await stopGamesRegistry()
|
||||
} catch (err) {
|
||||
console.error('[index] stopGamesRegistry threw:', err)
|
||||
}
|
||||
flushNow()
|
||||
app.exit(0)
|
||||
})()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -56,9 +56,16 @@ import {
|
||||
|
||||
export function registerIpc(): void {
|
||||
ipcMain.handle(IPC.getState, () => {
|
||||
// Накладываем актуальное значение autostart (источник истины — OS),
|
||||
// но НЕ мутируем кэш. Раньше прямая мутация state.settings оставляла
|
||||
// в RAM startWithWindows, отличающийся от persisted-disk-значения,
|
||||
// и при следующем flush на диск шла OS-правда, а не пользовательский
|
||||
// toggle. Сейчас возвращаем поверхностную копию.
|
||||
const state = getState()
|
||||
state.settings.startWithWindows = isAutostartEnabled()
|
||||
return state
|
||||
return {
|
||||
...state,
|
||||
settings: { ...state.settings, startWithWindows: isAutostartEnabled() }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 { ICON_CHOICES, type IconName } from './icon-choices'
|
||||
|
||||
// Re-export для обратной совместимости с импортёрами icon.tsx.
|
||||
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 —
|
||||
@@ -17,15 +57,12 @@ export function Icon({
|
||||
name,
|
||||
...props
|
||||
}: { name: string } & LucideProps): JSX.Element {
|
||||
if (!ICON_SET.has(name)) {
|
||||
const Cmp = ICON_MAP[name as IconName]
|
||||
if (!Cmp) {
|
||||
if (import.meta.env.DEV) {
|
||||
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} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user