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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user