@lincoln504/pi-research 1.3.12 → 1.4.1
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 +5495 -4645
- 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 +94 -19
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +149 -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 +12 -8
- 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 +719 -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 +220 -34
- package/src/infrastructure/browser/config.ts +41 -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 +32 -2
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +94 -19
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- 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/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 +43 -17
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -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/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/tools/research-knowledge-search.ts +11 -3
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- 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/llm-usage.ts +33 -6
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -19,10 +19,11 @@ import type { IStateManager } from '../../core/interfaces/state-manager-interfac
|
|
|
19
19
|
import { BrowserServer, getBrowserServerAuthSecret } from './browser-server.ts';
|
|
20
20
|
import { isPoolShutdownError } from './browser-error-utils.ts';
|
|
21
21
|
import type { WorkerPoolManager } from './worker-pool-manager.ts';
|
|
22
|
-
import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
22
|
+
import type { IScheduler, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
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
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Grace added to the worker's own task budget before the dead-worker backstop
|
|
@@ -74,6 +75,47 @@ export async function executePoolTaskWithDeathBackstop<T>(
|
|
|
74
75
|
* Browser task scheduler - manages the worker pool and executes tasks.
|
|
75
76
|
* Only the leader process has an instance of this scheduler.
|
|
76
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* How long a task actually RAN, given when it was dispatched and when it was
|
|
80
|
+
* submitted.
|
|
81
|
+
*
|
|
82
|
+
* Duration used to be `now - submittedAt`, which folds queue wait into the work.
|
|
83
|
+
* That is the number `Search completed ... in Nms` prints and the number
|
|
84
|
+
* `browser_search_duration_ms` records, so it is what every latency investigation
|
|
85
|
+
* reads — and during a saturated burst it reported a 51s median against a true
|
|
86
|
+
* 5.8s execution median. A whole evening went into explaining a slowdown that had
|
|
87
|
+
* not happened. Moving the deadline off queue wait while leaving the measurement
|
|
88
|
+
* on it would have preserved exactly that misdiagnosis.
|
|
89
|
+
*
|
|
90
|
+
* A task that never reached a worker has no execution time; report its full
|
|
91
|
+
* elapsed span rather than zero, so a timeout is not silently recorded as instant.
|
|
92
|
+
*/
|
|
93
|
+
export function executionMs(dispatchedAt: number, submittedAt: number, now: number = Date.now()): number {
|
|
94
|
+
return dispatchedAt > 0 ? now - dispatchedAt : now - submittedAt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How long a task sat in the queue before a worker took it. Zero if never dispatched. */
|
|
98
|
+
export function queueWaitMs(dispatchedAt: number, submittedAt: number): number {
|
|
99
|
+
return dispatchedAt > 0 ? dispatchedAt - submittedAt : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Record a task's queue wait, whether or not it was ever dispatched.
|
|
104
|
+
*
|
|
105
|
+
* Split by outcome because the two are different quantities and must not be summed
|
|
106
|
+
* into one distribution: a dispatched task's wait ENDED, while an abandoned task's
|
|
107
|
+
* wait was still running when the caller gave up, so the latter is a lower bound.
|
|
108
|
+
* Folding abandonments in as `queueWaitMs`'s zero would pull the distribution
|
|
109
|
+
* DOWNWARD in exactly the bursts where it should spike.
|
|
110
|
+
*/
|
|
111
|
+
export function observeQueueWait(metric: string, dispatchedAt: number, submittedAt: number): void {
|
|
112
|
+
metrics.observe(
|
|
113
|
+
metric,
|
|
114
|
+
dispatchedAt > 0 ? dispatchedAt - submittedAt : Date.now() - submittedAt,
|
|
115
|
+
{ dispatched: dispatchedAt > 0 ? 'yes' : 'no' },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
export class BrowserTaskScheduler implements IScheduler {
|
|
78
120
|
private workerPoolManager: WorkerPoolManager | null = null;
|
|
79
121
|
private server: BrowserServer | null = null;
|
|
@@ -169,6 +211,9 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
169
211
|
// Decay (not reset) so a slow worker crash-loop can still accumulate to
|
|
170
212
|
// the auto-recovery threshold instead of being zeroed on every tick.
|
|
171
213
|
poolManager.decayConsecutiveErrors();
|
|
214
|
+
// Sampled on the leadership tick rather than per task: this is a
|
|
215
|
+
// distribution property, not a per-task one, and it should read zero.
|
|
216
|
+
poolManager.recordDispatchHealth();
|
|
172
217
|
} catch (err) {
|
|
173
218
|
logger.warn('[Scheduler] Leadership check error:', err);
|
|
174
219
|
} finally {
|
|
@@ -218,7 +263,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
218
263
|
return this.server.start();
|
|
219
264
|
}
|
|
220
265
|
|
|
221
|
-
async runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
|
|
266
|
+
async runSearch(query: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
|
|
222
267
|
// Reject tasks once this scheduler has begun teardown. shutdown() nulls priorityQueue and
|
|
223
268
|
// clears the scheduler reference (a future run gets a fresh scheduler), so getPriorityQueue()
|
|
224
269
|
// here would otherwise build a FRESH, non-shutdown queue on this dead instance and dispatch
|
|
@@ -235,12 +280,36 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
235
280
|
const cfg = config || getConfig();
|
|
236
281
|
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
|
|
237
282
|
|
|
283
|
+
// The deadline measures EXECUTION, not queue wait: the timer is armed by
|
|
284
|
+
// `startDeadline()` from inside the enqueued callback, i.e. once the queue has
|
|
285
|
+
// actually dispatched this task to a worker.
|
|
286
|
+
//
|
|
287
|
+
// It used to be armed here, before enqueuing, so a saturated queue burned a task's
|
|
288
|
+
// entire budget waiting for a slot and it aborted having done no work at all. That
|
|
289
|
+
// made the number of tasks a burst could complete `capacity x (budget / latency)`
|
|
290
|
+
// rather than the number submitted — measured on a live run, 78 of 100 searches
|
|
291
|
+
// aborted at the deadline without ever running. A queue is supposed to absorb
|
|
292
|
+
// volume by making work wait, not by killing whatever it could not reach in time.
|
|
293
|
+
// Waiting is now bounded by the caller's signal and the run-level timeouts; a task
|
|
294
|
+
// that does get dispatched always gets its full budget.
|
|
295
|
+
// `dispatchedAt` is the same instant, kept for MEASUREMENT rather than for the
|
|
296
|
+
// deadline. Reporting `Date.now() - startTime` as the search's duration charged
|
|
297
|
+
// queue wait to the work, and that number is what every latency analysis reads:
|
|
298
|
+
// on the night this was diagnosed it reported a 51s median against a true 5.8s
|
|
299
|
+
// execution median, and invented an eightfold latency regression that never
|
|
300
|
+
// happened. Moving the deadline off queue wait without moving the measurement
|
|
301
|
+
// would have left the misdiagnosis in place while claiming to have fixed it.
|
|
238
302
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
303
|
+
let dispatchedAt = 0;
|
|
304
|
+
let startDeadline: () => void = () => {};
|
|
239
305
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
306
|
+
startDeadline = () => {
|
|
307
|
+
dispatchedAt = Date.now();
|
|
308
|
+
timeoutId = setTimeout(() => {
|
|
309
|
+
reject(new Error(`Search task timed out after ${timeoutMs}ms. query="${query}"`));
|
|
310
|
+
}, timeoutMs);
|
|
311
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
312
|
+
};
|
|
244
313
|
});
|
|
245
314
|
|
|
246
315
|
logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
|
|
@@ -258,19 +327,24 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
258
327
|
// This ensures that even if the queue is saturated, we won't hang forever.
|
|
259
328
|
result = await Promise.race([
|
|
260
329
|
queue.enqueue('search', async () => {
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
330
|
+
startDeadline();
|
|
331
|
+
// Same instant, for the same reason, one layer up: whoever called us
|
|
332
|
+
// arms their own guard here instead of at call time, and disarms it
|
|
333
|
+
// when we settle. A listener that throws must not take the task down.
|
|
334
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
335
|
+
try {
|
|
336
|
+
return await executePoolTaskWithDeathBackstop(
|
|
337
|
+
() => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
338
|
+
timeoutMs,
|
|
339
|
+
'Search',
|
|
340
|
+
);
|
|
341
|
+
} finally {
|
|
342
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
343
|
+
}
|
|
270
344
|
}, queueSignal),
|
|
271
345
|
timeoutPromise
|
|
272
346
|
]);
|
|
273
|
-
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${
|
|
347
|
+
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${executionMs(dispatchedAt, startTime)}ms (queued ${queueWaitMs(dispatchedAt, startTime)}ms)`);
|
|
274
348
|
} catch (error) {
|
|
275
349
|
// A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
|
|
276
350
|
// mid-search-burst): the task never ran because dispose() destroyed the pool.
|
|
@@ -304,9 +378,16 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
304
378
|
} finally {
|
|
305
379
|
if (timeoutId) clearTimeout(timeoutId);
|
|
306
380
|
settled.abort();
|
|
381
|
+
// Recorded here rather than on the success path below, because the tasks
|
|
382
|
+
// that wait longest are precisely the ones that do not succeed — measuring
|
|
383
|
+
// queue wait only on completed tasks censors the tail this metric exists to
|
|
384
|
+
// expose. A task that never reached a worker has no dispatch instant, so its
|
|
385
|
+
// wait is the whole elapsed time and is labelled as such; averaging it in as
|
|
386
|
+
// a zero would have understated saturation exactly when it was worst.
|
|
387
|
+
observeQueueWait('browser_search_queue_wait_ms', dispatchedAt, startTime);
|
|
307
388
|
}
|
|
308
389
|
|
|
309
|
-
const duration =
|
|
390
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
310
391
|
// In-band worker error checked FIRST: a task the worker reports as failed
|
|
311
392
|
// must count only as an error. Recording success unconditionally before
|
|
312
393
|
// this check double-counted every worker-reported failure as
|
|
@@ -327,7 +408,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
327
408
|
return result.results;
|
|
328
409
|
}
|
|
329
410
|
|
|
330
|
-
async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
|
|
411
|
+
async runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult> {
|
|
331
412
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
332
413
|
this.resetIdleTimer();
|
|
333
414
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
@@ -338,12 +419,19 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
338
419
|
const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
339
420
|
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
|
|
340
421
|
|
|
422
|
+
// Armed on dispatch, not on enqueue — see runSearch for why.
|
|
423
|
+
// See runSearch: dispatch time is tracked for measurement, not for the deadline.
|
|
341
424
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
425
|
+
let dispatchedAt = 0;
|
|
426
|
+
let startDeadline: () => void = () => {};
|
|
342
427
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
428
|
+
startDeadline = () => {
|
|
429
|
+
dispatchedAt = Date.now();
|
|
430
|
+
timeoutId = setTimeout(() => {
|
|
431
|
+
reject(new Error(`Scrape task timed out after ${timeoutMs}ms. url="${url}"`));
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
434
|
+
};
|
|
347
435
|
});
|
|
348
436
|
|
|
349
437
|
let result: any;
|
|
@@ -355,11 +443,18 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
355
443
|
const queue = this.getPriorityQueue(config);
|
|
356
444
|
result = await Promise.race([
|
|
357
445
|
queue.enqueue('scrape', async () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
446
|
+
startDeadline();
|
|
447
|
+
// See runSearch: the caller's guard is armed on dispatch and disarmed on settle.
|
|
448
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
449
|
+
try {
|
|
450
|
+
return await executePoolTaskWithDeathBackstop(
|
|
451
|
+
() => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
452
|
+
timeoutMs,
|
|
453
|
+
'Scrape',
|
|
454
|
+
);
|
|
455
|
+
} finally {
|
|
456
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
457
|
+
}
|
|
363
458
|
}, queueSignal),
|
|
364
459
|
timeoutPromise
|
|
365
460
|
]);
|
|
@@ -386,9 +481,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
386
481
|
} finally {
|
|
387
482
|
if (timeoutId) clearTimeout(timeoutId);
|
|
388
483
|
settled.abort();
|
|
484
|
+
// See runSearch: recorded on every outcome, not only on success.
|
|
485
|
+
observeQueueWait('browser_scrape_queue_wait_ms', dispatchedAt, startTime);
|
|
389
486
|
}
|
|
390
487
|
|
|
391
|
-
const duration =
|
|
488
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
392
489
|
// In-band error first — success-side recording must not precede it (see runSearch).
|
|
393
490
|
if (result.error) {
|
|
394
491
|
metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
|
|
@@ -405,17 +502,103 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
405
502
|
return result;
|
|
406
503
|
}
|
|
407
504
|
|
|
408
|
-
async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
|
|
505
|
+
async runHealthCheck(config?: Config, signal?: AbortSignal, budgetOverrideMs?: number): Promise<{ success: boolean }> {
|
|
409
506
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
410
507
|
this.resetIdleTimer();
|
|
411
508
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
412
509
|
const startTime = Date.now();
|
|
413
510
|
const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
414
|
-
|
|
511
|
+
// Derived, not hardcoded — see getHealthCheckBudgetMs. The wait-phase verdict
|
|
512
|
+
// below rests on this budget out-waiting any task that can hold a slot, and a
|
|
513
|
+
// fixed 105s made that true only for the default timeouts. `budgetOverrideMs`
|
|
514
|
+
// exists so the verdict is reachable from a test at all: at 105s it is not,
|
|
515
|
+
// which is exactly how a previous version of it shipped unreachable and stayed
|
|
516
|
+
// that way.
|
|
517
|
+
const timeoutMs = budgetOverrideMs ?? getHealthCheckBudgetMs(config) / (isMocking ? 4 : 1);
|
|
518
|
+
|
|
519
|
+
// A probe that never got a worker used to fail as "timed out (including queue
|
|
520
|
+
// wait)", and the registry reported BrowserRuntime CRITICALLY UNHEALTHY —
|
|
521
|
+
// during a burst in which the pool was healthily executing the run's own
|
|
522
|
+
// searches. Twice on the night this was measured.
|
|
523
|
+
//
|
|
524
|
+
// Arming the deadline on DISPATCH is the whole fix, and it is sufficient. Three
|
|
525
|
+
// attempts were made to add a second, wait-phase verdict that would excuse a
|
|
526
|
+
// saturated pool, and all three were wrong. They are recorded here because each
|
|
527
|
+
// one reads sensibly and the next reader will otherwise reach for one of them:
|
|
528
|
+
//
|
|
529
|
+
// 1. "did the queue dispatch anything recently" — unreachable. Healthcheck
|
|
530
|
+
// tasks have strict queue priority, so if a slot frees while this probe
|
|
531
|
+
// waits, the queue dispatches THIS probe and cancels the timer. The timer
|
|
532
|
+
// can only fire in a window where nothing dispatched at all.
|
|
533
|
+
// 2. "are the slots occupied" — wrong in the dangerous direction. activeCount
|
|
534
|
+
// rises before pool.execute, so six workers hung inside a browser launch
|
|
535
|
+
// are six occupied slots, and the exact incident this was written for
|
|
536
|
+
// would have reported HEALTHY.
|
|
537
|
+
// 3. "has a task completed successfully recently" — unreachable for the same
|
|
538
|
+
// reason as (1). A success frees a slot, a freed slot dispatches this
|
|
539
|
+
// probe, so at expiry the last success is necessarily older than the whole
|
|
540
|
+
// budget. The condition it tested was false by construction.
|
|
541
|
+
//
|
|
542
|
+
// The general form of the mistake: with strict priority, a probe that waited
|
|
543
|
+
// out its entire budget without being dispatched IS the evidence of a wedge.
|
|
544
|
+
// There is no second signal to consult, and every attempt to find one either
|
|
545
|
+
// could not fire or fired for a dead pool. The numbers below are kept for the
|
|
546
|
+
// DIAGNOSTIC message — an operator needs to know whether the slots were busy
|
|
547
|
+
// and when work last succeeded — and not for the verdict.
|
|
548
|
+
//
|
|
549
|
+
// False positives are bounded by arithmetic rather than by judgement: the probe
|
|
550
|
+
// allows 105s while every task that can hold a slot is itself deadlined well
|
|
551
|
+
// under that (search 55s, scrape 25s plus backstop), so a slot frees inside the
|
|
552
|
+
// budget unless the pool has genuinely stopped.
|
|
553
|
+
//
|
|
554
|
+
// Once dispatched, the deadline measures execution alone (armed below, like
|
|
555
|
+
// runSearch/runScrape), so a slow probe still fails on its own merits.
|
|
415
556
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
557
|
+
let dispatched = false;
|
|
558
|
+
let hcDispatchedAt = 0;
|
|
559
|
+
let startDeadline: () => void = () => {};
|
|
560
|
+
// Captured BEFORE the timer can run. Resolving the queue inside the callback
|
|
561
|
+
// would call getPriorityQueue() on a scheduler that may have shut down in the
|
|
562
|
+
// meantime, which builds a FRESH queue on the dead instance — undoing the
|
|
563
|
+
// isShutdown latch that exists to reject enqueues racing teardown, and
|
|
564
|
+
// reporting a routine shutdown as a wedge because a new queue has dispatched
|
|
565
|
+
// nothing.
|
|
566
|
+
const healthQueue = this.getPriorityQueue(config);
|
|
567
|
+
// Rejects only. It used to be typed as resolvable because a branch here answered
|
|
568
|
+
// "healthy" without running the probe; nothing does that now, and the type says so.
|
|
416
569
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
417
|
-
|
|
570
|
+
const onWaitExpired = () => {
|
|
571
|
+
if (dispatched) return;
|
|
572
|
+
const lastSuccessAt = healthQueue.getLastSuccessAt();
|
|
573
|
+
const sinceSuccess = lastSuccessAt > 0 ? Date.now() - lastSuccessAt : Number.POSITIVE_INFINITY;
|
|
574
|
+
// The wording is load-bearing, not prose. isBusyPoolHealthFailure
|
|
575
|
+
// (src/healthcheck/index.ts) decides whether a failed probe aborts the
|
|
576
|
+
// run, and it decides by matching /tim(e|ed) out|timeout/ on this very
|
|
577
|
+
// string. An earlier rewrite of this message dropped the word entirely —
|
|
578
|
+
// so a probe that queued out under load stopped reading as a timeout, the
|
|
579
|
+
// tolerance gate rejected it, and a BUSY pool aborted the run at
|
|
580
|
+
// quick-research-orchestrator and research-health. The commit that did it
|
|
581
|
+
// was the one meant to stop a busy pool being reported as dead; it moved
|
|
582
|
+
// the abort one layer up. Pinned by a test that drives this exact string
|
|
583
|
+
// through that exact classifier.
|
|
584
|
+
//
|
|
585
|
+
// The rest is diagnosis: slots busy with nothing completing is a worker
|
|
586
|
+
// fault, slots free is a queue fault (the queue stopped dispatching), and
|
|
587
|
+
// the last-success age separates "stopped just now" from "never started".
|
|
588
|
+
reject(new Error(
|
|
589
|
+
`Health check timed out after ${timeoutMs}ms without reaching a worker — ${healthQueue.getActiveCount()} of ${healthQueue.getConcurrency()} slots busy, ` +
|
|
590
|
+
`${lastSuccessAt > 0 ? `last successful task ${sinceSuccess}ms ago` : 'no task has ever completed successfully'}.`,
|
|
591
|
+
));
|
|
592
|
+
};
|
|
593
|
+
timeoutId = setTimeout(onWaitExpired, timeoutMs);
|
|
418
594
|
if (timeoutId.unref) timeoutId.unref();
|
|
595
|
+
startDeadline = () => {
|
|
596
|
+
dispatched = true;
|
|
597
|
+
hcDispatchedAt = Date.now();
|
|
598
|
+
clearTimeout(timeoutId);
|
|
599
|
+
timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
600
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
601
|
+
};
|
|
419
602
|
});
|
|
420
603
|
|
|
421
604
|
let result: { success: boolean; error?: string };
|
|
@@ -424,9 +607,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
424
607
|
const settled = new AbortController();
|
|
425
608
|
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
426
609
|
try {
|
|
427
|
-
|
|
610
|
+
// Same instance the wait-expiry message reads, so the diagnosis cannot
|
|
611
|
+
// describe a different queue from the one this probe waited on.
|
|
428
612
|
result = await Promise.race([
|
|
429
|
-
|
|
613
|
+
healthQueue.enqueue('healthcheck', async () => {
|
|
614
|
+
startDeadline();
|
|
430
615
|
return await executePoolTaskWithDeathBackstop(() => {
|
|
431
616
|
const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
|
|
432
617
|
execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
|
|
@@ -459,9 +644,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
459
644
|
} finally {
|
|
460
645
|
if (timeoutId) clearTimeout(timeoutId);
|
|
461
646
|
settled.abort();
|
|
647
|
+
observeQueueWait('browser_healthcheck_queue_wait_ms', hcDispatchedAt, startTime);
|
|
462
648
|
}
|
|
463
649
|
|
|
464
|
-
const duration =
|
|
650
|
+
const duration = executionMs(hcDispatchedAt, startTime);
|
|
465
651
|
// In-band error first — success-side recording must not precede it (see
|
|
466
652
|
// runSearch). This path additionally owns the browser_pool_health gauge:
|
|
467
653
|
// setting it to 1 before the check flickered the pool healthy on every
|
|
@@ -327,4 +327,44 @@ export function isBrowserAvailable(): boolean {
|
|
|
327
327
|
function isFullMockMode(): boolean {
|
|
328
328
|
return process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
|
|
329
329
|
process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
330
|
-
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* How long the leader's health probe may take, end to end.
|
|
333
|
+
*
|
|
334
|
+
* This was a hardcoded 105s (45s probe + 60s queue-wait margin) and the verdict logic
|
|
335
|
+
* that reads it is built on an arithmetic claim: the probe out-waits any task that can
|
|
336
|
+
* hold a worker slot, so a probe that never reaches a worker really has found a wedge.
|
|
337
|
+
* That claim held for the DEFAULT timeouts and for no others. `SEARCH_TIMEOUT_MS` and
|
|
338
|
+
* `BROWSER_TASK_TIMEOUT_MS` are both settable to 120 000, so a search may legally hold
|
|
339
|
+
* a slot for 240s — and a user who merely raised the search timeout would see healthy
|
|
340
|
+
* pools reported as wedged, which aborts the run at the readiness gate.
|
|
341
|
+
*
|
|
342
|
+
* Deriving it instead makes the invariant hold by construction:
|
|
343
|
+
* - the longest a single task may hold a slot, plus
|
|
344
|
+
* - the probe's own work: the worker runs up to THREE navigation attempts (primary,
|
|
345
|
+
* retry, neutral-endpoint fallback) at `max(10s, HEALTH_CHECK_TIMEOUT_MS)` each,
|
|
346
|
+
* and a budget below that sum makes the fallback ladder unreachable — so raising
|
|
347
|
+
* HEALTH_CHECK_TIMEOUT_MS used to make the health check strictly worse.
|
|
348
|
+
*
|
|
349
|
+
* Floored at the historical 105s so default installs are unchanged.
|
|
350
|
+
*/
|
|
351
|
+
export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
|
|
352
|
+
|
|
353
|
+
/** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
|
|
354
|
+
const HEALTHCHECK_NAV_ATTEMPTS = 3;
|
|
355
|
+
|
|
356
|
+
export function getHealthCheckBudgetMs(config?: Config): number {
|
|
357
|
+
const c = (config || getConfig()) as Partial<Config>;
|
|
358
|
+
// Each field is read defensively. A missing or non-finite value would otherwise
|
|
359
|
+
// propagate a NaN into `setTimeout`, which Node coerces to 1ms — turning the health
|
|
360
|
+
// probe's deadline into an instant failure. Falling back to the schema default is
|
|
361
|
+
// the only reading that cannot silently invert the bound.
|
|
362
|
+
const ms = (value: unknown, fallback: number): number =>
|
|
363
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
364
|
+
const longestSlotHold =
|
|
365
|
+
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);
|
|
367
|
+
// Mirrors the worker's own floor in executeHealthCheck.
|
|
368
|
+
const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
|
|
369
|
+
return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
|
|
370
|
+
}
|
|
@@ -2,6 +2,20 @@ import { logger } from '../../logger.ts';
|
|
|
2
2
|
|
|
3
3
|
export type TaskType = 'search' | 'scrape' | 'healthcheck';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Did this resolved value carry an in-band worker failure?
|
|
7
|
+
*
|
|
8
|
+
* Worker tasks report failure by RESOLVING with `{ error: ... }` rather than by
|
|
9
|
+
* rejecting, so "the promise settled successfully" and "the task succeeded" are
|
|
10
|
+
* different facts about the same value.
|
|
11
|
+
*/
|
|
12
|
+
function isTaskFailureResult(result: unknown): boolean {
|
|
13
|
+
return typeof result === 'object'
|
|
14
|
+
&& result !== null
|
|
15
|
+
&& typeof (result as { error?: unknown }).error === 'string'
|
|
16
|
+
&& (result as { error: string }).error.length > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export interface QueuedTask<T> {
|
|
6
20
|
type: TaskType;
|
|
7
21
|
fn: () => Promise<T>;
|
|
@@ -27,6 +41,16 @@ export class PriorityTaskQueue {
|
|
|
27
41
|
private maxTotalConcurrency: number;
|
|
28
42
|
private readonly maxQueueDepth: number;
|
|
29
43
|
private isShutdown = false;
|
|
44
|
+
/** When a task last completed WITHOUT error (epoch ms), or 0 if never.
|
|
45
|
+
*
|
|
46
|
+
* Diagnostic only — it is reported in the health probe's failure message so an
|
|
47
|
+
* operator can tell a pool that stopped a moment ago from one that never started.
|
|
48
|
+
* It was briefly used as a health VERDICT and must not be again: a success frees a
|
|
49
|
+
* slot, a freed slot dispatches the waiting probe (healthchecks have strict
|
|
50
|
+
* priority), so by the time a probe times out this value is necessarily older than
|
|
51
|
+
* its whole budget. Failures deliberately do not count either — a wedged pool still
|
|
52
|
+
* turns its slots over as deadlines and death-backstops fire. */
|
|
53
|
+
private lastSuccessAt = 0;
|
|
30
54
|
|
|
31
55
|
constructor(maxTotalConcurrency: number, maxQueueDepth = 500) {
|
|
32
56
|
this.maxTotalConcurrency = maxTotalConcurrency;
|
|
@@ -90,6 +114,21 @@ export class PriorityTaskQueue {
|
|
|
90
114
|
});
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
/** Epoch ms of the last task that completed successfully, or 0 if none has. */
|
|
118
|
+
getLastSuccessAt(): number {
|
|
119
|
+
return this.lastSuccessAt;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Tasks currently executing on a worker. */
|
|
123
|
+
getActiveCount(): number {
|
|
124
|
+
return this.activeCount;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** How many tasks may execute at once. */
|
|
128
|
+
getConcurrency(): number {
|
|
129
|
+
return this.maxTotalConcurrency;
|
|
130
|
+
}
|
|
131
|
+
|
|
93
132
|
private removeFromQueue(task: QueuedTask<any>): boolean {
|
|
94
133
|
const queues = [this.healthcheckQueue, this.searchQueue, this.scrapeQueue];
|
|
95
134
|
for (const q of queues) {
|
|
@@ -171,6 +210,13 @@ export class PriorityTaskQueue {
|
|
|
171
210
|
} else {
|
|
172
211
|
result = await fnPromise;
|
|
173
212
|
}
|
|
213
|
+
// A worker-reported failure arrives as a RESOLVED value carrying `error` —
|
|
214
|
+
// poolifier delivers it that way, and the scheduler only turns it into a
|
|
215
|
+
// throw afterwards. Resolution alone therefore does not mean the task
|
|
216
|
+
// succeeded: a worker returning `{error:'Task timed out'}` for every task
|
|
217
|
+
// would keep this timestamp fresh through a total outage, which is the one
|
|
218
|
+
// thing it must not do.
|
|
219
|
+
if (!isTaskFailureResult(result)) this.lastSuccessAt = Date.now();
|
|
174
220
|
task.resolve(result);
|
|
175
221
|
} catch (err) {
|
|
176
222
|
task.reject(err);
|
|
@@ -187,8 +233,16 @@ export class PriorityTaskQueue {
|
|
|
187
233
|
* Update the maximum concurrency limit (e.g. if config changes).
|
|
188
234
|
*/
|
|
189
235
|
updateConcurrency(maxTotalConcurrency: number) {
|
|
236
|
+
// Called on a path that runs per task, and the value almost never changes: this
|
|
237
|
+
// logged "Concurrency updated to 6" 709 times in one day, always to the value it
|
|
238
|
+
// already had. Log real changes only — a line that fires constantly and says
|
|
239
|
+
// nothing is what a reader learns to skip past, including on the day it matters.
|
|
240
|
+
// process() still runs unconditionally: it is idempotent, and a raise that
|
|
241
|
+
// arrived while the queue was full must be free to dispatch immediately.
|
|
242
|
+
if (maxTotalConcurrency !== this.maxTotalConcurrency) {
|
|
243
|
+
logger.debug(`[PriorityQueue] Concurrency changed ${this.maxTotalConcurrency} -> ${maxTotalConcurrency}`);
|
|
244
|
+
}
|
|
190
245
|
this.maxTotalConcurrency = maxTotalConcurrency;
|
|
191
|
-
logger.debug(`[PriorityQueue] Concurrency updated to ${maxTotalConcurrency}`);
|
|
192
246
|
this.process();
|
|
193
247
|
}
|
|
194
248
|
|
|
@@ -162,8 +162,19 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
|
|
|
162
162
|
|
|
163
163
|
logger.log('[Scheduler] Restart complete. Next call will create fresh scheduler.');
|
|
164
164
|
} finally {
|
|
165
|
+
// Deliberately does NOT dispose the browser-init lock. This function never
|
|
166
|
+
// acquires it, and FileLockService.dispose() is not a release — it drains for
|
|
167
|
+
// 5s and then FORCES the teardown, unlinking the lock file and retiring the
|
|
168
|
+
// instance. A browser init holds that lock for as long as it takes to launch a
|
|
169
|
+
// browser server, which is routinely longer than 5s, and the retry paths in
|
|
170
|
+
// task-execution-service call this function mid-run precisely when an init is
|
|
171
|
+
// likely in flight. The victim's critical section is not cancelled by the
|
|
172
|
+
// teardown, and the next getBrowserInitLock() builds a fresh instance (new uuid,
|
|
173
|
+
// new FIFO queue) over the now-unlinked path, so it acquires immediately: two
|
|
174
|
+
// concurrent browser inits, two elections, two listening servers. The lock is
|
|
175
|
+
// path-scoped and version-independent, so a restart does not invalidate it;
|
|
176
|
+
// the owning SchedulerFactoryService disposes it at container teardown.
|
|
165
177
|
schedulerService.setSchedulerRestartInProgress(false);
|
|
166
|
-
await disposeBrowserInitLock(container).catch(() => {});
|
|
167
178
|
}
|
|
168
179
|
}
|
|
169
180
|
|
|
@@ -18,7 +18,7 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
|
|
|
18
18
|
import type { ServiceContainer } from '../../core/service-registry.ts';
|
|
19
19
|
import { ServiceNames } from '../../core/service-interfaces.ts';
|
|
20
20
|
import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
|
|
21
|
-
import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
21
|
+
import type { ISchedulerInternals, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
22
22
|
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
23
23
|
import { BrowserClient } from './browser-client.ts';
|
|
24
24
|
|
|
@@ -331,7 +331,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
331
331
|
/**
|
|
332
332
|
* Run a worker search query with retry logic.
|
|
333
333
|
*/
|
|
334
|
-
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
|
|
334
|
+
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
|
|
335
335
|
if (signal?.aborted) throw new Error('Aborted');
|
|
336
336
|
|
|
337
337
|
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -340,7 +340,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
340
340
|
return await breaker.execute(async () => {
|
|
341
341
|
if (signal?.aborted) throw new Error('Aborted');
|
|
342
342
|
const scheduler = await getScheduler(config, container);
|
|
343
|
-
return await scheduler.runSearch(query, config, signal);
|
|
343
|
+
return await scheduler.runSearch(query, config, signal, onTaskEvent);
|
|
344
344
|
});
|
|
345
345
|
} catch (error: unknown) {
|
|
346
346
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
@@ -358,7 +358,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
358
358
|
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${briefErrorMessage(error)}...`);
|
|
359
359
|
await recoverFromLeaderHandover(container);
|
|
360
360
|
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
361
|
-
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
361
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1, onTaskEvent);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
@@ -381,7 +381,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
381
381
|
|
|
382
382
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
383
383
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
384
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
384
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries, onTaskEvent);
|
|
385
385
|
}
|
|
386
386
|
throw error;
|
|
387
387
|
}
|
|
@@ -244,11 +244,28 @@ export async function initBrowser(): Promise<void> {
|
|
|
244
244
|
// timeout races ahead.
|
|
245
245
|
launchPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
246
246
|
let launchTimeoutId: NodeJS.Timeout | undefined;
|
|
247
|
+
let raceLost = false;
|
|
248
|
+
// If the launch resolves AFTER we gave up on it, nobody holds the result:
|
|
249
|
+
// `browser` is assigned only from the race's winner, so the catch below —
|
|
250
|
+
// whose comment promises to "close any partially-launched browser" — finds
|
|
251
|
+
// null and closes nothing. Camoufox's own launch bound is 180s against our
|
|
252
|
+
// 90s, so a launch completing in that window leaves a fully-started headless
|
|
253
|
+
// Firefox with no owner, surviving the orphan sweep (its parent worker is
|
|
254
|
+
// alive) until the pool shuts down. Adopt and close it, exactly as
|
|
255
|
+
// createPageSafe already does for an orphaned page.
|
|
256
|
+
launchPromise.then((b: any) => {
|
|
257
|
+
if (!raceLost) return;
|
|
258
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser launch completed after its deadline; closing the orphaned instance.`);
|
|
259
|
+
try { b?.close?.(); } catch { /* already gone */ }
|
|
260
|
+
}).catch(() => { /* rejection already logged above */ });
|
|
247
261
|
try {
|
|
248
262
|
return await Promise.race([
|
|
249
263
|
launchPromise,
|
|
250
264
|
new Promise<never>((_, reject) => {
|
|
251
|
-
launchTimeoutId = setTimeout(() =>
|
|
265
|
+
launchTimeoutId = setTimeout(() => {
|
|
266
|
+
raceLost = true;
|
|
267
|
+
reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
|
|
268
|
+
}, launchTimeoutMs);
|
|
252
269
|
})
|
|
253
270
|
]);
|
|
254
271
|
} finally {
|
|
@@ -283,10 +300,23 @@ export async function initBrowser(): Promise<void> {
|
|
|
283
300
|
const contextPromise = browser.newContext();
|
|
284
301
|
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
285
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 */ });
|
|
286
313
|
context = await Promise.race([
|
|
287
314
|
contextPromise,
|
|
288
315
|
new Promise<never>((_, reject) => {
|
|
289
|
-
contextTimeoutId = setTimeout(() =>
|
|
316
|
+
contextTimeoutId = setTimeout(() => {
|
|
317
|
+
contextRaceLost = true;
|
|
318
|
+
reject(new Error('Browser context creation timed out after 30000ms'));
|
|
319
|
+
}, 30000);
|
|
290
320
|
})
|
|
291
321
|
]);
|
|
292
322
|
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|