fix: 修复 Z-Library 加载无限转圈
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run

登录时注入页面的 fetch 没有超时,executeJavaScriptInIsolatedWorld 在页面
卡死时也不会 reject,两处 Promise 永不落定会让 IPC 不回包,界面停在加载中
且没有任何错误。wrap() 只能捕获抛出,捕获不了不返回。

页内 fetch 加 AbortSignal.timeout,注入求值外挂超时,数据源 IPC 统一经
sourceCall() 加兜底时限。另外启动与保存代理时等待 Chromium 切换完成并关闭
旧连接,避免继续复用切换前的连接。
This commit is contained in:
lofyer
2026-08-09 15:02:39 +08:00
parent 763f2b02ac
commit 7f0eea7464
3 changed files with 169 additions and 19 deletions
+11
View File
@@ -84,6 +84,17 @@ app.whenReady().then(async () => {
const configReadyMs = Date.now() - startedAt;
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
const proxyResult = await win.webContents.executeJavaScript(
`window.api.proxy.set('http://127.0.0.1:9')`
);
const proxyRoute = await app.whenReady().then(() => (
require('electron').session.defaultSession.resolveProxy('https://example.com/')
));
check('保存代理后 Chromium 会话已切换到新代理',
proxyResult && proxyResult.ok && /PROXY 127\.0\.0\.1:9/i.test(proxyRoute),
proxyRoute);
await win.webContents.executeJavaScript(`window.api.proxy.set('')`);
const sourceRows = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
const rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({
id: input.dataset.id,
+104
View File
@@ -11,6 +11,12 @@ const { compareVersion } = h.extractFns(
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
);
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
const { applySessionProxy } = h.extractFns(
mainFile, 'async function applySessionProxy', 'app.whenReady()', ['applySessionProxy']
);
const { withTimeout } = h.extractFns(
mainFile, 'function withTimeout', 'const SOURCE_TIMEOUT_MS', ['withTimeout']
);
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
@@ -43,6 +49,104 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
});
test('withTimeout 让永不落定的等待变成可读错误', async () => {
await assert.rejects(
withTimeout(new Promise(() => {}), 30, '数据源长时间无响应'),
/数据源长时间无响应/
);
assert.strictEqual(await withTimeout(Promise.resolve('ok'), 1000, '不该超时'), 'ok');
await assert.rejects(
withTimeout(Promise.reject(new Error('镜像返回了非预期内容')), 1000, '不该超时'),
/镜像返回了非预期内容/
);
});
test('withTimeout 在落定后清掉定时器,不吊住进程', async () => {
const timers = [];
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
global.setTimeout = (fn, ms) => {
const handle = originalSetTimeout(fn, ms);
timers.push(handle);
return handle;
};
let cleared = 0;
global.clearTimeout = (handle) => { cleared += 1; return originalClearTimeout(handle); };
try {
await withTimeout(Promise.resolve(1), 60000, '不该超时');
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
for (const handle of timers) originalClearTimeout(handle);
}
assert.strictEqual(cleared, 1, '超时定时器没有被清理');
});
test('数据源 IPC 带总时限,永不落定也会回包', () => {
const source = mainSrc.slice(
mainSrc.indexOf('function sourceCall'),
mainSrc.indexOf("ipcMain.handle('source:readOnline'")
);
assert.match(source, /withTimeout\(\s*Promise\.resolve\(\)\.then\(fn\),\s*SOURCE_TIMEOUT_MS/);
for (const channel of ['source:list', 'source:search', 'source:detail', 'source:download', 'source:chapters']) {
const line = new RegExp(`ipcMain\\.handle\\('${channel}'[^\\n]*`).exec(mainSrc);
assert.ok(line, `缺少 ${channel} handler`);
assert.match(line[0], /sourceCall\(/, `${channel} 没有总时限,界面会卡在加载中`);
}
});
test('Z-Library 登录页内请求与注入求值都有超时', () => {
const start = mainSrc.indexOf('async function browserZlibLogin');
const end = mainSrc.indexOf('// Z-Library 凭据', start);
const segment = mainSrc.slice(start, end);
assert.match(segment, /signal: AbortSignal\.timeout\(\$\{ZLIB_LOGIN_FETCH_MS\}\)/,
'页内 fetch 没有超时,代理不通时会永远挂着');
assert.match(segment, /await withTimeout\(\s*win\.webContents\.executeJavaScriptInIsolatedWorld/,
'executeJavaScript 在页面卡死时不会 reject,必须外挂超时');
});
test('Chromium 代理切换完成并关闭旧连接后才继续请求', async () => {
const calls = [];
const fakeSession = {
setProxy: async (config) => { calls.push(['setProxy', config.proxyRules]); },
closeAllConnections: async () => { calls.push(['closeAllConnections']); }
};
await applySessionProxy(fakeSession, 'http://127.0.0.1:7890');
assert.deepStrictEqual(calls, [
['setProxy', 'http://127.0.0.1:7890'],
['closeAllConnections']
]);
calls.length = 0;
await applySessionProxy(fakeSession, '');
assert.deepStrictEqual(calls[0], ['setProxy', 'direct://']);
});
test('启动与保存设置都会等待 Chromium 代理生效', () => {
const startup = mainSrc.slice(
mainSrc.indexOf('app.whenReady().then'),
mainSrc.indexOf("app.on('window-all-closed'")
);
assert.match(startup, /app\.whenReady\(\)\.then\(async \(\) =>/);
assert.ok(
startup.indexOf('await applySessionProxy(session.defaultSession, getProxy())')
< startup.indexOf('createWindow()'),
'主窗口在 Chromium 代理生效前创建'
);
const proxyHandler = mainSrc.slice(
mainSrc.indexOf("ipcMain.handle('proxy:set'"),
mainSrc.indexOf('function zlibOrigin')
);
assert.match(proxyHandler, /wrap\(async \(\) =>/);
assert.match(proxyHandler, /await applySessionProxy\(session\.defaultSession, u\)/);
assert.ok(
proxyHandler.indexOf('await applySessionProxy(session.defaultSession, u)')
< proxyHandler.indexOf("settings.set('proxy', u)"),
'代理尚未应用成功就提前持久化'
);
});
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
const end = mainSrc.indexOf('// 代理配置', start);