@lincoln504/pi-research 1.1.2 → 1.2.0
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 +3 -3
- package/dist/cli.mjs +1287 -540
- package/dist/thread-worker.mjs +30 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +81 -1
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +149 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +30 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/http-body.ts +176 -0
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -24,6 +24,52 @@ import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowser
|
|
|
24
24
|
import { raceWithDeadline } from '../../utils/safe-unref.ts';
|
|
25
25
|
import { PriorityTaskQueue } from './priority-task-queue.ts';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Grace added to the worker's own task budget before the dead-worker backstop
|
|
29
|
+
* fires, so a live worker's more informative timeout always answers first.
|
|
30
|
+
*/
|
|
31
|
+
export const WORKER_DEATH_BACKSTOP_GRACE_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Execute a pool task with a dead-worker backstop.
|
|
35
|
+
*
|
|
36
|
+
* poolifier (measured on 5.3.2) never settles an in-flight execute() whose worker
|
|
37
|
+
* dies — not when the worker exits, not when a replacement is respawned, not even
|
|
38
|
+
* when the pool is destroyed. The worker enforces taskTimeoutMs on itself, but only
|
|
39
|
+
* while it is alive; an OOM-killed worker leaves the promise pending forever, and
|
|
40
|
+
* with it the PriorityTaskQueue concurrency slot the dispatched closure holds. Each
|
|
41
|
+
* such death would permanently shrink the queue until the leader serves nothing but
|
|
42
|
+
* timeouts.
|
|
43
|
+
*
|
|
44
|
+
* The backstop timer is created inside the dispatched closure, so it shares no
|
|
45
|
+
* lifetime with the caller's outer race — whose `finally` clearing a shared timer
|
|
46
|
+
* is exactly how a QUEUED task once lost its guard. Armed at dispatch, it fires
|
|
47
|
+
* only when nothing is left to answer; its rejection frees the queue slot (the
|
|
48
|
+
* caller has already been answered by the outer race).
|
|
49
|
+
*/
|
|
50
|
+
export async function executePoolTaskWithDeathBackstop<T>(
|
|
51
|
+
execute: () => Promise<T>,
|
|
52
|
+
taskTimeoutMs: number,
|
|
53
|
+
label: string,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const backstopMs = taskTimeoutMs + WORKER_DEATH_BACKSTOP_GRACE_MS;
|
|
56
|
+
let backstopId: NodeJS.Timeout | undefined;
|
|
57
|
+
try {
|
|
58
|
+
return await Promise.race([
|
|
59
|
+
execute(),
|
|
60
|
+
new Promise<never>((_, reject) => {
|
|
61
|
+
backstopId = setTimeout(
|
|
62
|
+
() => reject(new Error(`${label} task unanswered after ${backstopMs}ms — worker likely died mid-task`)),
|
|
63
|
+
backstopMs,
|
|
64
|
+
);
|
|
65
|
+
if (backstopId.unref) backstopId.unref();
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
} finally {
|
|
69
|
+
if (backstopId) clearTimeout(backstopId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
/**
|
|
28
74
|
* Browser task scheduler - manages the worker pool and executes tasks.
|
|
29
75
|
* Only the leader process has an instance of this scheduler.
|
|
@@ -192,17 +238,29 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
192
238
|
|
|
193
239
|
logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
|
|
194
240
|
let result: any;
|
|
241
|
+
// Settling the outer race must also release the task's queue claim: a task
|
|
242
|
+
// still QUEUED when the caller is answered is cancelled before it consumes
|
|
243
|
+
// a slot, and a RUNNING one frees its slot instead of keeping it (plus a
|
|
244
|
+
// fresh worker budget) for a result nobody will consume. The abort-while-
|
|
245
|
+
// queued/running semantics are the queue's own, pinned by its tests.
|
|
246
|
+
const settled = new AbortController();
|
|
247
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
195
248
|
try {
|
|
196
249
|
const queue = this.getPriorityQueue(config);
|
|
197
250
|
// Race the enqueue call against the timeoutPromise.
|
|
198
251
|
// This ensures that even if the queue is saturated, we won't hang forever.
|
|
199
252
|
result = await Promise.race([
|
|
200
253
|
queue.enqueue('search', async () => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
254
|
+
// The shared timeoutPromise is deliberately NOT raced in here (its timer
|
|
255
|
+
// dies with the outer race); the backstop below carries its own timer, so
|
|
256
|
+
// a QUEUED task can never be dispatched against a disarmed guard, and a
|
|
257
|
+
// worker dying mid-task cannot pin the queue slot forever.
|
|
258
|
+
return await executePoolTaskWithDeathBackstop(
|
|
259
|
+
() => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
260
|
+
timeoutMs,
|
|
261
|
+
'Search',
|
|
262
|
+
);
|
|
263
|
+
}, queueSignal),
|
|
206
264
|
timeoutPromise
|
|
207
265
|
]);
|
|
208
266
|
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
|
|
@@ -226,6 +284,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
226
284
|
throw error;
|
|
227
285
|
} finally {
|
|
228
286
|
if (timeoutId) clearTimeout(timeoutId);
|
|
287
|
+
settled.abort();
|
|
229
288
|
}
|
|
230
289
|
|
|
231
290
|
const duration = Date.now() - startTime;
|
|
@@ -265,15 +324,20 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
265
324
|
});
|
|
266
325
|
|
|
267
326
|
let result: any;
|
|
327
|
+
// See runSearch: outer settle releases the queue claim; the backstop bounds
|
|
328
|
+
// a dead worker's never-settling execute().
|
|
329
|
+
const settled = new AbortController();
|
|
330
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
268
331
|
try {
|
|
269
332
|
const queue = this.getPriorityQueue(config);
|
|
270
333
|
result = await Promise.race([
|
|
271
334
|
queue.enqueue('scrape', async () => {
|
|
272
|
-
return await
|
|
273
|
-
pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
335
|
+
return await executePoolTaskWithDeathBackstop(
|
|
336
|
+
() => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
337
|
+
timeoutMs,
|
|
338
|
+
'Scrape',
|
|
339
|
+
);
|
|
340
|
+
}, queueSignal),
|
|
277
341
|
timeoutPromise
|
|
278
342
|
]);
|
|
279
343
|
} catch (error) {
|
|
@@ -292,6 +356,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
292
356
|
throw error;
|
|
293
357
|
} finally {
|
|
294
358
|
if (timeoutId) clearTimeout(timeoutId);
|
|
359
|
+
settled.abort();
|
|
295
360
|
}
|
|
296
361
|
|
|
297
362
|
const duration = Date.now() - startTime;
|
|
@@ -324,17 +389,20 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
324
389
|
});
|
|
325
390
|
|
|
326
391
|
let result: { success: boolean; error?: string };
|
|
392
|
+
// See runSearch: outer settle releases the queue claim; the backstop bounds
|
|
393
|
+
// a dead worker's never-settling execute().
|
|
394
|
+
const settled = new AbortController();
|
|
395
|
+
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
327
396
|
try {
|
|
328
397
|
const queue = this.getPriorityQueue(config);
|
|
329
398
|
result = await Promise.race([
|
|
330
399
|
queue.enqueue('healthcheck', async () => {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
execPromise
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}, signal),
|
|
400
|
+
return await executePoolTaskWithDeathBackstop(() => {
|
|
401
|
+
const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
|
|
402
|
+
execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
|
|
403
|
+
return execPromise;
|
|
404
|
+
}, timeoutMs, 'Healthcheck');
|
|
405
|
+
}, queueSignal),
|
|
338
406
|
timeoutPromise
|
|
339
407
|
]) as { success: boolean; error?: string };
|
|
340
408
|
} catch (error) {
|
|
@@ -353,6 +421,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
353
421
|
throw error;
|
|
354
422
|
} finally {
|
|
355
423
|
if (timeoutId) clearTimeout(timeoutId);
|
|
424
|
+
settled.abort();
|
|
356
425
|
}
|
|
357
426
|
|
|
358
427
|
const duration = Date.now() - startTime;
|
|
@@ -411,12 +480,21 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
411
480
|
// reference would be moot. Guarding here keeps shutdown from logging a
|
|
412
481
|
// spurious "Cannot get service 'scheduler' during container disposal".
|
|
413
482
|
if (!this.container.isDisposing) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
483
|
+
try {
|
|
484
|
+
const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
|
|
485
|
+
const currentScheduler = schedulerService.getSchedulerInstance();
|
|
486
|
+
if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
|
|
487
|
+
schedulerService.setSchedulerInstance(null);
|
|
488
|
+
schedulerService.setSchedulerVersion(null);
|
|
489
|
+
schedulerService.setSchedulerInitializationPromise(null);
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
// Disposal can begin between the isDisposing check above and the
|
|
493
|
+
// getService call. isShuttingDown is already latched true, so a
|
|
494
|
+
// throw here would abort shutdown permanently — drain, state-clear,
|
|
495
|
+
// server-stop and pool teardown would all be skipped. The reference
|
|
496
|
+
// being unclearable is the same moot case the guard above covers.
|
|
497
|
+
logger.debug('[Scheduler] Could not clear scheduler service reference during shutdown:', err);
|
|
420
498
|
}
|
|
421
499
|
}
|
|
422
500
|
|
|
@@ -9,8 +9,9 @@ const DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
9
9
|
* Firefox/Camoufox profile lock files, cross-platform. A live browser holds:
|
|
10
10
|
* - Unix: `lock` — a SYMLINK whose target encodes the owner: "<ip>:+<pid>" or "+<pid>",
|
|
11
11
|
* plus `.parentlock` (an fcntl-locked empty file).
|
|
12
|
-
* - Windows: `parent.lock` — a file kept OPEN by the live process
|
|
13
|
-
*
|
|
12
|
+
* - Windows: `parent.lock` — a file kept OPEN by the live process. That is a liveness
|
|
13
|
+
* SIGNAL (see isWindowsParentLockHeld), not a safety net: a recursive delete
|
|
14
|
+
* has already removed the rest of the profile before it reaches this file.
|
|
14
15
|
* The previous code checked for `.lock`, which Firefox/Camoufox never creates, so its
|
|
15
16
|
* lock-awareness was inert. These are the real names.
|
|
16
17
|
*/
|
|
@@ -20,6 +21,37 @@ const LOCK_FILE_NAMES = ['lock', '.parentlock', 'parent.lock'];
|
|
|
20
21
|
* so a just-starting concurrent run is not reclaimed out from under itself. */
|
|
21
22
|
const LOCK_FRESHNESS_MS = 2 * 60 * 1000;
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Windows: is `parent.lock` still held open by a live browser?
|
|
26
|
+
*
|
|
27
|
+
* Firefox opens this file with no sharing, so while the browser runs ANY other
|
|
28
|
+
* open of it fails with a sharing violation. That makes the probe positive
|
|
29
|
+
* evidence of a live owner — the same standing as the Unix `lock` symlink's PID.
|
|
30
|
+
*
|
|
31
|
+
* Recency cannot substitute for it here: `parent.lock`'s mtime is stamped once at
|
|
32
|
+
* browser startup and never refreshed, so every browser alive longer than
|
|
33
|
+
* LOCK_FRESHNESS_MS read as dead — the normal case for a minutes-long research
|
|
34
|
+
* run. Nor does "the OS refuses to delete a held-open file" save the profile:
|
|
35
|
+
* `fs.rm({recursive:true})` unlinks entries one at a time, so prefs, cookies and
|
|
36
|
+
* places are already gone by the time it reaches the locked file and fails.
|
|
37
|
+
*
|
|
38
|
+
* Returns null when the answer is not knowable, which callers treat as live —
|
|
39
|
+
* sparing a leaked profile costs disk, deleting a live one corrupts a running run.
|
|
40
|
+
*/
|
|
41
|
+
async function isWindowsParentLockHeld(lockPath: string): Promise<boolean | null> {
|
|
42
|
+
let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
handle = await fs.open(lockPath, 'r');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
47
|
+
if (code === 'ENOENT') return false; // vanished — no live owner
|
|
48
|
+
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') return true; // held open
|
|
49
|
+
return null; // unexpected — don't guess
|
|
50
|
+
}
|
|
51
|
+
await handle.close().catch(() => {});
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
/** Parse the owner PID out of a Firefox `lock` symlink target ("<ip>:+<pid>" / "+<pid>"). */
|
|
24
56
|
function parsePidFromLockTarget(target: string): number | null {
|
|
25
57
|
const m = /\+(\d+)\s*$/.exec(target);
|
|
@@ -62,9 +94,15 @@ async function isProfileLive(profileDir: string): Promise<boolean> {
|
|
|
62
94
|
// unreadable symlink — fall back to freshness below
|
|
63
95
|
}
|
|
64
96
|
}
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
97
|
+
// Windows has no `lock` symlink, so `parent.lock` is the only signal there. Probe
|
|
98
|
+
// whether it is still held open — authoritative, unlike recency, which reads every
|
|
99
|
+
// browser older than LOCK_FRESHNESS_MS as dead because the mtime is never refreshed.
|
|
100
|
+
if (process.platform === 'win32' && name === 'parent.lock') {
|
|
101
|
+
const held = await isWindowsParentLockHeld(lockPath);
|
|
102
|
+
if (held !== null) return held;
|
|
103
|
+
return true; // indeterminate — spare the profile
|
|
104
|
+
}
|
|
105
|
+
// `.parentlock` (no PID encoded) or an unreadable `lock`: use recency as the proxy.
|
|
68
106
|
return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
|
|
69
107
|
}
|
|
70
108
|
return false;
|
|
@@ -127,7 +165,7 @@ export async function cleanupStaleProfiles(
|
|
|
127
165
|
logger.debug(`[Cleanup] Skipping profile ${entry} — owned by a live browser`);
|
|
128
166
|
continue;
|
|
129
167
|
}
|
|
130
|
-
await fs.rm(fullPath, { recursive: true, force: true });
|
|
168
|
+
await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
131
169
|
removed++;
|
|
132
170
|
continue;
|
|
133
171
|
}
|
|
@@ -135,7 +173,7 @@ export async function cleanupStaleProfiles(
|
|
|
135
173
|
// Unrecognized entry (only reachable with an explicit baseDir): be conservative —
|
|
136
174
|
// only reclaim if both old AND not live, so we never delete something unknown promptly.
|
|
137
175
|
if (now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
|
|
138
|
-
await fs.rm(fullPath, { recursive: true, force: true });
|
|
176
|
+
await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
139
177
|
removed++;
|
|
140
178
|
}
|
|
141
179
|
} catch (_e) {
|
|
@@ -10,14 +10,15 @@ import * as http from 'node:http';
|
|
|
10
10
|
/**
|
|
11
11
|
* Global HTTP Agent for high-concurrency client requests
|
|
12
12
|
*
|
|
13
|
-
* IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (
|
|
14
|
-
* to prevent premature socket closure when the
|
|
13
|
+
* IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (60s,
|
|
14
|
+
* REQUEST_TIMEOUT_MS in browser-client.ts) to prevent premature socket closure when the
|
|
15
|
+
* browser is slow or the queue is deep.
|
|
15
16
|
*/
|
|
16
17
|
const clientAgent = new http.Agent({
|
|
17
18
|
keepAlive: true,
|
|
18
19
|
maxSockets: 100, // Allow up to 100 concurrent requests to the leader
|
|
19
20
|
maxFreeSockets: 10,
|
|
20
|
-
//
|
|
21
|
+
// 180s (3x the client timeout) to handle slow browser responses
|
|
21
22
|
// and prevent "socket hang up" errors during peak load
|
|
22
23
|
timeout: 180000
|
|
23
24
|
});
|
|
@@ -149,11 +149,18 @@ export class PriorityTaskQueue {
|
|
|
149
149
|
// on its own; we swallow its late settlement and free the slot now so the
|
|
150
150
|
// queue can dispatch the next task immediately.
|
|
151
151
|
let onAbort: (() => void) | undefined;
|
|
152
|
-
const fnPromise = task.fn();
|
|
153
|
-
// Prevent an unhandled rejection if abort wins the race and fn() rejects later.
|
|
154
|
-
fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
|
|
155
152
|
|
|
156
153
|
try {
|
|
154
|
+
// task.fn() is invoked INSIDE the try: a non-async fn that throws
|
|
155
|
+
// synchronously would otherwise escape before the finally that decrements
|
|
156
|
+
// activeCount, permanently inflating the active count (starving the queue
|
|
157
|
+
// by one slot each time) and leaving the caller's enqueue() promise
|
|
158
|
+
// unsettled. Every fn today is async, so this is a latent shape defect
|
|
159
|
+
// rather than a live one — but the correct placement costs nothing.
|
|
160
|
+
const fnPromise = task.fn();
|
|
161
|
+
// Prevent an unhandled rejection if abort wins the race and fn() rejects later.
|
|
162
|
+
fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
|
|
163
|
+
|
|
157
164
|
let result: any;
|
|
158
165
|
if (task.signal) {
|
|
159
166
|
const abortPromise = new Promise<never>((_, rej) => {
|
|
@@ -282,6 +282,25 @@ export async function getScheduler(config?: Config, container: ServiceContainer
|
|
|
282
282
|
let wonElection = false;
|
|
283
283
|
let winnerPort = port;
|
|
284
284
|
let winnerAuthSecret: string | undefined;
|
|
285
|
+
// Read our start-time BEFORE taking the state lock (the lookup can shell
|
|
286
|
+
// out on macOS/Windows; getCurrentProcessStartTime caches, so this is a
|
|
287
|
+
// single cheap read). Recording it alongside the PID is what makes
|
|
288
|
+
// StateManager.isPidAlive's PID-reuse guard work: that guard passes
|
|
289
|
+
// `state.browserServer.startTime` as expectedStartTime, so an election
|
|
290
|
+
// that writes only the PID leaves it permanently undefined and the guard
|
|
291
|
+
// degrades to a bare signal-0 check — which a recycled PID passes,
|
|
292
|
+
// making every follower adopt an unrelated process as the browser leader.
|
|
293
|
+
// Null on a platform where it cannot be read → the entry omits it and
|
|
294
|
+
// liveness falls back to that bare check, same as pre-existing entries.
|
|
295
|
+
const myStartTime = await (async () => {
|
|
296
|
+
try {
|
|
297
|
+
const processLifecycle = await getService<IProcessLifecycle>(ServiceNames.PROCESS_LIFECYCLE, undefined, container);
|
|
298
|
+
return await processLifecycle.getCurrentProcessStartTime();
|
|
299
|
+
} catch (err) {
|
|
300
|
+
logger.debug('[Scheduler] Could not read own process start time (PID-reuse guard degrades to signal-0):', err);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
285
304
|
try {
|
|
286
305
|
await stateManager.updateState(async (state) => {
|
|
287
306
|
if (state.browserServer) {
|
|
@@ -294,7 +313,13 @@ export async function getScheduler(config?: Config, container: ServiceContainer
|
|
|
294
313
|
}
|
|
295
314
|
}
|
|
296
315
|
const secret = getBrowserServerAuthSecret();
|
|
297
|
-
state.browserServer = {
|
|
316
|
+
state.browserServer = {
|
|
317
|
+
port,
|
|
318
|
+
pid: process.pid,
|
|
319
|
+
schedulerId,
|
|
320
|
+
authSecret: secret,
|
|
321
|
+
...(myStartTime !== null ? { startTime: myStartTime } : {}),
|
|
322
|
+
};
|
|
298
323
|
state.schedulerVersion = schedulerVersion;
|
|
299
324
|
wonElection = true;
|
|
300
325
|
return state;
|
|
@@ -17,6 +17,9 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
|
|
|
17
17
|
import type { ServiceContainer } from '../../core/service-registry.ts';
|
|
18
18
|
import { ServiceNames } from '../../core/service-interfaces.ts';
|
|
19
19
|
import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
|
|
20
|
+
import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
21
|
+
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
22
|
+
import { BrowserClient } from './browser-client.ts';
|
|
20
23
|
|
|
21
24
|
// Cooldown to prevent cascading scheduler restarts (thundering herd)
|
|
22
25
|
let lastRestartTime = 0;
|
|
@@ -62,6 +65,92 @@ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<v
|
|
|
62
65
|
);
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Is the leader currently registered in state still a live process — and is it
|
|
70
|
+
* the one our cached handle actually targets?
|
|
71
|
+
*
|
|
72
|
+
* Used only to decide whether the restart cooldown below may be honoured.
|
|
73
|
+
* Anything that prevents a confident "yes" (no registered leader, an unreadable
|
|
74
|
+
* state file, a service-resolution failure, a cached client aimed at a different
|
|
75
|
+
* port than the registered leader's) answers `false`, because the cost of an
|
|
76
|
+
* unnecessary re-resolve is one cheap round-trip while the cost of skipping a
|
|
77
|
+
* needed one is a task failure against a dead port.
|
|
78
|
+
*/
|
|
79
|
+
async function isRegisteredLeaderAliveUncached(container: ServiceContainer): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER, undefined, container);
|
|
82
|
+
const info = await stateManager.getBrowserServer();
|
|
83
|
+
if (!info) return false;
|
|
84
|
+
// "Leader alive" is not enough: the cooldown protects the CACHED handle, so
|
|
85
|
+
// that handle must also target the registered leader. If our leader B died
|
|
86
|
+
// and another process already elected C, C is alive — but our cached client
|
|
87
|
+
// still aims at B's dead port, and honouring the cooldown would leave every
|
|
88
|
+
// retry failing against the corpse until the window expired.
|
|
89
|
+
const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, container);
|
|
90
|
+
const cached = schedulerService.getSchedulerInstance();
|
|
91
|
+
if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
|
|
92
|
+
return await stateManager.isPidAlive(info.pid, info.schedulerId);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Coalesce concurrent liveness probes.
|
|
100
|
+
*
|
|
101
|
+
* This runs on the failure path, where a whole query burst fails at once: a
|
|
102
|
+
* 20-query search losing its leader calls it up to 19 times within milliseconds
|
|
103
|
+
* (the first caller restarts and the rest land inside the cooldown). The probe is
|
|
104
|
+
* cheap on Linux — a `/proc/<pid>/stat` read — but on Windows the PID-reuse guard
|
|
105
|
+
* resolves the process start time by spawning `powershell -NoProfile`, so an
|
|
106
|
+
* uncoalesced burst would fan out into ~19 concurrent PowerShell processes on the
|
|
107
|
+
* slowest platform we support. The window is deliberately far shorter than
|
|
108
|
+
* RESTART_COOLDOWN_MS: it exists to collapse one burst, not to cache a verdict.
|
|
109
|
+
*/
|
|
110
|
+
const LEADER_LIVENESS_COALESCE_MS = 1000;
|
|
111
|
+
let leaderLivenessProbe: { startedAt: number; result: Promise<boolean> } | null = null;
|
|
112
|
+
|
|
113
|
+
async function isRegisteredLeaderAlive(container: ServiceContainer): Promise<boolean> {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
|
|
116
|
+
return leaderLivenessProbe.result;
|
|
117
|
+
}
|
|
118
|
+
const result = isRegisteredLeaderAliveUncached(container);
|
|
119
|
+
leaderLivenessProbe = { startedAt: now, result };
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Test seam: drop the coalesced liveness probe. */
|
|
124
|
+
export function _resetLeaderLivenessProbeForTests(): void {
|
|
125
|
+
leaderLivenessProbe = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Drop the scheduler handle and wait for the pool to settle, subject to a
|
|
130
|
+
* cooldown that stops N concurrent task failures from each restarting.
|
|
131
|
+
*
|
|
132
|
+
* The cooldown is only safe while the registered leader is still alive: then the
|
|
133
|
+
* cached handle points at a working process and skipping the restart costs
|
|
134
|
+
* nothing. It is NOT safe when the leader is gone. A hard leader kill (SIGKILL,
|
|
135
|
+
* OOM) surfaces to clients as ECONNREFUSED, which `isTransientSocketError`
|
|
136
|
+
* matches but `isPoolShutdownError` does not — so it lands here rather than on
|
|
137
|
+
* the handover path, and a bare wait leaves the cached client aimed at a corpse.
|
|
138
|
+
* The retry then re-resolves to the same dead port and fails identically: the
|
|
139
|
+
* exact stale-handle failure the leader-handover work fixed for the *graceful*
|
|
140
|
+
* shutdown case. So the cooldown yields whenever the leader is not confirmed alive.
|
|
141
|
+
*/
|
|
142
|
+
async function restartSchedulerWithHerdGuard(container: ServiceContainer): Promise<void> {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS || !(await isRegisteredLeaderAlive(container))) {
|
|
145
|
+
lastRestartTime = now;
|
|
146
|
+
logger.warn('[BrowserManager] Forcing scheduler restart and retrying...');
|
|
147
|
+
await forceSchedulerRestart(false, container);
|
|
148
|
+
} else {
|
|
149
|
+
logger.warn('[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive — waiting for pool idle instead...');
|
|
150
|
+
}
|
|
151
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
152
|
+
}
|
|
153
|
+
|
|
65
154
|
/**
|
|
66
155
|
* Get the scheduler from the factory, optionally using a specific container.
|
|
67
156
|
*/
|
|
@@ -163,16 +252,7 @@ export async function runBrowserTask<T>(
|
|
|
163
252
|
// PID+start-time liveness probe confirms the registered leader is
|
|
164
253
|
// actually gone; it only skips clearing when that probe finds the
|
|
165
254
|
// leader alive, which is exactly the case we must not disturb.
|
|
166
|
-
|
|
167
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
168
|
-
lastRestartTime = now;
|
|
169
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
170
|
-
await forceSchedulerRestart(false, container);
|
|
171
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
172
|
-
} else {
|
|
173
|
-
logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
|
|
174
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
175
|
-
}
|
|
255
|
+
await restartSchedulerWithHerdGuard(container);
|
|
176
256
|
}
|
|
177
257
|
|
|
178
258
|
// Retry with jitter (100-500ms) to prevent thundering herd
|
|
@@ -211,15 +291,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
211
291
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
212
292
|
// transient socket error — let the liveness probe inside
|
|
213
293
|
// forceSchedulerRestart decide.
|
|
214
|
-
|
|
215
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
216
|
-
lastRestartTime = now;
|
|
217
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
218
|
-
await forceSchedulerRestart(false, container);
|
|
219
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
220
|
-
} else {
|
|
221
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
222
|
-
}
|
|
294
|
+
await restartSchedulerWithHerdGuard(container);
|
|
223
295
|
}
|
|
224
296
|
|
|
225
297
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
@@ -278,15 +350,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
278
350
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
279
351
|
// transient socket error — let the liveness probe inside
|
|
280
352
|
// forceSchedulerRestart decide.
|
|
281
|
-
|
|
282
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
283
|
-
lastRestartTime = now;
|
|
284
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
285
|
-
await forceSchedulerRestart(false, container);
|
|
286
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
287
|
-
} else {
|
|
288
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
289
|
-
}
|
|
353
|
+
await restartSchedulerWithHerdGuard(container);
|
|
290
354
|
}
|
|
291
355
|
|
|
292
356
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
@@ -206,14 +206,60 @@ export function setupOrphanProtection(): void {
|
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
// Capture the parent PID ONCE, at setup. `process.ppid` is re-read from the OS
|
|
210
|
+
// on every access, so on POSIX it changes the moment we are orphaned — and that
|
|
211
|
+
// change, not a liveness probe, is the reliable signal (see checkOrphan).
|
|
212
|
+
const originalPpid = process.ppid;
|
|
213
|
+
if (!originalPpid) {
|
|
210
214
|
return;
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/** Tear down the browser and exit. Shared by both orphan signals below. */
|
|
218
|
+
const exitAsOrphan = async () => {
|
|
219
|
+
// Await cleanup to prevent browser/context leaks — but bounded. The browser is
|
|
220
|
+
// the plausible thing to be wedged after the parent died abruptly, and an
|
|
221
|
+
// unbounded browser.close() hang here would keep the orphan (and its browser)
|
|
222
|
+
// alive forever: the precise leak this guard exists to stop. The exit deadline
|
|
223
|
+
// wins over a perfect teardown.
|
|
224
|
+
if (cleanupBrowser) {
|
|
225
|
+
await Promise.race([
|
|
226
|
+
cleanupBrowser().catch(err => {
|
|
227
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
|
|
228
|
+
}),
|
|
229
|
+
new Promise<void>(resolve => {
|
|
230
|
+
const t = setTimeout(resolve, 10_000);
|
|
231
|
+
if (t.unref) t.unref();
|
|
232
|
+
}),
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
// Clear the orphan check timer
|
|
236
|
+
cleanupOrphanProtection();
|
|
237
|
+
process.env['PI_PROCESS_EXITING'] = '1';
|
|
238
|
+
process.exit(1);
|
|
239
|
+
};
|
|
240
|
+
|
|
213
241
|
const checkOrphan = async () => {
|
|
214
242
|
try {
|
|
215
|
-
// signal
|
|
216
|
-
|
|
243
|
+
// Re-parenting is the authoritative orphan signal on POSIX. When the parent
|
|
244
|
+
// dies the kernel immediately re-parents us — to PID 1, or to the nearest
|
|
245
|
+
// subreaper (a user systemd instance, a container init). Probing the CURRENT
|
|
246
|
+
// ppid cannot see that: PID 1 always exists, and signalling it from an
|
|
247
|
+
// unprivileged process raises EPERM, which the handler below correctly reads
|
|
248
|
+
// as "alive but not ours to signal". Net effect on Linux was that this guard
|
|
249
|
+
// never fired at all and orphaned workers kept their browsers running.
|
|
250
|
+
// Comparing against the PID captured at setup detects it directly.
|
|
251
|
+
// Windows does not re-parent, so the liveness probe below remains the
|
|
252
|
+
// mechanism there and this check simply never trips.
|
|
253
|
+
if (process.ppid !== originalPpid) {
|
|
254
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process.ppid}): parent died, shutting down...`);
|
|
255
|
+
await exitAsOrphan();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// signal 0 checks if the process is alive. Probe the ORIGINAL ppid, never
|
|
260
|
+
// `process.ppid` — after a re-parent the latter points at init, not at the
|
|
261
|
+
// process whose liveness we actually care about.
|
|
262
|
+
process.kill(originalPpid, 0);
|
|
217
263
|
|
|
218
264
|
// Schedule next check
|
|
219
265
|
orphanCheckTimer = setTimeout(checkOrphan, 10000);
|
|
@@ -231,16 +277,7 @@ export function setupOrphanProtection(): void {
|
|
|
231
277
|
}
|
|
232
278
|
// If error is thrown, the parent process is likely dead or unreachable
|
|
233
279
|
logToDebugFile('WARN', `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
|
|
234
|
-
|
|
235
|
-
if (cleanupBrowser) {
|
|
236
|
-
await cleanupBrowser().catch(err => {
|
|
237
|
-
logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
// Clear the orphan check timer
|
|
241
|
-
cleanupOrphanProtection();
|
|
242
|
-
process.env['PI_PROCESS_EXITING'] = '1';
|
|
243
|
-
process.exit(1);
|
|
280
|
+
await exitAsOrphan();
|
|
244
281
|
}
|
|
245
282
|
};
|
|
246
283
|
|