fix(main): 修复 config.json 并发读写导致的 EBUSY 主进程崩溃弹窗

- 主进程 6 个独立 Store 实例合并为共享单例(window-size/lyric/server/deviceInfo/window 复用 config.ts 实例)
- initializeConfig 幂等化,修复 set-store-value IPC 监听重复注册导致的双倍写入
- 窗口/歌词窗 move、resize 保存增加 500ms 防抖,拖动时不再每秒几十次写盘,close 时冲刷落盘
- store 读写 IPC handler 与窗口状态读写加 try-catch,撞锁丢弃单次写入而非崩溃
- 全局 uncaughtException 兜底:文件锁类错误(EBUSY/EPERM 等)仅记日志,其余异常保留报错弹窗

Closes #714
This commit is contained in:
alger
2026-07-05 14:06:01 +08:00
parent f39100e9eb
commit 9f5473e14d
7 changed files with 154 additions and 49 deletions
+20 -1
View File
@@ -1,7 +1,26 @@
import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, ipcMain, nativeImage, protocol, session } from 'electron';
import { app, dialog, ipcMain, nativeImage, protocol, session } from 'electron';
import { join } from 'path';
// 全局兜底(#714):Windows 上 config.json 等文件可能被杀毒/云同步软件短暂锁定,
// electron-store 读写撞锁会抛 EBUSY 等未捕获异常,Electron 默认弹出致命错误框。
// 对带 path 的文件系统锁类错误仅记录日志;其余异常保留报错弹窗以免掩盖真 bug。
const FILE_LOCK_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN', 'EMFILE', 'ENFILE']);
process.on('uncaughtException', (error: NodeJS.ErrnoException) => {
if (error?.code && FILE_LOCK_ERROR_CODES.has(error.code) && typeof error.path === 'string') {
console.error('[main] 文件被占用/锁定,已忽略本次读写:', error.message);
return;
}
console.error('[main] 未捕获异常:', error);
dialog.showErrorBox(
'A JavaScript error occurred in the main process',
error?.stack || String(error)
);
});
process.on('unhandledRejection', (reason) => {
console.error('[main] 未处理的 Promise 拒绝:', reason);
});
// 必须在 app.whenReady() 之前注册自定义协议为特权协议,
// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成
// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME
+24 -6
View File
@@ -1,10 +1,28 @@
import { BrowserWindow, IpcMain, screen } from 'electron';
import Store from 'electron-store';
import path, { join } from 'path';
const store = new Store();
import { getSharedStore } from './modules/config';
const store = getSharedStore();
let lyricWindow: BrowserWindow | null = null;
// 歌词窗口 bounds 防抖保存:拖动/缩放时高频触发,
// 直接写盘会加剧 config.json 文件争用(#714 EBUSY
let lyricBoundsSaveTimer: ReturnType<typeof setTimeout> | null = null;
const saveLyricWindowBounds = (bounds: Record<string, number>) => {
if (lyricBoundsSaveTimer) {
clearTimeout(lyricBoundsSaveTimer);
}
lyricBoundsSaveTimer = setTimeout(() => {
lyricBoundsSaveTimer = null;
try {
store.set('lyricWindowBounds', bounds);
} catch (error) {
console.error('保存歌词窗口位置失败:', error);
}
}, 500);
};
// 跟踪拖动状态
let isDragging = false;
@@ -196,8 +214,8 @@ const createWin = () => {
const [width, height] = lyricWindow.getSize();
const [x, y] = lyricWindow.getPosition();
// 保存窗口位置和大小
store.set('lyricWindowBounds', { x, y, width, height });
// 保存窗口位置和大小(防抖)
saveLyricWindowBounds({ x, y, width, height });
}
});
@@ -357,7 +375,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
false
);
// 更新存储的位置
// 更新存储的位置(防抖,拖动结束后统一落盘)
const windowBounds = {
x: newX,
y: newY,
@@ -365,7 +383,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
height: windowHeight,
displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理
};
store.set('lyricWindowBounds', windowBounds);
saveLyricWindowBounds(windowBounds);
} catch (error) {
console.error('Error during window drag:', error);
// 出错时尝试使用更简单的方法
+54 -24
View File
@@ -38,41 +38,63 @@ interface StoreType {
shortcuts: ShortcutsConfig;
}
let store: Store<StoreType>;
// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。
// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁
// 叠加触发 EBUSY 未捕获异常(#714)
const store = new Store<StoreType>({
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
let initialized = false;
/**
* 初始化配置管理
* 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听)
*/
export function initializeConfig() {
store = new Store<StoreType>({
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
if (initialized) {
return store;
}
initialized = true;
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
store.get('set.diskCacheDir') ||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
store.set('set.diskCacheMaxSizeMB', 4096);
}
if (!store.get('set.diskCacheCleanupPolicy')) {
store.set('set.diskCacheCleanupPolicy', 'lru');
}
if (store.get('set.enableDiskCache') === undefined) {
store.set('set.enableDiskCache', true);
try {
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
store.get('set.diskCacheDir') ||
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
if (store.get('set.diskCacheMaxSizeMB') === undefined) {
store.set('set.diskCacheMaxSizeMB', 4096);
}
if (!store.get('set.diskCacheCleanupPolicy')) {
store.set('set.diskCacheCleanupPolicy', 'lru');
}
if (store.get('set.enableDiskCache') === undefined) {
store.set('set.enableDiskCache', true);
}
} catch (error) {
console.error('[config] 初始化默认配置失败:', error);
}
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {
store.set(key, value);
try {
store.set(key, value);
} catch (error) {
// config.json 可能被杀毒/云同步短暂锁定,丢一次写入无害,避免主进程崩溃
console.error(`[config] 写入配置失败 key=${key}:`, error);
}
});
ipcMain.on('get-store-value', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
ipcMain.on('get-store-value', (event, key) => {
try {
const value = store.get(key);
event.returnValue = value || '';
} catch (error) {
console.error(`[config] 读取配置失败 key=${key}:`, error);
event.returnValue = '';
}
});
// GPU加速设置更新处理
@@ -98,3 +120,11 @@ export function initializeConfig() {
export function getStore() {
return store;
}
/**
* 供主进程其他模块共享同一个 config.json Store 实例
* (不要再 new Store(),多实例并发读写是 #714 EBUSY 崩溃的诱因之一)
*/
export function getSharedStore(): Store<Record<string, unknown>> {
return store as unknown as Store<Record<string, unknown>>;
}
+3 -2
View File
@@ -1,9 +1,10 @@
import { app } from 'electron';
import Store from 'electron-store';
import { machineIdSync } from 'node-machine-id';
import os from 'os';
const store = new Store();
import { getSharedStore } from './config';
const store = getSharedStore();
/**
* 获取设备唯一标识符
+49 -12
View File
@@ -1,7 +1,9 @@
import { app, BrowserWindow, ipcMain, screen } from 'electron';
import Store from 'electron-store';
import type Store from 'electron-store';
const store = new Store();
import { getSharedStore } from './config';
const store = getSharedStore();
// 默认窗口尺寸
export const DEFAULT_MAIN_WIDTH = 1200;
@@ -38,16 +40,40 @@ export interface WindowState {
* 负责保存、恢复和维护窗口大小状态
*/
class WindowSizeManager {
private store: Store;
private store: Store<Record<string, unknown>>;
private mainWindow: BrowserWindow | null = null;
private savedState: WindowState | null = null;
private isInitialized: boolean = false;
private saveStateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.store = store;
// 初始化时不做与screen相关的操作,等app ready后再初始化
}
/**
* 防抖保存窗口状态:move/resize 在拖动时每秒触发几十次,
* 直接写 config.json 是 #714 EBUSY 争用的最大来源
*/
private scheduleSaveWindowState(win: BrowserWindow): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
}
this.saveStateDebounceTimer = setTimeout(() => {
this.saveStateDebounceTimer = null;
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
}
}, 500);
}
private flushScheduledSave(): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
this.saveStateDebounceTimer = null;
}
}
/**
* 初始化窗口大小管理器
* 必须在app ready后调用
@@ -117,17 +143,17 @@ class WindowSizeManager {
* 设置事件监听器
*/
private setupEventListeners(win: BrowserWindow): void {
// 监听窗口大小调整事件
// 监听窗口大小调整事件(防抖,拖动结束后统一落盘)
win.on('resize', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
this.scheduleSaveWindowState(win);
}
});
// 监听窗口移动事件
// 监听窗口移动事件(防抖,拖动结束后统一落盘)
win.on('move', () => {
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
this.scheduleSaveWindowState(win);
}
});
@@ -145,8 +171,9 @@ class WindowSizeManager {
}
});
// 监听窗口关闭事件,确保保存最终状态
// 监听窗口关闭事件,确保保存最终状态(取消挂起的防抖,立即落盘)
win.on('close', () => {
this.flushScheduledSave();
if (!win.isDestroyed()) {
this.saveWindowState(win);
}
@@ -409,9 +436,13 @@ class WindowSizeManager {
return state;
}
// 保存状态到存储
this.store.set(WINDOW_STATE_KEY, state);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
// 保存状态到存储config.json 可能被外部程序短暂锁定,失败时丢弃本次写入即可)
try {
this.store.set(WINDOW_STATE_KEY, state);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
} catch (error) {
console.error('保存窗口状态失败:', error);
}
// 更新内部状态
this.savedState = state;
@@ -424,7 +455,13 @@ class WindowSizeManager {
* 获取保存的窗口状态
*/
getWindowState(): WindowState | null {
const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
let state: WindowState | undefined;
try {
state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
} catch (error) {
console.error('读取窗口状态失败:', error);
return this.savedState;
}
if (!state) {
console.log('未找到保存的窗口状态,将使用默认值');
+2 -2
View File
@@ -9,9 +9,9 @@ import {
session,
shell
} from 'electron';
import Store from 'electron-store';
import { join } from 'path';
import { getSharedStore } from './config';
import {
applyContentZoom,
applyInitialState,
@@ -27,7 +27,7 @@ import {
WindowState
} from './window-size';
const store = new Store();
const store = getSharedStore();
// 保存主窗口引用,以便在 activate 事件中使用
let mainWindowInstance: BrowserWindow | null = null;
+2 -2
View File
@@ -1,9 +1,9 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getSharedStore } from './modules/config';
import { type Platform, unblockMusic } from './unblockMusic';
// 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件
@@ -12,7 +12,7 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}
const store = new Store();
const store = getSharedStore();
// 设置音乐解析的处理程序
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {