From 53cd006a23cb863ef04df27bf7db4c94e63a24b8 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 15 Mar 2026 20:33:22 +0800 Subject: [PATCH] fix(boss): resolve limit=60 infinite loop and global CLI hangs - search.ts: Added deduplication break condition so if the API returns duplicate pages, it stops instead of looping forever. - search.ts: Removed unsafe Promise.race around page.goto which caused 'context closed' errors. - browser.ts: Added robust static cleanup hooks to guarantee process and MCP lock destruction on crashed or ctrl+c'd runs. --- src/browser.ts | 70 ++++++++++++++++++++++++++++++++++++----- src/clis/boss/search.ts | 29 ++++++++++++++--- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/browser.ts b/src/browser.ts index 9142f11..2223a61 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -296,6 +296,28 @@ export class Page implements IPage { * Playwright MCP process manager. */ export class PlaywrightMCP { + private static _activeInsts: Set = new Set(); + private static _cleanupRegistered = false; + + private static _registerGlobalCleanup() { + if (this._cleanupRegistered) return; + this._cleanupRegistered = true; + const cleanup = () => { + for (const inst of this._activeInsts) { + if (inst._lockAcquired) { + try { fs.rmdirSync(LOCK_DIR); } catch {} + inst._lockAcquired = false; + } + if (inst._proc && !inst._proc.killed) { + try { inst._proc.kill('SIGKILL'); } catch {} + } + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { cleanup(); process.exit(130); }); + process.on('SIGTERM', () => { cleanup(); process.exit(143); }); + } + private _proc: ChildProcess | null = null; private _buffer = ''; private _waiters: Array<(data: any) => void> = []; @@ -326,7 +348,13 @@ export class PlaywrightMCP { } return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out connecting to browser (${timeout}s)`)), timeout * 1000); + const isDebug = process.env.DEBUG?.includes('opencli:mcp'); + const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); + + const timer = setTimeout(() => { + debugLog('Connection timed out'); + reject(new Error(`Timed out connecting to browser (${timeout}s)`)); + }, timeout * 1000); const mcpArgs: string[] = [mcpPath]; if (cdpEndpoint) { @@ -340,6 +368,7 @@ export class PlaywrightMCP { if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) { mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH); } + debugLog(`Spawning node ${mcpArgs.join(' ')}`); this._proc = spawn('node', mcpArgs, { stdio: ['pipe', 'pipe', 'pipe'], @@ -362,16 +391,26 @@ export class PlaywrightMCP { this._buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; + debugLog(`RECV: ${line}`); try { const parsed = JSON.parse(line); const waiter = this._waiters.shift(); if (waiter) waiter(parsed); - } catch {} + } catch (e) { + debugLog(`Parse error: ${e}`); + } } }); - this._proc.stderr?.on('data', () => {}); - this._proc.on('error', (err) => { clearTimeout(timer); reject(err); }); + this._proc.stderr?.on('data', (chunk) => debugLog(`STDERR: ${chunk}`)); + this._proc.on('error', (err) => { + debugLog(`Subprocess error: ${err.message}`); + clearTimeout(timer); + reject(err); + }); + this._proc.on('close', (code) => { + debugLog(`Subprocess closed with code ${code}`); + }); // Initialize: send initialize request const initMsg = jsonRpcRequest('initialize', { @@ -379,16 +418,24 @@ export class PlaywrightMCP { capabilities: {}, clientInfo: { name: 'opencli', version: PKG_VERSION }, }); + debugLog(`SEND: ${initMsg.trim()}`); this._proc.stdin?.write(initMsg); // Wait for initialize response, then send initialized notification const origRecv = () => new Promise((res) => { this._waiters.push(res); }); + debugLog('Waiting for initialize response...'); origRecv().then((resp) => { + debugLog('Got initialize response'); if (resp.error) { clearTimeout(timer); reject(new Error(`MCP init failed: ${resp.error.message}`)); return; } - this._proc?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'); + + const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'; + debugLog(`SEND: ${initializedMsg.trim()}`); + this._proc?.stdin?.write(initializedMsg); // Get initial tab count for cleanup + debugLog('Fetching initial tabs count...'); page.tabs().then((tabs: any) => { + debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`); if (typeof tabs === 'string') { this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length; } else if (Array.isArray(tabs)) { @@ -396,8 +443,16 @@ export class PlaywrightMCP { } clearTimeout(timer); resolve(page); - }).catch(() => { clearTimeout(timer); resolve(page); }); - }).catch((err) => { clearTimeout(timer); reject(err); }); + }).catch((err) => { + debugLog(`Tabs fetch error: ${err.message}`); + clearTimeout(timer); + resolve(page); + }); + }).catch((err) => { + debugLog(`Init promise rejected: ${err.message}`); + clearTimeout(timer); + reject(err); + }); }); } @@ -427,6 +482,7 @@ export class PlaywrightMCP { } finally { this._page = null; this._releaseLock(); + PlaywrightMCP._activeInsts.delete(this); } } diff --git a/src/clis/boss/search.ts b/src/clis/boss/search.ts index 4eff3ae..73f430a 100644 --- a/src/clis/boss/search.ts +++ b/src/clis/boss/search.ts @@ -87,6 +87,9 @@ cli({ const cityCode = resolveCity(kwargs.city); + if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) { + console.error(`[opencli:boss] Navigating to set referrer context...`); + } // Navigate to the Web search view first to establish proper referrer context // This is a lesson learned from boss-cli: referrer is important await page.goto(`https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`); @@ -124,21 +127,31 @@ cli({ const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`; + if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) { + console.error(`[opencli:boss] Fetching page ${currentPage}... (current jobs: ${allJobs.length})`); + } + const evaluateScript = ` async () => { return new Promise((resolve, reject) => { const xhr = new window.XMLHttpRequest(); xhr.open('GET', '${targetUrl}', true); xhr.withCredentials = true; + xhr.timeout = 15000; // 15s timeout xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); xhr.onload = () => { - try { - resolve(JSON.parse(xhr.responseText)); - } catch (e) { - reject(new Error('Failed to parse JSON. Raw API response (first 200 chars): ' + xhr.responseText.substring(0, 200))); + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch (e) { + reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200))); + } + } else { + reject(new Error('XHR HTTP Status: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('XHR Network Error')); + xhr.ontimeout = () => reject(new Error('XHR Timeout')); xhr.send(); }); } @@ -164,6 +177,7 @@ cli({ break; // No more results } + let addedInBatch = 0; for (const j of batch) { if (!j.encryptJobId || seenIds.has(j.encryptJobId)) continue; seenIds.add(j.encryptJobId); @@ -179,9 +193,16 @@ cli({ boss: j.bossName + ' ยท ' + j.bossTitle, url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html', }); + addedInBatch++; if (allJobs.length >= limit) break; } + if (addedInBatch === 0) { + // Boss API is repeating identical pages, we've hit the pagination limit + if (process.env.OPENCLI_VERBOSE) console.error(`[opencli:boss] API returned duplicate page, stopping pagination at ${allJobs.length} items`); + break; + } + if (!zpData.hasMore) { break; // API says no more pages }