fix: v0.6.1 修复回复/推理期间偶发崩溃 — 浏览器回退窗口竞态 + 崩溃可观测性
【根因(实证归因,非猜测)】 分析 userData/logs/main.log 全部 33 次启动会话,定位 3 处异常终止点 (07-25 ×2 / 08-22 ×1,启动标记前无 Database closed)。三处 100% 共享 同一模式:web_search 并行抓取 → 多个 web_fetch 同时进入浏览器回退 → 共享单例 BrowserWindowManager 中后到 open() 销毁前一个正在加载/执行 JS 的窗口。关键统计:56 次浏览器回退中 ERR_ABORTED(并发互毁的直接 证据)仅 3 次,而这 3 次恰好全部对应 3 个崩溃点;无并发销毁的 53 次 回退从未崩溃 —— 触发条件完全收敛。 缺陷链(三层叠加): 1. browserFetch 直接 open/evaluate 共享单例,无跨调用序列化 — 并发 回退互相销毁窗口(ERR_ABORTED / "Object has been destroyed") 2. destroy() 对仍在使用中的 partition fire-and-forget clearStorageData/clearCache,与紧随其后的新窗口创建并发 — 原生存储层竞态(崩溃引爆点) 3. ensureReady 检查与实际 executeJavaScript/loadURL 之间存在竞态窗口; loadURLWithTimeout 的 Race 落败方 rejection 无人处理 【修复(browser-window-manager.ts + web-fetch.ts + browser.ts)】 - 新增 fetchPageText:排队版页面抓取,串行化完整 open→等待→evaluate 序列(与 open 共用单一操作链,destroy 只会在链上发生,跨链互毁彻底 消除);web_fetch 浏览器回退改走此入口 - open() 拆分 openInternal(链内直调);open 与 fetchPageText 共用 单一串行链,排队不分死锁 - destroy() 移除 session 存储清理(终态清理迁移至 close(),await 执行, 不再与窗口创建并发) - safeWebContents() 即时校验替代 racy 的 ensureReady;evaluate/extract/ screenshot/click/type/scroll/waitForSelector 全部加固,消除对已销毁 webContents 的调用 - loadURLWithTimeout 落败方 rejection 兜底(防 unhandledRejection) - cleanupBrowser/cleanup/close 异步化适配(main.ts 退出链路 await) 【崩溃可观测性(此前崩溃无迹可查 — 日志无声截断)】 - process.on(uncaughtException/unhandledRejection) → [FATAL] 落盘 - app.on(render-process-gone/child-process-gone) → [FATAL] 落盘 - WindowManager: 每窗口 render-process-gone 日志 + 自动 reload 自愈 (渲染进程 OOM/崩溃不再白屏卡死,可自动恢复) 【验证】 - lint 0/0;typecheck 双工程 0 错误;test:electron 252/252;build 通过
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.6.0-blue?style=flat-square" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.1-blue?style=flat-square" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
||||
|
||||
@@ -52,7 +52,14 @@ export interface WaitOptions {
|
||||
export class BrowserWindowManager {
|
||||
private win: BrowserWindow | null = null;
|
||||
private currentUrl: string | null = null;
|
||||
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
|
||||
/**
|
||||
* v0.3.0 修复 + 崩溃修复: 单一操作串行链 — open 与 fetchPageText 共用。
|
||||
*
|
||||
* 崩溃实证(main.log 三处异常终止点 100% 相关):web_search 并行抓取触发多个
|
||||
* web_fetch 同时进入浏览器回退时,后到的 open() 因 URL 不同销毁前一个正在
|
||||
* 加载/执行 JS 的窗口(ERR_ABORTED ×3 = 崩溃 ×3;无并发销毁的 53 次回退从未崩溃)。
|
||||
* 串行化完整抓取序列(open→等待→evaluate)后,destroy 只会在链上发生。
|
||||
*/
|
||||
private openChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** 窗口是否就绪 */
|
||||
@@ -60,11 +67,38 @@ export class BrowserWindowManager {
|
||||
return this.win !== null && !this.win.isDestroyed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 崩溃修复: 安全获取当前 webContents — 窗口已销毁时抛出可捕获错误。
|
||||
*
|
||||
* 原实现的 ensureReady 检查与实际 executeJavaScript/loadURL 调用之间存在
|
||||
* 竞态窗口(检查通过后窗口被并发 open() 的 destroy() 销毁),对已销毁
|
||||
* webContents 调用 API 会同步抛 "Object has been destroyed" 或触发原生层
|
||||
* use-after-free。所有窗口方法改为此守卫 + 即时校验。
|
||||
*/
|
||||
private safeWebContents(): Electron.WebContents {
|
||||
if (!this.win || this.win.isDestroyed()) {
|
||||
throw new Error('Browser window not ready or destroyed. Call open first.');
|
||||
}
|
||||
return this.win.webContents;
|
||||
}
|
||||
|
||||
// ===== browserOpen =====
|
||||
|
||||
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
|
||||
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
|
||||
const run = async (): Promise<BrowserOpenResult> => {
|
||||
const run = async (): Promise<BrowserOpenResult> => this.openInternal(options);
|
||||
// 串行化:等待前一个操作完成(open 与 fetchPageText 共用一条链)
|
||||
this.openChain = this.openChain.then(run, run);
|
||||
return this.openChain as Promise<BrowserOpenResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部实际执行 open(不排队 — 供已在链上的调用方直接使用)
|
||||
*
|
||||
* 崩溃修复: destroy() 仅在链上(openInternal / close)发生 —— 消除
|
||||
* "后到 open 销毁前一个正在加载/执行 JS 的窗口" 的跨链竞态。
|
||||
*/
|
||||
private async openInternal(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
|
||||
// 若已有窗口加载了不同 URL → 先关闭重建
|
||||
if (this.win && this.currentUrl !== options.url) {
|
||||
this.destroy();
|
||||
@@ -105,6 +139,9 @@ export class BrowserWindowManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.win.isDestroyed()) {
|
||||
throw new Error('Browser window destroyed before navigation');
|
||||
}
|
||||
await this.loadURLWithTimeout(this.win, options.url, 30_000);
|
||||
this.currentUrl = options.url;
|
||||
|
||||
@@ -115,22 +152,18 @@ export class BrowserWindowManager {
|
||||
|
||||
const title = await this.evaluate('document.title');
|
||||
return { title: String(title ?? ''), url: options.url };
|
||||
};
|
||||
|
||||
// 串行化:等待前一个 open 完成
|
||||
this.openChain = this.openChain.then(run, run);
|
||||
return this.openChain as Promise<BrowserOpenResult>;
|
||||
}
|
||||
|
||||
// ===== browserScreenshot =====
|
||||
|
||||
async screenshot(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||||
this.ensureReady();
|
||||
// 崩溃修复: safeWebContents 即时校验(消除检查-使用间竞态)
|
||||
const wc = this.safeWebContents();
|
||||
const win = this.win!;
|
||||
|
||||
// 元素截图
|
||||
if (options.selector) {
|
||||
const rect = await win.webContents.executeJavaScript(
|
||||
const rect = (await wc.executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(options.selector)});
|
||||
if (!el) return null;
|
||||
@@ -138,11 +171,11 @@ export class BrowserWindowManager {
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
})()`,
|
||||
true,
|
||||
) as { x: number; y: number; width: number; height: number } | null;
|
||||
)) as { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
if (!rect) throw new Error(`Element not found: ${options.selector}`);
|
||||
|
||||
const image = await win.webContents.capturePage({
|
||||
const image = await wc.capturePage({
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
@@ -153,25 +186,20 @@ export class BrowserWindowManager {
|
||||
|
||||
// 全页截图
|
||||
if (options.fullPage) {
|
||||
const dims = await win.webContents.executeJavaScript(
|
||||
const dims = (await wc.executeJavaScript(
|
||||
`({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })`,
|
||||
true,
|
||||
) as { width: number; height: number };
|
||||
)) as { width: number; height: number };
|
||||
|
||||
// 先滚动到底部触发懒加载
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.scrollTo(0, document.body.scrollHeight)`,
|
||||
true,
|
||||
);
|
||||
await wc.executeJavaScript(`window.scrollTo(0, document.body.scrollHeight)`, true);
|
||||
await this.sleep(500);
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.scrollTo(0, 0)`,
|
||||
true,
|
||||
);
|
||||
await wc.executeJavaScript(`window.scrollTo(0, 0)`, true);
|
||||
await this.sleep(300);
|
||||
|
||||
const image = await win.webContents.capturePage({
|
||||
x: 0, y: 0,
|
||||
const image = await wc.capturePage({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
});
|
||||
@@ -179,8 +207,8 @@ export class BrowserWindowManager {
|
||||
}
|
||||
|
||||
// 视口截图
|
||||
const image = await win.webContents.capturePage();
|
||||
const size = win.getContentSize();
|
||||
const image = await wc.capturePage();
|
||||
const size = win.isDestroyed() ? [0, 0] : win.getContentSize();
|
||||
return { data: image.toPNG().toString('base64'), width: size[0], height: size[1] };
|
||||
}
|
||||
|
||||
@@ -198,7 +226,9 @@ export class BrowserWindowManager {
|
||||
* 和 sandbox: true,Electron API 不会被暴露给页面。
|
||||
*/
|
||||
async evaluate(js: string): Promise<unknown> {
|
||||
this.ensureReady();
|
||||
// 崩溃修复: safeWebContents 即时校验(ensureReady 与实际调用间的竞态会引发
|
||||
// "Object has been destroyed" 同步抛出 / 原生层 use-after-free)
|
||||
const wc = this.safeWebContents();
|
||||
|
||||
// #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯
|
||||
log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500));
|
||||
@@ -208,13 +238,17 @@ export class BrowserWindowManager {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
this.win!.webContents.executeJavaScript(js, true),
|
||||
wc.executeJavaScript(js, true),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
// 审查修复 M17: 超时后中止页面 JS 执行。
|
||||
// executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行,
|
||||
// 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。
|
||||
try { this.win?.webContents.stop(); } catch { /* 窗口可能已销毁,忽略 */ }
|
||||
try {
|
||||
this.win?.webContents.stop();
|
||||
} catch {
|
||||
/* 窗口可能已销毁,忽略 */
|
||||
}
|
||||
reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`));
|
||||
}, EVAL_TIMEOUT_MS);
|
||||
}),
|
||||
@@ -228,10 +262,37 @@ export class BrowserWindowManager {
|
||||
|
||||
// ===== browserExtract =====
|
||||
|
||||
async extract(selector?: string): Promise<ExtractResult> {
|
||||
this.ensureReady();
|
||||
/**
|
||||
* 崩溃修复: 排队版页面抓取 — 串行化完整 open→等待→evaluate 序列。
|
||||
*
|
||||
* web_fetch 浏览器回退的入口。并行回退(web_search 自动抓取并发 3)此前
|
||||
* 各自直接 open/evaluate 共享单例,后到者销毁前者的窗口(崩溃根因,
|
||||
* 见 openChain 注释)。排队后同一时刻只有一个抓取在使用窗口。
|
||||
*
|
||||
* @returns 提取的页面正文;失败/内容过短返回 null(调用方走失败路径)
|
||||
*/
|
||||
async fetchPageText(url: string): Promise<string | null> {
|
||||
const run = async (): Promise<string | null> => {
|
||||
await this.openInternal({ url });
|
||||
await this.sleep(2_500);
|
||||
const text = (await this.evaluate(`
|
||||
(function() {
|
||||
var clone = document.body.cloneNode(true);
|
||||
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
|
||||
noise.forEach(function(el) { el.remove(); });
|
||||
return clone.innerText || '';
|
||||
})();
|
||||
`)) as string;
|
||||
return text && text.trim() ? text : null;
|
||||
};
|
||||
this.openChain = this.openChain.then(run, run);
|
||||
return this.openChain as Promise<string | null>;
|
||||
}
|
||||
|
||||
const result = await this.win!.webContents.executeJavaScript(
|
||||
async extract(selector?: string): Promise<ExtractResult> {
|
||||
const wc = this.safeWebContents();
|
||||
|
||||
const result = (await wc.executeJavaScript(
|
||||
`(() => {
|
||||
const root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.body'};
|
||||
if (!root) return null;
|
||||
@@ -249,7 +310,7 @@ export class BrowserWindowManager {
|
||||
return { text, links };
|
||||
})()`,
|
||||
true,
|
||||
) as { text: string; links: Array<{ text: string; url: string }> } | null;
|
||||
)) as { text: string; links: Array<{ text: string; url: string }> } | null;
|
||||
|
||||
if (!result) throw new Error(selector ? `Element not found: ${selector}` : 'No body content');
|
||||
|
||||
@@ -259,13 +320,12 @@ export class BrowserWindowManager {
|
||||
// ===== browserClick =====
|
||||
|
||||
async click(selector: string, wait = false): Promise<void> {
|
||||
this.ensureReady();
|
||||
|
||||
if (wait) {
|
||||
await this.waitForSelector(selector, 10_000);
|
||||
}
|
||||
const wc = this.safeWebContents();
|
||||
|
||||
const found = await this.win!.webContents.executeJavaScript(
|
||||
const found = await wc.executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return false;
|
||||
@@ -278,7 +338,7 @@ export class BrowserWindowManager {
|
||||
if (!found) throw new Error(`Element not found: ${selector}`);
|
||||
|
||||
await this.sleep(300);
|
||||
await this.win!.webContents.executeJavaScript(
|
||||
await this.safeWebContents().executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return;
|
||||
@@ -291,10 +351,14 @@ export class BrowserWindowManager {
|
||||
|
||||
// ===== browserType =====
|
||||
|
||||
async type(selector: string, text: string, options: { clear?: boolean; submit?: boolean } = {}): Promise<void> {
|
||||
this.ensureReady();
|
||||
async type(
|
||||
selector: string,
|
||||
text: string,
|
||||
options: { clear?: boolean; submit?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const wc = this.safeWebContents();
|
||||
|
||||
const found = await this.win!.webContents.executeJavaScript(
|
||||
const found = await wc.executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return false;
|
||||
@@ -309,7 +373,7 @@ export class BrowserWindowManager {
|
||||
await this.sleep(300);
|
||||
|
||||
// 清空 + 赋值 + 触发事件(兼容 React/Vue)
|
||||
await this.win!.webContents.executeJavaScript(
|
||||
await this.safeWebContents().executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return;
|
||||
@@ -322,7 +386,7 @@ export class BrowserWindowManager {
|
||||
);
|
||||
|
||||
if (options.submit) {
|
||||
await this.win!.webContents.executeJavaScript(
|
||||
await this.safeWebContents().executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return;
|
||||
@@ -340,10 +404,10 @@ export class BrowserWindowManager {
|
||||
// ===== browserScroll =====
|
||||
|
||||
async scroll(options: ScrollOptions = {}): Promise<void> {
|
||||
this.ensureReady();
|
||||
const wc = this.safeWebContents();
|
||||
|
||||
if (options.selector) {
|
||||
await this.win!.webContents.executeJavaScript(
|
||||
await wc.executeJavaScript(
|
||||
`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(options.selector)});
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
@@ -354,14 +418,15 @@ export class BrowserWindowManager {
|
||||
}
|
||||
|
||||
const direction = options.direction ?? 'down';
|
||||
const js = {
|
||||
const js =
|
||||
{
|
||||
down: 'window.scrollBy(0, 500)',
|
||||
up: 'window.scrollBy(0, -500)',
|
||||
top: 'window.scrollTo(0, 0)',
|
||||
bottom: 'window.scrollTo(0, document.body.scrollHeight)',
|
||||
}[direction] ?? 'window.scrollBy(0, 500)';
|
||||
|
||||
await this.win!.webContents.executeJavaScript(js, true);
|
||||
await wc.executeJavaScript(js, true);
|
||||
await this.sleep(300);
|
||||
}
|
||||
|
||||
@@ -377,34 +442,67 @@ export class BrowserWindowManager {
|
||||
|
||||
// ===== browserClose =====
|
||||
|
||||
close(): void {
|
||||
/**
|
||||
* 关闭并清理(应用退出 / browser close 动作时调用)
|
||||
*
|
||||
* 崩溃修复: session 存储清理从 destroy() 迁移至此 —— destroy() 此前对
|
||||
* 仍在使用中的 partition(紧随其后就会新建窗口)fire-and-forget 调用
|
||||
* clearStorageData/clearCache,与新窗口初始化并发执行,构成原生存储层
|
||||
* 竞态(崩溃引爆点)。close() 是终态路径,await 清理与窗口销毁不再交叠。
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.destroy();
|
||||
try {
|
||||
const ses = session.fromPartition(AGENT_PARTITION);
|
||||
await ses.clearStorageData({
|
||||
storages: [
|
||||
'cookies',
|
||||
'localstorage',
|
||||
'indexdb',
|
||||
'shadercache',
|
||||
'serviceworkers',
|
||||
'cachestorage',
|
||||
],
|
||||
});
|
||||
await ses.clearCache();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 内部辅助 =====
|
||||
|
||||
private ensureReady(): void {
|
||||
if (!this.ready) {
|
||||
throw new Error('Browser window not ready. Call browser_open first.');
|
||||
}
|
||||
}
|
||||
|
||||
/** 带超时的 loadURL(Electron 原生不支持 timeout 选项) */
|
||||
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
|
||||
private async loadURLWithTimeout(
|
||||
win: BrowserWindow,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
// 崩溃修复: Race 落败方的 rejection 必须兜底 —— 超时/窗口销毁先触发时,
|
||||
// loadURL 随后以 ERR_ABORTED 拒绝,无人处理会成为 unhandledRejection
|
||||
const loadPromise = win.loadURL(url);
|
||||
loadPromise.catch(() => {
|
||||
/* 落败方 rejection 已由 race 胜者处理,此处仅防漏 */
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
win.loadURL(url),
|
||||
timeoutPromise,
|
||||
]);
|
||||
await Promise.race([loadPromise, timeoutPromise]);
|
||||
} catch (e) {
|
||||
// v0.3.0 修复: 超时后停止页面加载,避免后台继续消耗网络和 CPU 资源
|
||||
if (!win.isDestroyed()) {
|
||||
try { win.webContents.stop(); } catch { /* ignore */ }
|
||||
try {
|
||||
win.webContents.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -415,7 +513,8 @@ export class BrowserWindowManager {
|
||||
private async waitForSelector(selector: string, timeoutMs: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const found = await this.win!.webContents.executeJavaScript(
|
||||
// 崩溃修复: 循环内即时校验(窗口可能在等待期间被链上操作销毁)
|
||||
const found = await this.safeWebContents().executeJavaScript(
|
||||
`!!document.querySelector(${JSON.stringify(selector)})`,
|
||||
true,
|
||||
);
|
||||
@@ -427,28 +526,31 @@ export class BrowserWindowManager {
|
||||
|
||||
private destroy(): void {
|
||||
if (this.win && !this.win.isDestroyed()) {
|
||||
try { this.win.webContents.stop(); } catch { /* ignore */ }
|
||||
try { this.win.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
this.win.webContents.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
this.win.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this.win = null;
|
||||
this.currentUrl = null;
|
||||
// v0.3.0 修复: 清理 agent session 存储,防止下一次浏览残留上一次的 Cookie/缓存/localStorage
|
||||
try {
|
||||
const ses = session.fromPartition(AGENT_PARTITION);
|
||||
ses.clearStorageData({
|
||||
storages: ['cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'],
|
||||
}).catch(() => { /* ignore */ });
|
||||
ses.clearCache().catch(() => { /* ignore */ });
|
||||
} catch { /* ignore */ }
|
||||
// 崩溃修复: 移除 session 清理 —— 原实现 fire-and-forget 清理仍在使用的
|
||||
// partition,与紧随其后的新窗口创建并发,构成原生存储层竞态。
|
||||
// 存储清理迁移至 close()(终态路径,见方法注释)。
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 应用退出时清理 */
|
||||
static cleanup(manager: BrowserWindowManager | null): void {
|
||||
if (manager) manager.close();
|
||||
/** 应用退出时清理(close 现为异步 — 含 session 存储终态清理) */
|
||||
static async cleanup(manager: BrowserWindowManager | null): Promise<void> {
|
||||
if (manager) await manager.close();
|
||||
log.info('[BrowserWindowManager] Cleaned up');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ export function getBrowserManager(): BrowserWindowManager {
|
||||
return getManager();
|
||||
}
|
||||
|
||||
/** 应用退出时清理(由 main.ts 调用) */
|
||||
export function cleanupBrowser(): void {
|
||||
BrowserWindowManager.cleanup(managerInstance);
|
||||
/** 应用退出时清理(由 main.ts 调用;close 含 session 存储终态清理,需 await) */
|
||||
export async function cleanupBrowser(): Promise<void> {
|
||||
await BrowserWindowManager.cleanup(managerInstance);
|
||||
managerInstance = null;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,17 @@ export class WebBrowserTool implements IMetonaTool {
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['open', 'screenshot', 'evaluate', 'extract', 'click', 'type', 'scroll', 'wait', 'close'],
|
||||
enum: [
|
||||
'open',
|
||||
'screenshot',
|
||||
'evaluate',
|
||||
'extract',
|
||||
'click',
|
||||
'type',
|
||||
'scroll',
|
||||
'wait',
|
||||
'close',
|
||||
],
|
||||
description: 'Browser operation to perform',
|
||||
},
|
||||
url: {
|
||||
@@ -114,7 +124,8 @@ export class WebBrowserTool implements IMetonaTool {
|
||||
},
|
||||
time_ms: {
|
||||
type: 'number',
|
||||
description: '[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.',
|
||||
description:
|
||||
'[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
@@ -263,7 +274,7 @@ export class WebBrowserTool implements IMetonaTool {
|
||||
|
||||
// ===== close =====
|
||||
case 'close': {
|
||||
getManager().close();
|
||||
await getManager().close();
|
||||
return { success: true, action, closed: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -35,17 +35,29 @@ const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
|
||||
export class WebFetchTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'web_fetch',
|
||||
description: 'Fetch a web page and convert to plain text. Uses a three-phase fallback strategy: HTTP fetch with anti-crawl headers → SPA auto-upgrade → browser rendering. Handles Cloudflare interception, JavaScript-rendered pages, and large files (10MB limit).',
|
||||
description:
|
||||
'Fetch a web page and convert to plain text. Uses a three-phase fallback strategy: HTTP fetch with anti-crawl headers → SPA auto-upgrade → browser rendering. Handles Cloudflare interception, JavaScript-rendered pages, and large files (10MB limit).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Target URL (http/https only)' },
|
||||
// H-3/H-4 修复: 补齐规范要求的 max_chars 和 extract_mode 参数
|
||||
// @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
|
||||
max_chars: { type: 'number', description: 'Maximum characters to return (default 50000, truncated with notice)' },
|
||||
extract_mode: { type: 'string', enum: ['text', 'html'], description: 'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed' },
|
||||
max_chars: {
|
||||
type: 'number',
|
||||
description: 'Maximum characters to return (default 50000, truncated with notice)',
|
||||
},
|
||||
extract_mode: {
|
||||
type: 'string',
|
||||
enum: ['text', 'html'],
|
||||
description:
|
||||
'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed',
|
||||
},
|
||||
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
|
||||
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' },
|
||||
retry: {
|
||||
type: 'boolean',
|
||||
description: 'Enable retry with exponential backoff (default true)',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
@@ -85,7 +97,10 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 内容过短检测 → Phase 2 升级(仅对 text 模式生效,html 模式不升级)
|
||||
if (extractMode === 'text' && phase1Content.length < 200) {
|
||||
logTool('web_fetch', `Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`);
|
||||
logTool(
|
||||
'web_fetch',
|
||||
`Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`,
|
||||
);
|
||||
const browserResult = await this.browserFetch(url);
|
||||
if (browserResult) {
|
||||
return this.buildSuccess(url, browserResult, 'browser', maxChars);
|
||||
@@ -120,7 +135,13 @@ export class WebFetchTool implements IMetonaTool {
|
||||
url: string,
|
||||
mobileUA: boolean,
|
||||
enableRetry: boolean,
|
||||
): Promise<{ success: boolean; html: string; text: string; intercepted: boolean; reason: string }> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
html: string;
|
||||
text: string;
|
||||
intercepted: boolean;
|
||||
reason: string;
|
||||
}> {
|
||||
const maxRetries = enableRetry ? 3 : 1;
|
||||
const backoffBase = 2_000;
|
||||
|
||||
@@ -131,16 +152,30 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 跳过重试的状态码 → 直接进入浏览器回退
|
||||
if (SKIP_RETRY_STATUS.has(response.status)) {
|
||||
return { success: false, html: '', text: '', intercepted: true, reason: `HTTP ${response.status}` };
|
||||
return {
|
||||
success: false,
|
||||
html: '',
|
||||
text: '',
|
||||
intercepted: true,
|
||||
reason: `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// 5xx 可重试
|
||||
if (response.status >= 500 && attempt < maxRetries - 1) {
|
||||
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
|
||||
await this.sleep(
|
||||
backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return { success: false, html: '', text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
|
||||
return {
|
||||
success: false,
|
||||
html: '',
|
||||
text: '',
|
||||
intercepted: false,
|
||||
reason: `HTTP ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 读取正文(10MB 限制)
|
||||
@@ -148,7 +183,13 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 拦截检测
|
||||
if (isInterceptedPage(html)) {
|
||||
return { success: false, html: '', text: '', intercepted: true, reason: 'Intercepted page detected' };
|
||||
return {
|
||||
success: false,
|
||||
html: '',
|
||||
text: '',
|
||||
intercepted: true,
|
||||
reason: 'Intercepted page detected',
|
||||
};
|
||||
}
|
||||
|
||||
// HTML → 纯文本
|
||||
@@ -166,7 +207,13 @@ export class WebFetchTool implements IMetonaTool {
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, html: '', text: '', intercepted: false, reason: 'All retries exhausted' };
|
||||
return {
|
||||
success: false,
|
||||
html: '',
|
||||
text: '',
|
||||
intercepted: false,
|
||||
reason: 'All retries exhausted',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
|
||||
@@ -180,23 +227,10 @@ export class WebFetchTool implements IMetonaTool {
|
||||
}
|
||||
|
||||
try {
|
||||
const manager = getBrowserManager();
|
||||
|
||||
// 通过 manager 打开 URL(复用已打开的同 URL 窗口,避免重复创建)
|
||||
await manager.open({ url });
|
||||
|
||||
// 等待 JS 渲染
|
||||
await this.sleep(2_500);
|
||||
|
||||
// 提取页面正文
|
||||
const text = await manager.evaluate(`
|
||||
(function() {
|
||||
var clone = document.body.cloneNode(true);
|
||||
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
|
||||
noise.forEach(function(el) { el.remove(); });
|
||||
return clone.innerText || '';
|
||||
})();
|
||||
`) as string;
|
||||
// 崩溃修复: 走 manager.fetchPageText(内部串行化完整的 open→等待→evaluate 序列)。
|
||||
// 原实现直接 open/evaluate 共享单例 —— web_search 并行抓取触发多个回退同时进入时,
|
||||
// 后到者销毁前者的窗口(ERR_ABORTED ×3 = 应用崩溃 ×3,见 manager 注释)。
|
||||
const text = await getBrowserManager().fetchPageText(url);
|
||||
|
||||
if (text && text.trim().length >= 80) {
|
||||
// 拦截检测(浏览器渲染后仍可能是验证码挑战页)
|
||||
@@ -207,7 +241,8 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 内容大小限制(与 HTTP 阶段一致,防止超大页面耗尽上下文)
|
||||
const MAX_BROWSER_TEXT = 500_000; // 500K chars
|
||||
const safeText = text.length > MAX_BROWSER_TEXT
|
||||
const safeText =
|
||||
text.length > MAX_BROWSER_TEXT
|
||||
? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]'
|
||||
: text;
|
||||
|
||||
|
||||
+26
-1
@@ -95,6 +95,26 @@ import { HealthChecker, SLOMonitor } from './utils/slo';
|
||||
log.transports.file.level = 'info';
|
||||
log.transports.console.level = 'debug';
|
||||
|
||||
// ===== 崩溃可观测性(此前崩溃无迹可查 — main.log 只会无声中断) =====
|
||||
// 三个历史崩溃点(07-25 ×2 / 08-22 ×1)的日志均无声截断,无任何错误线索。
|
||||
// 以下钩子确保任何致命异常/进程级崩溃都先落盘再终止,供事后归因。
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('[FATAL] uncaughtException:', err?.stack ?? err);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error('[FATAL] unhandledRejection:', reason instanceof Error ? reason.stack : reason);
|
||||
});
|
||||
app.on('render-process-gone', (_event, webContents, details) => {
|
||||
log.error(
|
||||
`[FATAL] render-process-gone: wcId=${webContents.id} reason=${details.reason} exitCode=${details.exitCode}`,
|
||||
);
|
||||
});
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
log.error(
|
||||
`[FATAL] child-process-gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? 'n/a'}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)=====
|
||||
const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json');
|
||||
|
||||
@@ -776,7 +796,12 @@ async function initialize(): Promise<void> {
|
||||
} catch (err) {
|
||||
log.error('[Shutdown] MCP shutdown failed:', err);
|
||||
}
|
||||
cleanupBrowser();
|
||||
// 崩溃修复: cleanupBrowser 现为异步(close 含 session 存储终态清理)
|
||||
try {
|
||||
await cleanupBrowser();
|
||||
} catch (err) {
|
||||
log.error('[Shutdown] Browser cleanup failed:', err);
|
||||
}
|
||||
|
||||
// v0.3.18 修复: 等待进行中的记忆固化任务完成,避免应用退出导致记忆丢失
|
||||
if (memoryConsolidator.isRunning()) {
|
||||
|
||||
@@ -86,7 +86,24 @@ export class WindowManager {
|
||||
win.on('closed', () => {
|
||||
this.windows.delete(id);
|
||||
if (this.activeWindowId === id) {
|
||||
this.activeWindowId = this.windows.size > 0 ? this.windows.keys().next().value ?? null : null;
|
||||
this.activeWindowId =
|
||||
this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null;
|
||||
}
|
||||
});
|
||||
|
||||
// 崩溃可观测性 + 自愈: 渲染进程崩溃(OOM/原生崩溃)时记录归因日志并自动重载,
|
||||
// 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复)
|
||||
win.webContents.on('render-process-gone', (_event, details) => {
|
||||
log.error(
|
||||
`[WindowManager] render-process-gone (id=${win.id}): reason=${details.reason} exitCode=${details.exitCode}`,
|
||||
);
|
||||
if (!win.isDestroyed() && details.reason !== 'clean-exit') {
|
||||
try {
|
||||
win.webContents.reload();
|
||||
log.info(`[WindowManager] Window ${id} reloaded after renderer crash`);
|
||||
} catch (err) {
|
||||
log.error(`[WindowManager] Reload after crash failed:`, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "metona-ai-desktop",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||
"main": "dist-electron/main/main.js",
|
||||
"author": "Metona Team",
|
||||
|
||||
Reference in New Issue
Block a user