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.
This commit is contained in:
jackwener committed 2026-03-15 20:33:22 +08:00
1 parent c9710a656b
commit 53cd006a23
2 files changed
+88 -11

No files matched your search

+63 -7
View File
@@ -296,6 +296,28 @@ export class Page implements IPage {
* Playwright MCP process manager.
*/
export class PlaywrightMCP {
private static _activeInsts: Set<PlaywrightMCP> = 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<Page>((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<any>((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);
}
}
+25 -4
View File
@@ -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
}