@lincoln504/pi-research 1.4.1 → 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/dist/cli.mjs +271 -156
- package/dist/thread-worker.mjs +91 -59
- package/docs/CHANGELOG.md +76 -0
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/config.ts +10 -4
- package/src/core/planning-service.ts +55 -8
- package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
- package/src/infrastructure/browser/config.ts +40 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
- package/src/infrastructure/browser/thread-worker-messaging.ts +61 -4
- package/src/infrastructure/browser/thread-worker.mjs +91 -59
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/embedding/embedding-server.ts +34 -0
- package/src/knowledge/embedder-init.ts +3 -2
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- package/src/orchestration/session-state.ts +16 -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-tool-definition.ts +38 -7
- package/src/tools/security.ts +13 -10
- package/src/utils/coverage-digest.ts +1 -1
- package/src/utils/host-config.ts +12 -1
- package/src/utils/log-utils.ts +10 -0
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
|
@@ -23,7 +23,7 @@ import type { IScheduler, TaskDispatchListener } from '../../core/interfaces/sch
|
|
|
23
23
|
import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowserProcesses } from './browser-cleanup.ts';
|
|
24
24
|
import { raceWithDeadline } from '../../utils/safe-unref.ts';
|
|
25
25
|
import { PriorityTaskQueue } from './priority-task-queue.ts';
|
|
26
|
-
import { getHealthCheckBudgetMs } from './config.ts';
|
|
26
|
+
import { getHealthCheckBudgetMs, COLD_START_ALLOWANCE_MS } from './config.ts';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Grace added to the worker's own task budget before the dead-worker backstop
|
|
@@ -273,12 +273,17 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
273
273
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
274
274
|
const startTime = Date.now();
|
|
275
275
|
|
|
276
|
-
// Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) +
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
276
|
+
// Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) + an overhead margin
|
|
277
|
+
// (BROWSER_TASK_TIMEOUT_MS) + a cold-start allowance (COLD_START_ALLOWANCE_MS).
|
|
278
|
+
// Deriving from SEARCH_TIMEOUT_MS keeps the task ceiling coherent with the
|
|
279
|
+
// worker's own nav timeout — using a flat base here would kill a search before
|
|
280
|
+
// it had used its full nav budget. The cold-start term matters because eager
|
|
281
|
+
// browser warmup is deliberately disabled: the first search dispatched to a
|
|
282
|
+
// freshly created or just-reset worker pays a real Camoufox launch + context
|
|
283
|
+
// creation inline, before any navigation even starts — without this term that
|
|
284
|
+
// legitimate cold start reads as a dead/wedged worker (see COLD_START_ALLOWANCE_MS).
|
|
280
285
|
const cfg = config || getConfig();
|
|
281
|
-
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
|
|
286
|
+
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS;
|
|
282
287
|
|
|
283
288
|
// The deadline measures EXECUTION, not queue wait: the timer is armed by
|
|
284
289
|
// `startDeadline()` from inside the enqueued callback, i.e. once the queue has
|
|
@@ -413,11 +418,13 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
413
418
|
this.resetIdleTimer();
|
|
414
419
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
415
420
|
const startTime = Date.now();
|
|
416
|
-
// Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) +
|
|
417
|
-
//
|
|
421
|
+
// Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) + an overhead margin,
|
|
422
|
+
// mirroring the search path so both stay coherent. The mocked branch skips the
|
|
423
|
+
// cold-start allowance deliberately: PI_RESEARCH_MOCK_SCRAPE implies no real
|
|
424
|
+
// Camoufox launch is expected for scrape tasks, so there is nothing to budget for.
|
|
418
425
|
const cfg = config || getConfig();
|
|
419
426
|
const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
420
|
-
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
|
|
427
|
+
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS);
|
|
421
428
|
|
|
422
429
|
// Armed on dispatch, not on enqueue — see runSearch for why.
|
|
423
430
|
// See runSearch: dispatch time is tracked for measurement, not for the deadline.
|
|
@@ -346,13 +346,50 @@ function isFullMockMode(): boolean {
|
|
|
346
346
|
* and a budget below that sum makes the fallback ladder unreachable — so raising
|
|
347
347
|
* HEALTH_CHECK_TIMEOUT_MS used to make the health check strictly worse.
|
|
348
348
|
*
|
|
349
|
-
* Floored at the historical 105s
|
|
349
|
+
* Floored at the historical 105s, kept as a floor rather than a default: the
|
|
350
|
+
* derived value now exceeds it even at default timeouts once COLD_START_ALLOWANCE_MS
|
|
351
|
+
* (below) is folded into `longestSlotHold` — a cold Firefox launch was always able to
|
|
352
|
+
* hold a slot for close to that long, the 105s floor just didn't know it. The floor
|
|
353
|
+
* still matters for a hypothetical config where the derived value would otherwise land
|
|
354
|
+
* below it.
|
|
350
355
|
*/
|
|
351
356
|
export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
|
|
352
357
|
|
|
353
358
|
/** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
|
|
354
359
|
const HEALTHCHECK_NAV_ATTEMPTS = 3;
|
|
355
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Hard ceiling on a single Camoufox browser launch (see launchOnce() in
|
|
363
|
+
* thread-worker-browser.ts, the single source of truth this mirrors — CI runners
|
|
364
|
+
* (2 vCPU) can take up to ~60s to start Firefox under load, floored generously
|
|
365
|
+
* above that).
|
|
366
|
+
*/
|
|
367
|
+
export const BROWSER_LAUNCH_TIMEOUT_MS = 90_000;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Hard ceiling on creating one BrowserContext against an already-launched browser
|
|
371
|
+
* (see acquireTaskContext() in thread-worker-browser.ts, the single source of
|
|
372
|
+
* truth this mirrors).
|
|
373
|
+
*/
|
|
374
|
+
export const CONTEXT_CREATION_TIMEOUT_MS = 30_000;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Worst-case time a worker can spend getting a browser+context ready before it
|
|
378
|
+
* starts a task's actual work. Eager warmup is deliberately disabled (thundering-
|
|
379
|
+
* herd avoidance — see thread-worker.ts), so the first task dispatched to a
|
|
380
|
+
* freshly created or just-reset worker pays this in full, inline with the task
|
|
381
|
+
* itself. Every deadline that bounds how long a task may legitimately hold a
|
|
382
|
+
* worker slot has to include this on top of its own nav budget, or a completely
|
|
383
|
+
* normal cold start on a fresh pool reads as a dead/wedged worker — the false
|
|
384
|
+
* positive `runSearch`/`runScrape`'s task-timeout ceiling and this budget were
|
|
385
|
+
* both built to eliminate, just triggered by a different clock.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately NOT part of BROWSER_TASK_TIMEOUT_MS: that value is user-tunable
|
|
388
|
+
* overhead for per-task work, and cold-start cost isn't something a user should
|
|
389
|
+
* need to account for by hand when adjusting it.
|
|
390
|
+
*/
|
|
391
|
+
export const COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
|
|
392
|
+
|
|
356
393
|
export function getHealthCheckBudgetMs(config?: Config): number {
|
|
357
394
|
const c = (config || getConfig()) as Partial<Config>;
|
|
358
395
|
// Each field is read defensively. A missing or non-finite value would otherwise
|
|
@@ -363,7 +400,8 @@ export function getHealthCheckBudgetMs(config?: Config): number {
|
|
|
363
400
|
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
364
401
|
const longestSlotHold =
|
|
365
402
|
Math.max(ms(c.SEARCH_TIMEOUT_MS, 45_000), ms(c.SCRAPE_TIMEOUT_MS, 15_000))
|
|
366
|
-
+ ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000)
|
|
403
|
+
+ ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000)
|
|
404
|
+
+ COLD_START_ALLOWANCE_MS;
|
|
367
405
|
// Mirrors the worker's own floor in executeHealthCheck.
|
|
368
406
|
const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
|
|
369
407
|
return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
|
|
@@ -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,
|
|
@@ -295,35 +301,6 @@ export async function initBrowser(): Promise<void> {
|
|
|
295
301
|
}
|
|
296
302
|
|
|
297
303
|
browser = launchedBrowser;
|
|
298
|
-
// newContext() can hang if the browser process becomes unresponsive immediately
|
|
299
|
-
// after launch (e.g. OOM, GPU crash). Guard with a hard timeout.
|
|
300
|
-
const contextPromise = browser.newContext();
|
|
301
|
-
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
302
|
-
let contextTimeoutId: NodeJS.Timeout | undefined;
|
|
303
|
-
let contextRaceLost = false;
|
|
304
|
-
// Same adoption as the launch above. Here the catch below CAN close the
|
|
305
|
-
// browser (it is assigned by now), which disposes the stray context with it —
|
|
306
|
-
// but only when initBrowser is entered with a dead browser, and it leaves the
|
|
307
|
-
// context open for the window in between. Closing it directly is unconditional.
|
|
308
|
-
contextPromise.then((c: any) => {
|
|
309
|
-
if (!contextRaceLost) return;
|
|
310
|
-
logToDebugFile('WARN', `[Worker-${workerId}] Browser context creation completed after its deadline; closing the orphaned context.`);
|
|
311
|
-
try { c?.close?.(); } catch { /* already gone */ }
|
|
312
|
-
}).catch(() => { /* rejection already logged above */ });
|
|
313
|
-
context = await Promise.race([
|
|
314
|
-
contextPromise,
|
|
315
|
-
new Promise<never>((_, reject) => {
|
|
316
|
-
contextTimeoutId = setTimeout(() => {
|
|
317
|
-
contextRaceLost = true;
|
|
318
|
-
reject(new Error('Browser context creation timed out after 30000ms'));
|
|
319
|
-
}, 30000);
|
|
320
|
-
})
|
|
321
|
-
]);
|
|
322
|
-
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
323
|
-
|
|
324
|
-
// Setup mocking for CI if enabled
|
|
325
|
-
await setupMocking(context);
|
|
326
|
-
|
|
327
304
|
logToDebugFile('INFO', `[Worker-${workerId}] Browser initialized.`);
|
|
328
305
|
}
|
|
329
306
|
} catch (e: unknown) {
|
|
@@ -334,7 +311,6 @@ export async function initBrowser(): Promise<void> {
|
|
|
334
311
|
Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
|
|
335
312
|
}
|
|
336
313
|
browser = null;
|
|
337
|
-
context = null;
|
|
338
314
|
const msg = e instanceof Error ? e.message : String(e);
|
|
339
315
|
|
|
340
316
|
if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
|
|
@@ -361,16 +337,60 @@ export async function initBrowser(): Promise<void> {
|
|
|
361
337
|
}
|
|
362
338
|
|
|
363
339
|
/**
|
|
364
|
-
*
|
|
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.
|
|
365
359
|
*/
|
|
366
|
-
export function
|
|
367
|
-
|
|
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;
|
|
368
390
|
}
|
|
369
391
|
|
|
370
392
|
function doResetBrowser(): void {
|
|
371
|
-
if (context) Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during reset: ${err.message}`));
|
|
372
393
|
if (browser) Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during reset: ${err.message}`));
|
|
373
|
-
context = null;
|
|
374
394
|
browser = null;
|
|
375
395
|
}
|
|
376
396
|
|
|
@@ -407,7 +427,9 @@ export async function cleanupBrowser(): Promise<void> {
|
|
|
407
427
|
const timeoutMs = 2000;
|
|
408
428
|
|
|
409
429
|
const cleanup = async () => {
|
|
410
|
-
|
|
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.
|
|
411
433
|
if (browser) await Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during cleanup: ${err.message}`));
|
|
412
434
|
};
|
|
413
435
|
|
|
@@ -419,7 +441,6 @@ export async function cleanupBrowser(): Promise<void> {
|
|
|
419
441
|
} catch {
|
|
420
442
|
// Ignore timeout — we just want to ensure cleanup doesn't hang indefinitely
|
|
421
443
|
} finally {
|
|
422
|
-
context = null;
|
|
423
444
|
browser = null;
|
|
424
445
|
}
|
|
425
446
|
}
|
|
@@ -433,6 +433,13 @@ export async function executeScrapeTask(
|
|
|
433
433
|
let poisonedError: Error | null = null;
|
|
434
434
|
const pendingAddrChecks: Array<Promise<void>> = [];
|
|
435
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
|
+
|
|
436
443
|
try {
|
|
437
444
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
|
|
438
445
|
|
|
@@ -511,6 +518,42 @@ export async function executeScrapeTask(
|
|
|
511
518
|
try {
|
|
512
519
|
if (resp.request().resourceType() === 'document' && resp.frame() === page.mainFrame()) {
|
|
513
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);
|
|
514
557
|
}
|
|
515
558
|
const check: Promise<void> = Promise.resolve(resp.serverAddr())
|
|
516
559
|
.then((addr: { ipAddress?: string } | null) => {
|
|
@@ -562,7 +605,14 @@ export async function executeScrapeTask(
|
|
|
562
605
|
// 403/503 — the HTML branch defers to the challenge wait below for that.
|
|
563
606
|
const status = response?.status?.() ?? 0;
|
|
564
607
|
|
|
565
|
-
|
|
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')) {
|
|
566
616
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
567
617
|
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
568
618
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
@@ -576,6 +626,10 @@ export async function executeScrapeTask(
|
|
|
576
626
|
const sizeMB = Math.round(declaredLength / 1024 / 1024);
|
|
577
627
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
578
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;
|
|
579
633
|
const buffer = await response.body();
|
|
580
634
|
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
581
635
|
// responses, so the byte count is the check that actually bounds memory.
|
|
@@ -692,9 +746,12 @@ export async function executeScrapeTask(
|
|
|
692
746
|
return { contentType, html, jitter };
|
|
693
747
|
} catch (error) {
|
|
694
748
|
await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
695
|
-
//
|
|
696
|
-
// with 'Target closed'; report the
|
|
697
|
-
//
|
|
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;
|
|
698
755
|
if (poisonedError) throw poisonedError;
|
|
699
756
|
throw error;
|
|
700
757
|
} finally {
|