@lincoln504/pi-research 1.3.12 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.mjs +5670 -4705
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +169 -62
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +225 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +22 -12
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +766 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
- package/src/infrastructure/browser/config.ts +79 -1
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
- package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
- package/src/infrastructure/browser/thread-worker.mjs +169 -62
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/embedding/embedding-server.ts +34 -0
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +46 -19
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +143 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/orchestration/session-state.ts +16 -0
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +31 -10
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/host-config.ts +12 -1
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +10 -0
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +102 -22
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -11,21 +11,23 @@
|
|
|
11
11
|
import { platform } from 'node:os';
|
|
12
12
|
import { setupMocking } from './thread-worker-messaging.ts';
|
|
13
13
|
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
14
|
-
import { resolveHeadlessMode } from './config.ts';
|
|
14
|
+
import { resolveHeadlessMode, BROWSER_LAUNCH_TIMEOUT_MS, CONTEXT_CREATION_TIMEOUT_MS } from './config.ts';
|
|
15
15
|
|
|
16
16
|
let browser: any = null;
|
|
17
|
-
let context: any = null;
|
|
18
17
|
let initPromise: Promise<void> | null = null;
|
|
19
18
|
let workerId: string = '';
|
|
20
19
|
|
|
21
20
|
// How many tasks this worker process currently has in flight. poolifier's
|
|
22
21
|
// tasksQueueOptions.concurrency (WORKER_CONCURRENCY, default 2 — see
|
|
23
22
|
// worker-pool-manager.ts) dispatches more than one task at a time to the SAME
|
|
24
|
-
// worker process, and every dispatched task shares this module's browser
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
23
|
+
// worker process, and every dispatched task shares this module's browser
|
|
24
|
+
// singleton (each gets its own FRESH BrowserContext via acquireTaskContext()
|
|
25
|
+
// below — a real Firefox process is too expensive to launch per task, but a
|
|
26
|
+
// context is cheap, and per-task contexts keep cookies/storage/route-mocking
|
|
27
|
+
// from one task bleeding into a concurrent or later, unrelated one).
|
|
28
|
+
// resetBrowser() uses this to avoid tearing the shared BROWSER down out from
|
|
29
|
+
// under a sibling task that is still actively using it — see resetBrowser()
|
|
30
|
+
// below.
|
|
29
31
|
let activeTaskCount = 0;
|
|
30
32
|
// A reset that resetBrowser() deferred because sibling tasks were still
|
|
31
33
|
// active, to be applied once the last one finishes.
|
|
@@ -119,12 +121,12 @@ function logToDebugFile(level: string, ...args: any[]): void {
|
|
|
119
121
|
* Initialize the browser instance
|
|
120
122
|
*/
|
|
121
123
|
export async function initBrowser(): Promise<void> {
|
|
122
|
-
if (isBrowserConnected()
|
|
124
|
+
if (isBrowserConnected()) return;
|
|
123
125
|
if (initPromise) return initPromise;
|
|
124
126
|
|
|
125
127
|
initPromise = (async () => {
|
|
126
128
|
try {
|
|
127
|
-
if (!isBrowserConnected()
|
|
129
|
+
if (!isBrowserConnected()) {
|
|
128
130
|
logToDebugFile('INFO', `[Worker-${workerId}] Initializing browser instance...`);
|
|
129
131
|
|
|
130
132
|
// ---------------------------------------------------------------------
|
|
@@ -196,8 +198,12 @@ export async function initBrowser(): Promise<void> {
|
|
|
196
198
|
//
|
|
197
199
|
// 90s launch timeout: CI runners (2 vCPU) need up to 60s to start Firefox
|
|
198
200
|
// when it has both CPUs. The old 45s limit was too tight and caused silent
|
|
199
|
-
// init failures under resource pressure.
|
|
200
|
-
|
|
201
|
+
// init failures under resource pressure. Imported from config.ts (single
|
|
202
|
+
// source of truth) rather than a local literal — the scheduler's task
|
|
203
|
+
// deadlines (browser-task-scheduler.ts) and the healthcheck budget
|
|
204
|
+
// (getHealthCheckBudgetMs) both have to budget for this exact worst case,
|
|
205
|
+
// via COLD_START_ALLOWANCE_MS.
|
|
206
|
+
const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
|
|
201
207
|
const launchOnce = async (headless: boolean | 'virtual') => {
|
|
202
208
|
const launchPromise = Camoufox({
|
|
203
209
|
headless,
|
|
@@ -244,11 +250,28 @@ export async function initBrowser(): Promise<void> {
|
|
|
244
250
|
// timeout races ahead.
|
|
245
251
|
launchPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
246
252
|
let launchTimeoutId: NodeJS.Timeout | undefined;
|
|
253
|
+
let raceLost = false;
|
|
254
|
+
// If the launch resolves AFTER we gave up on it, nobody holds the result:
|
|
255
|
+
// `browser` is assigned only from the race's winner, so the catch below —
|
|
256
|
+
// whose comment promises to "close any partially-launched browser" — finds
|
|
257
|
+
// null and closes nothing. Camoufox's own launch bound is 180s against our
|
|
258
|
+
// 90s, so a launch completing in that window leaves a fully-started headless
|
|
259
|
+
// Firefox with no owner, surviving the orphan sweep (its parent worker is
|
|
260
|
+
// alive) until the pool shuts down. Adopt and close it, exactly as
|
|
261
|
+
// createPageSafe already does for an orphaned page.
|
|
262
|
+
launchPromise.then((b: any) => {
|
|
263
|
+
if (!raceLost) return;
|
|
264
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser launch completed after its deadline; closing the orphaned instance.`);
|
|
265
|
+
try { b?.close?.(); } catch { /* already gone */ }
|
|
266
|
+
}).catch(() => { /* rejection already logged above */ });
|
|
247
267
|
try {
|
|
248
268
|
return await Promise.race([
|
|
249
269
|
launchPromise,
|
|
250
270
|
new Promise<never>((_, reject) => {
|
|
251
|
-
launchTimeoutId = setTimeout(() =>
|
|
271
|
+
launchTimeoutId = setTimeout(() => {
|
|
272
|
+
raceLost = true;
|
|
273
|
+
reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
|
|
274
|
+
}, launchTimeoutMs);
|
|
252
275
|
})
|
|
253
276
|
]);
|
|
254
277
|
} finally {
|
|
@@ -278,22 +301,6 @@ export async function initBrowser(): Promise<void> {
|
|
|
278
301
|
}
|
|
279
302
|
|
|
280
303
|
browser = launchedBrowser;
|
|
281
|
-
// newContext() can hang if the browser process becomes unresponsive immediately
|
|
282
|
-
// after launch (e.g. OOM, GPU crash). Guard with a hard timeout.
|
|
283
|
-
const contextPromise = browser.newContext();
|
|
284
|
-
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
285
|
-
let contextTimeoutId: NodeJS.Timeout | undefined;
|
|
286
|
-
context = await Promise.race([
|
|
287
|
-
contextPromise,
|
|
288
|
-
new Promise<never>((_, reject) => {
|
|
289
|
-
contextTimeoutId = setTimeout(() => reject(new Error('Browser context creation timed out after 30000ms')), 30000);
|
|
290
|
-
})
|
|
291
|
-
]);
|
|
292
|
-
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
293
|
-
|
|
294
|
-
// Setup mocking for CI if enabled
|
|
295
|
-
await setupMocking(context);
|
|
296
|
-
|
|
297
304
|
logToDebugFile('INFO', `[Worker-${workerId}] Browser initialized.`);
|
|
298
305
|
}
|
|
299
306
|
} catch (e: unknown) {
|
|
@@ -304,7 +311,6 @@ export async function initBrowser(): Promise<void> {
|
|
|
304
311
|
Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
|
|
305
312
|
}
|
|
306
313
|
browser = null;
|
|
307
|
-
context = null;
|
|
308
314
|
const msg = e instanceof Error ? e.message : String(e);
|
|
309
315
|
|
|
310
316
|
if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
|
|
@@ -331,16 +337,60 @@ export async function initBrowser(): Promise<void> {
|
|
|
331
337
|
}
|
|
332
338
|
|
|
333
339
|
/**
|
|
334
|
-
*
|
|
340
|
+
* Create a FRESH BrowserContext for one task, against the shared browser
|
|
341
|
+
* process. Call once per task, right after initBrowser() resolves; the
|
|
342
|
+
* caller owns the returned context and must close it itself (in a `finally`,
|
|
343
|
+
* covering the success, error, AND timeout-abort paths) once the task ends.
|
|
344
|
+
*
|
|
345
|
+
* Never reused across tasks — that was the bug this replaced. Two tasks
|
|
346
|
+
* dispatched concurrently to the same worker (WORKER_CONCURRENCY > 1, the
|
|
347
|
+
* default) used to share one BrowserContext, so cookies/storage a site set
|
|
348
|
+
* for one task's request were visible to a different, unrelated task's later
|
|
349
|
+
* request to the same site — a content-consistency gap with no counterpart
|
|
350
|
+
* anywhere else in this codebase, which scopes browser-adjacent state
|
|
351
|
+
* (circuit breakers, scrape caches) per session deliberately.
|
|
352
|
+
*
|
|
353
|
+
* The launch race/timeout/orphan-adoption pattern below is the exact one
|
|
354
|
+
* initBrowser() used when context creation lived there (moved here wholesale,
|
|
355
|
+
* not reinvented): newContext() can hang if the browser process is
|
|
356
|
+
* unresponsive (OOM, GPU crash), so it is guarded with a hard timeout, and a
|
|
357
|
+
* context that finishes creating AFTER that timeout is adopted and closed
|
|
358
|
+
* rather than left to leak.
|
|
335
359
|
*/
|
|
336
|
-
export function
|
|
337
|
-
|
|
360
|
+
export async function acquireTaskContext(): Promise<any> {
|
|
361
|
+
const contextPromise = browser.newContext();
|
|
362
|
+
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
363
|
+
let contextTimeoutId: NodeJS.Timeout | undefined;
|
|
364
|
+
let contextRaceLost = false;
|
|
365
|
+
contextPromise.then((c: any) => {
|
|
366
|
+
if (!contextRaceLost) return;
|
|
367
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser context creation completed after its deadline; closing the orphaned context.`);
|
|
368
|
+
try { c?.close?.(); } catch { /* already gone */ }
|
|
369
|
+
}).catch(() => { /* rejection already logged above */ });
|
|
370
|
+
let newContext: any;
|
|
371
|
+
try {
|
|
372
|
+
newContext = await Promise.race([
|
|
373
|
+
contextPromise,
|
|
374
|
+
new Promise<never>((_, reject) => {
|
|
375
|
+
contextTimeoutId = setTimeout(() => {
|
|
376
|
+
contextRaceLost = true;
|
|
377
|
+
reject(new Error(`Browser context creation timed out after ${CONTEXT_CREATION_TIMEOUT_MS}ms`));
|
|
378
|
+
}, CONTEXT_CREATION_TIMEOUT_MS);
|
|
379
|
+
})
|
|
380
|
+
]);
|
|
381
|
+
} finally {
|
|
382
|
+
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Route interception is per-context, so mocking must be (re-)applied to
|
|
386
|
+
// every fresh context, not just the first one ever created.
|
|
387
|
+
await setupMocking(newContext);
|
|
388
|
+
|
|
389
|
+
return newContext;
|
|
338
390
|
}
|
|
339
391
|
|
|
340
392
|
function doResetBrowser(): void {
|
|
341
|
-
if (context) Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during reset: ${err.message}`));
|
|
342
393
|
if (browser) Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during reset: ${err.message}`));
|
|
343
|
-
context = null;
|
|
344
394
|
browser = null;
|
|
345
395
|
}
|
|
346
396
|
|
|
@@ -377,7 +427,9 @@ export async function cleanupBrowser(): Promise<void> {
|
|
|
377
427
|
const timeoutMs = 2000;
|
|
378
428
|
|
|
379
429
|
const cleanup = async () => {
|
|
380
|
-
|
|
430
|
+
// Closes every context still open under it (any task whose own close()
|
|
431
|
+
// hasn't run yet) as a side effect — no separate context bookkeeping
|
|
432
|
+
// needed here now that contexts are task-owned, not module-owned.
|
|
381
433
|
if (browser) await Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during cleanup: ${err.message}`));
|
|
382
434
|
};
|
|
383
435
|
|
|
@@ -389,7 +441,6 @@ export async function cleanupBrowser(): Promise<void> {
|
|
|
389
441
|
} catch {
|
|
390
442
|
// Ignore timeout — we just want to ensure cleanup doesn't hang indefinitely
|
|
391
443
|
} finally {
|
|
392
|
-
context = null;
|
|
393
444
|
browser = null;
|
|
394
445
|
}
|
|
395
446
|
}
|
|
@@ -259,15 +259,42 @@ async function extractSearchResults(page: any): Promise<any[]> {
|
|
|
259
259
|
/**
|
|
260
260
|
* Execute a search task
|
|
261
261
|
*/
|
|
262
|
+
/**
|
|
263
|
+
* Bind a task-timeout signal to the page it is bounding.
|
|
264
|
+
*
|
|
265
|
+
* `page.setDefaultTimeout` bounds Playwright's action and navigation verbs
|
|
266
|
+
* individually; it does not bound their sum, and it does not bound a Response read
|
|
267
|
+
* at all. Closing the page is what actually ends the work: any in-flight call
|
|
268
|
+
* rejects with 'Target closed', which runTask's catch already maps back to the
|
|
269
|
+
* task timeout. Fire-and-forget, `{ once }` so the listener cannot outlive the task.
|
|
270
|
+
*/
|
|
271
|
+
function bindTaskAbortToPage(page: any, signal?: AbortSignal): void {
|
|
272
|
+
if (!signal) return;
|
|
273
|
+
const onAbort = () => { page.close().catch(() => {}); };
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
export async function executeSearchTask(
|
|
263
279
|
_context: any,
|
|
264
|
-
query: string
|
|
280
|
+
query: string,
|
|
281
|
+
signal?: AbortSignal
|
|
265
282
|
): Promise<{ results: any[]; jitter: number }> {
|
|
266
283
|
const page = await createPageSafe(_context);
|
|
267
284
|
const SEARCH_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'], 45000);
|
|
268
285
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
269
286
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
270
287
|
|
|
288
|
+
// Same binding the scrape path has used all along, and for the same reason: the
|
|
289
|
+
// worker's task deadline was armed but its signal was only ever handed to
|
|
290
|
+
// executeScrapeTask, so on a search it fired into nothing. Playwright's own
|
|
291
|
+
// per-action timeouts still bound each verb, but the sum of them plus the jitter
|
|
292
|
+
// sleep can outlast the whole task budget — and poolifier keeps counting this node
|
|
293
|
+
// as executing until the worker replies, so the node stays pinned long after the
|
|
294
|
+
// caller was answered. Closing the page rejects whatever is in flight with
|
|
295
|
+
// 'Target closed', which runTask's catch already recognises as the timeout.
|
|
296
|
+
bindTaskAbortToPage(page, signal);
|
|
297
|
+
|
|
271
298
|
try {
|
|
272
299
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting search for: ${query}`);
|
|
273
300
|
|
|
@@ -406,6 +433,13 @@ export async function executeScrapeTask(
|
|
|
406
433
|
let poisonedError: Error | null = null;
|
|
407
434
|
const pendingAddrChecks: Array<Promise<void>> = [];
|
|
408
435
|
|
|
436
|
+
// Set by the response-side early-abort check (see page.on('response') below) when the
|
|
437
|
+
// main-frame document's OWN headers already declare a PDF larger than MAX_PDF_SIZE.
|
|
438
|
+
// Declared outside the try for the same reason as poisonedError: the catch below needs
|
|
439
|
+
// to surface this instead of the secondary 'Target closed' error the triggering
|
|
440
|
+
// page.close() produces.
|
|
441
|
+
let oversizedPdfError: Error | null = null;
|
|
442
|
+
|
|
409
443
|
try {
|
|
410
444
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
|
|
411
445
|
|
|
@@ -484,6 +518,42 @@ export async function executeScrapeTask(
|
|
|
484
518
|
try {
|
|
485
519
|
if (resp.request().resourceType() === 'document' && resp.frame() === page.mainFrame()) {
|
|
486
520
|
finalMainFrameAddr = resp.serverAddr().catch(() => null);
|
|
521
|
+
|
|
522
|
+
// Early-abort a declared-oversized PDF as soon as its headers arrive, rather
|
|
523
|
+
// than waiting for page.goto() to finish downloading the whole thing first.
|
|
524
|
+
// 'response' fires on headers-received, well before the body is fully
|
|
525
|
+
// transferred — for a direct navigation to a PDF URL, Playwright's goto()
|
|
526
|
+
// does not resolve until the download completes, so without this the full
|
|
527
|
+
// (possibly multi-hundred-MB) body is pulled into the browser process before
|
|
528
|
+
// the PDF branch below ever gets to check Content-Length.
|
|
529
|
+
//
|
|
530
|
+
// This closes the gap only for a server that HONESTLY declares an oversized
|
|
531
|
+
// Content-Length up front. A chunked-transfer response (no Content-Length) or
|
|
532
|
+
// one that understates its length and then sends far more cannot be caught
|
|
533
|
+
// this way — Playwright's public API exposes no incremental
|
|
534
|
+
// bytes-received/streaming hook to abort mid-body on either engine. That
|
|
535
|
+
// residual case is bounded instead by the task's own SCRAPE_TIMEOUT_MS +
|
|
536
|
+
// BROWSER_TASK_TIMEOUT_MS deadline (see the onAbort/page.close() wiring
|
|
537
|
+
// above), which forcibly closes the page — and with it any in-flight
|
|
538
|
+
// download — once the task's time budget is exhausted, converting an
|
|
539
|
+
// otherwise-unbounded read into one bounded by (bandwidth x that budget).
|
|
540
|
+
const check2: Promise<void> = Promise.resolve(resp.headerValue('content-type'))
|
|
541
|
+
.then(async (ct: string | null) => {
|
|
542
|
+
// Matches the PDF branch below AND the fetch layer's own detection
|
|
543
|
+
// (web-scraper.ts): a server can mislabel or omit content-type, so a
|
|
544
|
+
// `.pdf` URL extension is an equally-trusted signal, not just the header.
|
|
545
|
+
const looksLikePdf = ct?.includes('application/pdf') || url.toLowerCase().endsWith('.pdf');
|
|
546
|
+
if (!looksLikePdf || oversizedPdfError || poisonedError) return;
|
|
547
|
+
const declared = parseInt((await resp.headerValue('content-length').catch(() => null)) || '', 10);
|
|
548
|
+
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
549
|
+
const sizeMB = Math.round(declared / 1024 / 1024);
|
|
550
|
+
oversizedPdfError = new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
551
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Aborting oversized PDF (${sizeMB}MB declared) before download completes: ${url}`);
|
|
552
|
+
page.close().catch(() => {});
|
|
553
|
+
}
|
|
554
|
+
})
|
|
555
|
+
.catch(() => {});
|
|
556
|
+
pendingAddrChecks.push(check2);
|
|
487
557
|
}
|
|
488
558
|
const check: Promise<void> = Promise.resolve(resp.serverAddr())
|
|
489
559
|
.then((addr: { ipAddress?: string } | null) => {
|
|
@@ -535,7 +605,14 @@ export async function executeScrapeTask(
|
|
|
535
605
|
// 403/503 — the HTML branch defers to the challenge wait below for that.
|
|
536
606
|
const status = response?.status?.() ?? 0;
|
|
537
607
|
|
|
538
|
-
|
|
608
|
+
// `.pdf` extension is an equally-trusted signal alongside the header (matches
|
|
609
|
+
// the fetch layer's own detection in web-scraper.ts): a server that mislabels
|
|
610
|
+
// or omits content-type would otherwise fall into the HTML branch below, whose
|
|
611
|
+
// page.content() would serialize Firefox's PDF-viewer chrome instead of
|
|
612
|
+
// extracting the document — a silent content loss for exactly the URLs this
|
|
613
|
+
// feature advertises as supported, and specifically for the malformed/oddly-
|
|
614
|
+
// served PDFs most likely to reach this browser fallback in the first place.
|
|
615
|
+
if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
|
|
539
616
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
540
617
|
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
541
618
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
@@ -549,6 +626,10 @@ export async function executeScrapeTask(
|
|
|
549
626
|
const sizeMB = Math.round(declaredLength / 1024 / 1024);
|
|
550
627
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
551
628
|
}
|
|
629
|
+
// The response-side early-abort check above (page.on('response')) may already have
|
|
630
|
+
// decided this and be in the middle of closing the page — skip the (redundant,
|
|
631
|
+
// about-to-fail) download attempt rather than race it.
|
|
632
|
+
if (oversizedPdfError) throw oversizedPdfError;
|
|
552
633
|
const buffer = await response.body();
|
|
553
634
|
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
554
635
|
// responses, so the byte count is the check that actually bounds memory.
|
|
@@ -665,9 +746,12 @@ export async function executeScrapeTask(
|
|
|
665
746
|
return { contentType, html, jitter };
|
|
666
747
|
} catch (error) {
|
|
667
748
|
await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
668
|
-
//
|
|
669
|
-
// with 'Target closed'; report the
|
|
670
|
-
//
|
|
749
|
+
// An early-abort-triggered page.close() (oversized PDF, or SSRF poisoning) makes
|
|
750
|
+
// the in-flight goto/content()/body() reject with 'Target closed'; report the
|
|
751
|
+
// actual reason (a benign-classified failure) instead of that secondary teardown
|
|
752
|
+
// error. oversizedPdfError takes priority since it can only be set on the PDF path,
|
|
753
|
+
// where poisonedError (an HTML-only DOM-exfil concern) is not meaningful.
|
|
754
|
+
if (oversizedPdfError) throw oversizedPdfError;
|
|
671
755
|
if (poisonedError) throw poisonedError;
|
|
672
756
|
throw error;
|
|
673
757
|
} finally {
|
|
@@ -692,11 +776,15 @@ const HEALTH_FALLBACK_URL = 'https://example.com/';
|
|
|
692
776
|
async function executeHealthCheckAttempt(
|
|
693
777
|
_context: any,
|
|
694
778
|
navTimeoutMs: number,
|
|
695
|
-
url: string = HEALTH_PRIMARY_URL
|
|
779
|
+
url: string = HEALTH_PRIMARY_URL,
|
|
780
|
+
signal?: AbortSignal
|
|
696
781
|
): Promise<{ success: boolean; navMs: number }> {
|
|
697
782
|
const page = await createPageSafe(_context);
|
|
698
783
|
page.setDefaultTimeout(navTimeoutMs);
|
|
699
784
|
page.setDefaultNavigationTimeout(navTimeoutMs);
|
|
785
|
+
// See executeSearchTask: the probe runs twice (primary then fallback), so its own
|
|
786
|
+
// nav timeout bounds each attempt but not the pair.
|
|
787
|
+
bindTaskAbortToPage(page, signal);
|
|
700
788
|
|
|
701
789
|
try {
|
|
702
790
|
const navStart = Date.now();
|
|
@@ -721,7 +809,8 @@ async function executeHealthCheckAttempt(
|
|
|
721
809
|
*/
|
|
722
810
|
export async function executeHealthCheck(
|
|
723
811
|
_context: any,
|
|
724
|
-
initMs: number
|
|
812
|
+
initMs: number,
|
|
813
|
+
signal?: AbortSignal
|
|
725
814
|
): Promise<{ success: boolean; navMs: number }> {
|
|
726
815
|
// Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
|
|
727
816
|
// The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
|
|
@@ -733,14 +822,14 @@ export async function executeHealthCheck(
|
|
|
733
822
|
}
|
|
734
823
|
|
|
735
824
|
try {
|
|
736
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
825
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
737
826
|
} catch (firstError: unknown) {
|
|
738
827
|
// One retry: if the first attempt fails, the page may have been in a bad state
|
|
739
828
|
// (challenge redirect, partial load, transient network blip). A second attempt
|
|
740
829
|
// on a fresh page helps distinguish a real outage from a one-off failure.
|
|
741
830
|
logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
|
|
742
831
|
try {
|
|
743
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
832
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
744
833
|
} catch (retryError: unknown) {
|
|
745
834
|
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
746
835
|
// A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
|
|
@@ -756,7 +845,7 @@ export async function executeHealthCheck(
|
|
|
756
845
|
// healthy enough to research (search has its own retries/fallbacks downstream).
|
|
757
846
|
logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
|
|
758
847
|
try {
|
|
759
|
-
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
|
|
848
|
+
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
|
|
760
849
|
logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
|
|
761
850
|
return fallback;
|
|
762
851
|
} catch (fallbackError: unknown) {
|