diff --git a/README.md b/README.md index 18fea24..2540e54 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React diff --git a/electron/harness/tools/built-in/browser-window-manager.ts b/electron/harness/tools/built-in/browser-window-manager.ts index 3b88a58..da29de5 100644 --- a/electron/harness/tools/built-in/browser-window-manager.ts +++ b/electron/harness/tools/built-in/browser-window-manager.ts @@ -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 = Promise.resolve(); /** 窗口是否就绪 */ @@ -60,77 +67,103 @@ 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 { // v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱 - const run = async (): Promise => { - // 若已有窗口加载了不同 URL → 先关闭重建 - if (this.win && this.currentUrl !== options.url) { - this.destroy(); - } - - if (!this.win) { - this.win = new BrowserWindow({ - width: 1280, - height: 800, - show: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - // v0.3.0 修复: 独立 partition,与主应用 default session 完全隔离 - // 防止 Agent 浏览产生的 Cookie/缓存/存储污染主应用 - partition: AGENT_PARTITION, - // v0.3.0 修复: 恢复 webSecurity,CORS 需求通过 session.webRequest 处理 - webSecurity: true, - plugins: false, - webviewTag: false, - }, - }); - - // v0.3.0 修复: 拦截 window.open,Agent 浏览的页面不允许再开新窗口 - this.win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - - // v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false - // 仅对 agent session 放行 CORS,不影响主应用 - const agentSession = session.fromPartition(AGENT_PARTITION); - agentSession.webRequest.onHeadersReceived((details, callback) => { - callback({ - responseHeaders: { - ...details.responseHeaders, - 'Access-Control-Allow-Origin': ['*'], - }, - }); - }); - } - - await this.loadURLWithTimeout(this.win, options.url, 30_000); - this.currentUrl = options.url; - - // 可选等待选择器 - if (options.waitSelector) { - await this.waitForSelector(options.waitSelector, 10_000); - } - - const title = await this.evaluate('document.title'); - return { title: String(title ?? ''), url: options.url }; - }; - - // 串行化:等待前一个 open 完成 + const run = async (): Promise => this.openInternal(options); + // 串行化:等待前一个操作完成(open 与 fetchPageText 共用一条链) this.openChain = this.openChain.then(run, run); return this.openChain as Promise; } + /** + * 内部实际执行 open(不排队 — 供已在链上的调用方直接使用) + * + * 崩溃修复: destroy() 仅在链上(openInternal / close)发生 —— 消除 + * "后到 open 销毁前一个正在加载/执行 JS 的窗口" 的跨链竞态。 + */ + private async openInternal(options: BrowserOpenOptions): Promise { + // 若已有窗口加载了不同 URL → 先关闭重建 + if (this.win && this.currentUrl !== options.url) { + this.destroy(); + } + + if (!this.win) { + this.win = new BrowserWindow({ + width: 1280, + height: 800, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + // v0.3.0 修复: 独立 partition,与主应用 default session 完全隔离 + // 防止 Agent 浏览产生的 Cookie/缓存/存储污染主应用 + partition: AGENT_PARTITION, + // v0.3.0 修复: 恢复 webSecurity,CORS 需求通过 session.webRequest 处理 + webSecurity: true, + plugins: false, + webviewTag: false, + }, + }); + + // v0.3.0 修复: 拦截 window.open,Agent 浏览的页面不允许再开新窗口 + this.win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + // v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false + // 仅对 agent session 放行 CORS,不影响主应用 + const agentSession = session.fromPartition(AGENT_PARTITION); + agentSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Access-Control-Allow-Origin': ['*'], + }, + }); + }); + } + + 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; + + // 可选等待选择器 + if (options.waitSelector) { + await this.waitForSelector(options.waitSelector, 10_000); + } + + const title = await this.evaluate('document.title'); + return { title: String(title ?? ''), url: options.url }; + } + // ===== browserScreenshot ===== async screenshot(options: ScreenshotOptions = {}): Promise { - 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 { - 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 | undefined; try { const result = await Promise.race([ - this.win!.webContents.executeJavaScript(js, true), + wc.executeJavaScript(js, true), new Promise((_, 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 { - this.ensureReady(); + /** + * 崩溃修复: 排队版页面抓取 — 串行化完整 open→等待→evaluate 序列。 + * + * web_fetch 浏览器回退的入口。并行回退(web_search 自动抓取并发 3)此前 + * 各自直接 open/evaluate 共享单例,后到者销毁前者的窗口(崩溃根因, + * 见 openChain 注释)。排队后同一时刻只有一个抓取在使用窗口。 + * + * @returns 提取的页面正文;失败/内容过短返回 null(调用方走失败路径) + */ + async fetchPageText(url: string): Promise { + const run = async (): Promise => { + 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; + } - const result = await this.win!.webContents.executeJavaScript( + async extract(selector?: string): Promise { + 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 { - 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 { - this.ensureReady(); + async type( + selector: string, + text: string, + options: { clear?: boolean; submit?: boolean } = {}, + ): Promise { + 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 { - 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 = { - 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)'; + 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 { 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 { + private async loadURLWithTimeout( + win: BrowserWindow, + url: string, + timeoutMs: number, + ): Promise { let timer: NodeJS.Timeout | null = null; const timeoutPromise = new Promise((_, 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 { 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 { 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 { + if (manager) await manager.close(); log.info('[BrowserWindowManager] Cleaned up'); } } diff --git a/electron/harness/tools/built-in/browser.ts b/electron/harness/tools/built-in/browser.ts index 5b697a8..d1e7342 100644 --- a/electron/harness/tools/built-in/browser.ts +++ b/electron/harness/tools/built-in/browser.ts @@ -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 { + 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 }; } diff --git a/electron/harness/tools/built-in/web-fetch.ts b/electron/harness/tools/built-in/web-fetch.ts index b04d079..02f19e4 100644 --- a/electron/harness/tools/built-in/web-fetch.ts +++ b/electron/harness/tools/built-in/web-fetch.ts @@ -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,9 +241,10 @@ export class WebFetchTool implements IMetonaTool { // 内容大小限制(与 HTTP 阶段一致,防止超大页面耗尽上下文) const MAX_BROWSER_TEXT = 500_000; // 500K chars - const safeText = text.length > MAX_BROWSER_TEXT - ? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]' - : text; + const safeText = + text.length > MAX_BROWSER_TEXT + ? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]' + : text; // 写缓存 fetchCache.set(url, safeText); diff --git a/electron/main.ts b/electron/main.ts index 15e54f3..f54b2b9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 { } 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()) { diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index a945691..1027e57 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -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); + } } }); diff --git a/package.json b/package.json index e39b84e..338fd15 100644 --- a/package.json +++ b/package.json @@ -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",