@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.
Files changed (34) hide show
  1. package/dist/cli.mjs +271 -156
  2. package/dist/thread-worker.mjs +91 -59
  3. package/docs/CHANGELOG.md +76 -0
  4. package/docs/CONFIGURATION.md +1 -1
  5. package/package.json +1 -1
  6. package/skills/pi-research/SKILL.md +1 -1
  7. package/src/config.ts +10 -4
  8. package/src/core/planning-service.ts +55 -8
  9. package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
  10. package/src/infrastructure/browser/config.ts +40 -2
  11. package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
  12. package/src/infrastructure/browser/thread-worker-messaging.ts +61 -4
  13. package/src/infrastructure/browser/thread-worker.mjs +91 -59
  14. package/src/infrastructure/browser/thread-worker.ts +13 -5
  15. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  16. package/src/knowledge/embedder-init.ts +3 -2
  17. package/src/knowledge/embedder.ts +11 -3
  18. package/src/knowledge/index.ts +36 -10
  19. package/src/knowledge/store.ts +29 -5
  20. package/src/orchestration/deep-research-orchestrator.ts +3 -1
  21. package/src/orchestration/session-state.ts +16 -0
  22. package/src/sdk.ts +10 -0
  23. package/src/skill-install/skill-installer.ts +5 -1
  24. package/src/stackexchange/index.ts +3 -0
  25. package/src/stackexchange/rest-client.ts +7 -12
  26. package/src/tools/research-tool-definition.ts +38 -7
  27. package/src/tools/security.ts +13 -10
  28. package/src/utils/coverage-digest.ts +1 -1
  29. package/src/utils/host-config.ts +12 -1
  30. package/src/utils/log-utils.ts +10 -0
  31. package/src/web-research/browser-search.ts +11 -4
  32. package/src/web-research/web-scraper.ts +27 -16
  33. package/src/youtube/potoken.ts +8 -1
  34. 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) + a queue-wait/overhead
277
- // margin (BROWSER_TASK_TIMEOUT_MS). Deriving from SEARCH_TIMEOUT_MS keeps the task
278
- // ceiling coherent with the worker's own nav timeout — using a flat base here would
279
- // kill a search before it had used its full nav budget.
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) + the queue-wait/overhead
417
- // margin (BROWSER_TASK_TIMEOUT_MS), mirroring the search path so both stay coherent.
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 so default installs are unchanged.
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
- // context singletons (each gets its own page via createPageSafe(context), but
26
- // the context/browser themselves are one shared instance). resetBrowser()
27
- // uses this to avoid tearing that shared instance down out from under a
28
- // sibling task that is still actively using it — see resetBrowser() below.
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() && context) return;
124
+ if (isBrowserConnected()) return;
123
125
  if (initPromise) return initPromise;
124
126
 
125
127
  initPromise = (async () => {
126
128
  try {
127
- if (!isBrowserConnected() || !context) {
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
- const launchTimeoutMs = 90000;
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
- * Get the current browser context
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 getContext(): any {
367
- return context;
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
- if (context) await Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during cleanup: ${err.message}`));
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
- if (contentType.includes('application/pdf')) {
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
- // A poison-triggered page.close() makes the in-flight goto/content() reject
696
- // with 'Target closed'; report the SSRF poisoning (a benign-classified
697
- // 'Fetch blocked:' failure), not the secondary teardown error.
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 {