@lincoln504/pi-research 0.1.12 → 1.0.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/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poolifier Worker
|
|
3
|
+
*
|
|
4
|
+
* Executes search and scrape tasks in worker processes using Camoufox.
|
|
5
|
+
*
|
|
6
|
+
* This module coordinates the worker lifecycle, browser management,
|
|
7
|
+
* and task execution using extracted sub-modules.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ClusterWorker } from 'poolifier';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
|
|
14
|
+
// Import extracted modules
|
|
15
|
+
import {
|
|
16
|
+
setWorkerId as setLifecycleWorkerId,
|
|
17
|
+
setupIpcErrorHandler,
|
|
18
|
+
setupUncaughtExceptionHandler,
|
|
19
|
+
setupOrphanProtection,
|
|
20
|
+
createKillHandler,
|
|
21
|
+
setBrowserCleanup,
|
|
22
|
+
} from './thread-worker-lifecycle.ts';
|
|
23
|
+
import {
|
|
24
|
+
setWorkerId,
|
|
25
|
+
initBrowser,
|
|
26
|
+
getContext,
|
|
27
|
+
resetBrowser,
|
|
28
|
+
cleanupBrowser,
|
|
29
|
+
} from './thread-worker-browser.ts';
|
|
30
|
+
import {
|
|
31
|
+
setWorkerId as setMessagingWorkerId,
|
|
32
|
+
executeSearchTask,
|
|
33
|
+
executeScrapeTask,
|
|
34
|
+
executeHealthCheck,
|
|
35
|
+
shouldResetBrowser,
|
|
36
|
+
} from './thread-worker-messaging.ts';
|
|
37
|
+
import type { TaskData, TaskResult } from './thread-worker-types.ts';
|
|
38
|
+
|
|
39
|
+
// Generate a random ID for this worker process to track distribution in logs
|
|
40
|
+
const workerId = crypto.randomBytes(2).toString('hex');
|
|
41
|
+
|
|
42
|
+
// Set worker ID in all modules
|
|
43
|
+
setLifecycleWorkerId(workerId);
|
|
44
|
+
setWorkerId(workerId);
|
|
45
|
+
setMessagingWorkerId(workerId);
|
|
46
|
+
|
|
47
|
+
// Setup IPC error handler
|
|
48
|
+
setupIpcErrorHandler();
|
|
49
|
+
|
|
50
|
+
// Setup uncaught exception handler
|
|
51
|
+
setupUncaughtExceptionHandler();
|
|
52
|
+
|
|
53
|
+
// Setup browser cleanup callback for lifecycle module
|
|
54
|
+
setBrowserCleanup(cleanupBrowser);
|
|
55
|
+
|
|
56
|
+
// Setup orphaned worker protection
|
|
57
|
+
if (process.env['GITHUB_ACTIONS'] !== 'true') {
|
|
58
|
+
setupOrphanProtection();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// When both search and scrape are mocked, skip Firefox entirely in task handlers
|
|
62
|
+
// and in the eager warm-up below. Firefox startup in cluster workers takes 60-90s
|
|
63
|
+
// on constrained CI runners; mocked tasks don't need a real browser at all.
|
|
64
|
+
const FULL_MOCK_MODE =
|
|
65
|
+
process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
|
|
66
|
+
process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Main task execution function
|
|
70
|
+
*/
|
|
71
|
+
async function runTask(data: TaskData | undefined): Promise<TaskResult> {
|
|
72
|
+
if (!data) {
|
|
73
|
+
return { error: 'No task data provided', duration: 0 };
|
|
74
|
+
}
|
|
75
|
+
const { type, query, url, queuedAt: _queuedAt, taskTimeoutMs } = data;
|
|
76
|
+
const startTime = Date.now();
|
|
77
|
+
|
|
78
|
+
if (FULL_MOCK_MODE) {
|
|
79
|
+
if (type === 'search') {
|
|
80
|
+
return {
|
|
81
|
+
results: [{ title: 'Mock Result', url: 'https://example.com/mock', content: `Mock search result for: ${query ?? ''}` }],
|
|
82
|
+
duration: Date.now() - startTime,
|
|
83
|
+
jitter: 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (type === 'scrape') {
|
|
87
|
+
return {
|
|
88
|
+
contentType: 'text/html',
|
|
89
|
+
html: `<html><body><p>Mock content for ${url ?? ''}</p></body></html>`,
|
|
90
|
+
duration: Date.now() - startTime,
|
|
91
|
+
jitter: 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (type === 'healthcheck') {
|
|
95
|
+
return { success: true, navMs: 0, duration: Date.now() - startTime };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const abortController = new AbortController();
|
|
100
|
+
let taskTimer: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
if (taskTimeoutMs && taskTimeoutMs > 0) {
|
|
102
|
+
taskTimer = setTimeout(() => abortController.abort(new Error(`Task timed out after ${taskTimeoutMs}ms`)), taskTimeoutMs);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await initBrowser();
|
|
107
|
+
const initMs = Date.now() - startTime;
|
|
108
|
+
|
|
109
|
+
let result: any;
|
|
110
|
+
if (type === 'search') {
|
|
111
|
+
if (!query) throw new Error('Search task requires a query');
|
|
112
|
+
const searchResult = await executeSearchTask(getContext(), query);
|
|
113
|
+
result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
|
|
114
|
+
} else if (type === 'scrape') {
|
|
115
|
+
if (!url) throw new Error('Scrape task requires a URL');
|
|
116
|
+
const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
|
|
117
|
+
result = { ...scrapeResult, duration: Date.now() - startTime };
|
|
118
|
+
} else if (type === 'healthcheck') {
|
|
119
|
+
const healthResult = await executeHealthCheck(getContext(), initMs);
|
|
120
|
+
result = { ...healthResult, duration: Date.now() - startTime };
|
|
121
|
+
} else {
|
|
122
|
+
result = { error: 'Unknown task type', duration: Date.now() - startTime };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (abortController.signal.aborted) {
|
|
126
|
+
return { error: `Task timed out after ${taskTimeoutMs}ms`, duration: Date.now() - startTime };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
} catch (error: unknown) {
|
|
131
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
132
|
+
|
|
133
|
+
// A deliberate task-timeout abort closes the page, so the in-flight op rejects with
|
|
134
|
+
// 'Target closed' — which shouldResetBrowser() would otherwise read as a browser
|
|
135
|
+
// crash and needlessly tear down the whole (healthy) browser. Report the timeout and
|
|
136
|
+
// leave the browser intact.
|
|
137
|
+
if (abortController.signal.aborted) {
|
|
138
|
+
return { error: `Task timed out after ${taskTimeoutMs}ms`, duration: Date.now() - startTime };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// If the browser crashed or disconnected, clear the instance to force re-initialization on next task
|
|
142
|
+
if (shouldResetBrowser(errMsg)) {
|
|
143
|
+
resetBrowser();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
error: errMsg,
|
|
148
|
+
duration: Date.now() - startTime
|
|
149
|
+
};
|
|
150
|
+
} finally {
|
|
151
|
+
if (taskTimer !== undefined) clearTimeout(taskTimer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const worker = new ClusterWorker(runTask, {
|
|
156
|
+
killHandler: createKillHandler(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export default worker;
|
|
160
|
+
|
|
161
|
+
// DEFERRED: Eagerly warming the browser when the worker starts is disabled
|
|
162
|
+
// to prevent "thundering herd" resource spikes. Instead, the browser is
|
|
163
|
+
// initialized lazily upon receipt of the first task in runTask().
|
|
164
|
+
/*
|
|
165
|
+
if (!FULL_MOCK_MODE) {
|
|
166
|
+
initBrowser().catch(() => {});
|
|
167
|
+
}
|
|
168
|
+
*/
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Pool Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of the worker pool for browser operations.
|
|
5
|
+
* Extracted from BrowserTaskScheduler for better separation of concerns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { FixedClusterPool, WorkerChoiceStrategies } from 'poolifier';
|
|
12
|
+
import { logger } from '../../logger.ts';
|
|
13
|
+
import { metrics } from '../../utils/metrics.ts';
|
|
14
|
+
import type { Config } from '../../config.ts';
|
|
15
|
+
import { getConfig } from '../../config.ts';
|
|
16
|
+
import { ensureBrowserCacheDir, getBrowserEnv, getBrowserProfileDir, getMaxWorkers } from './config.ts';
|
|
17
|
+
import { ensureBrowserInstalled } from './ensure-browser.ts';
|
|
18
|
+
import { cleanupStaleProfiles } from './cleanup-utils.ts';
|
|
19
|
+
import { cleanupOrphanedCamoufoxProcesses } from './browser-cleanup.ts';
|
|
20
|
+
import { setupMasterIpcErrorHandler, isBenignClusterIpcError } from './thread-worker-lifecycle.ts';
|
|
21
|
+
import { ServiceLifecycle, type IService } from '../../core/service-registry.ts';
|
|
22
|
+
import { raceWithDeadline } from '../../utils/safe-unref.ts';
|
|
23
|
+
import { ServiceNames } from '../../core/interfaces/service-names.ts';
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = dirname(__filename);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Worker pool manager for browser operations.
|
|
30
|
+
*/
|
|
31
|
+
export class WorkerPoolManager implements IService {
|
|
32
|
+
readonly name = ServiceNames.WORKER_POOL_MANAGER;
|
|
33
|
+
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
34
|
+
|
|
35
|
+
private pool: any | null = null;
|
|
36
|
+
private poolInitializationPromise: Promise<any> | null = null;
|
|
37
|
+
private currentWorkerCount: number | null = null;
|
|
38
|
+
private consecutiveErrors: number = 0;
|
|
39
|
+
private isShuttingDown: boolean = false;
|
|
40
|
+
// Monotonic shutdown generation. Each shutdown() bumps it, so an in-flight
|
|
41
|
+
// ensurePool() that started before the shutdown can detect it even after
|
|
42
|
+
// isShuttingDown has been reset to false (shutdown resets it so the instance
|
|
43
|
+
// is reusable). A bare boolean can't distinguish "no shutdown" from "a
|
|
44
|
+
// shutdown already started AND finished while I was building the pool" —
|
|
45
|
+
// exactly the window that let a freshly-built pool survive un-referenced.
|
|
46
|
+
private generation: number = 0;
|
|
47
|
+
// Monotonic per-pool-INSTANCE token. Bumped every time a new FixedClusterPool is
|
|
48
|
+
// constructed (including a same-generation worker-count recreation, which `generation`
|
|
49
|
+
// does NOT track). Each pool's error/exit handlers capture the epoch they belong to and
|
|
50
|
+
// ignore events once it is superseded, so a trailing error from a destroyed pool's dying
|
|
51
|
+
// worker cannot inflate the live pool's health counter and trip a spurious auto-recovery.
|
|
52
|
+
private poolEpoch: number = 0;
|
|
53
|
+
// FIX (#12): Flag to indicate a pool reset is in progress
|
|
54
|
+
private _resetInProgress: boolean = false;
|
|
55
|
+
// Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
|
|
56
|
+
// reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
|
|
57
|
+
private _resetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly onPoolError?: (error: Error, consecutiveErrors: number) => void
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensure the pool is initialized with the current config.
|
|
65
|
+
* Recreates the pool if the worker count has changed.
|
|
66
|
+
*/
|
|
67
|
+
async ensurePool(config?: Config): Promise<any> {
|
|
68
|
+
const maxWorkers = getMaxWorkers(config);
|
|
69
|
+
|
|
70
|
+
// Fast-fail if a shutdown is in progress — the caller should retry after
|
|
71
|
+
// the shutdown completes (isShuttingDown is reset to false on completion).
|
|
72
|
+
// This check must come BEFORE the cached-pool fast-path below because
|
|
73
|
+
// pool.destroy() may be in flight while this.pool is still non-null.
|
|
74
|
+
// Returning a mid-destroy pool would cause poolifier to throw
|
|
75
|
+
// "Cannot execute a task on destroying pool".
|
|
76
|
+
if (this.isShuttingDown) {
|
|
77
|
+
throw new Error('Worker pool is shutting down');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// FIX (#12): If a pool reset is in progress, wait briefly and retry
|
|
81
|
+
// instead of creating a duplicate pool.
|
|
82
|
+
if (this._resetInProgress) {
|
|
83
|
+
const maxWait = 3000;
|
|
84
|
+
const interval = 100;
|
|
85
|
+
const deadline = Date.now() + maxWait;
|
|
86
|
+
while (this._resetInProgress && Date.now() < deadline) {
|
|
87
|
+
await new Promise(r => setTimeout(r, interval));
|
|
88
|
+
}
|
|
89
|
+
if (this._resetInProgress) {
|
|
90
|
+
throw new Error('Worker pool is being reset, please retry');
|
|
91
|
+
}
|
|
92
|
+
// Reset complete — recurse once to get the fresh pool
|
|
93
|
+
return this.ensurePool(config);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If pool exists, worker count matches, and we are not shutting down,
|
|
97
|
+
// return the existing pool immediately.
|
|
98
|
+
if (this.pool && this.currentWorkerCount === maxWorkers) {
|
|
99
|
+
return this.pool;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Use a promise to coalesce concurrent initialization calls
|
|
103
|
+
if (this.poolInitializationPromise) {
|
|
104
|
+
return this.poolInitializationPromise;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Snapshot the generation this init belongs to. If a shutdown bumps it
|
|
108
|
+
// while we await below (ensureBrowserInstalled can be slow), the checks
|
|
109
|
+
// after pool construction tear the new pool down instead of leaking it.
|
|
110
|
+
const myGeneration = this.generation;
|
|
111
|
+
this.poolInitializationPromise = (async () => {
|
|
112
|
+
try {
|
|
113
|
+
if (this.pool && this.currentWorkerCount !== maxWorkers) {
|
|
114
|
+
logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
|
|
115
|
+
await this.pool.destroy();
|
|
116
|
+
this.pool = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.currentWorkerCount = maxWorkers;
|
|
120
|
+
|
|
121
|
+
// Install the master-side IPC guard BEFORE any worker is forked, so a
|
|
122
|
+
// `write EPIPE` on a closing worker channel (poolifier dispatch racing a
|
|
123
|
+
// 429-driven respawn) is swallowed instead of crashing the pi host via an
|
|
124
|
+
// unhandled cluster.Worker 'error'. Idempotent — only installs once.
|
|
125
|
+
setupMasterIpcErrorHandler();
|
|
126
|
+
|
|
127
|
+
logger.log(`[WorkerPoolManager] Initializing Unified FixedClusterPool (Size: ${maxWorkers}) on PID ${process.pid}`);
|
|
128
|
+
|
|
129
|
+
ensureBrowserCacheDir();
|
|
130
|
+
|
|
131
|
+
// Provision the camoufox browser if it is missing. This is a no-op
|
|
132
|
+
// (cheap existsSync) when the browser is already installed — i.e. on
|
|
133
|
+
// every install path whose postinstall ran (pi extension, plain npm).
|
|
134
|
+
// It only does work on install flows that skip postinstall (e.g.
|
|
135
|
+
// `npm install --ignore-scripts`), where the binary would
|
|
136
|
+
// otherwise be absent and the first scrape would fail.
|
|
137
|
+
await ensureBrowserInstalled();
|
|
138
|
+
|
|
139
|
+
const browserEnv = getBrowserEnv(config);
|
|
140
|
+
|
|
141
|
+
// Reclaim browser profiles leaked by a previously crashed run
|
|
142
|
+
// (Playwright self-cleans on a normal browser.close(); only an
|
|
143
|
+
// abnormal exit leaves them behind — and on a tmpfs /tmp that is
|
|
144
|
+
// leaked RAM). Lock-aware, so profiles in use by this or a
|
|
145
|
+
// concurrent instance are spared. Fire-and-forget: never block
|
|
146
|
+
// or fail pool startup. Sweeps the dedicated profile dir plus
|
|
147
|
+
// the legacy os.tmpdir() location for pre-upgrade leftovers.
|
|
148
|
+
const STALE_PROFILE_MS = 60 * 60 * 1000; // 1 hour
|
|
149
|
+
void cleanupStaleProfiles(getBrowserProfileDir(config), STALE_PROFILE_MS)
|
|
150
|
+
.catch((e) => logger.debug('[WorkerPoolManager] Profile sweep (profile dir) failed:', e));
|
|
151
|
+
void cleanupStaleProfiles(undefined, STALE_PROFILE_MS)
|
|
152
|
+
.catch((e) => logger.debug('[WorkerPoolManager] Profile sweep (legacy tmp) failed:', e));
|
|
153
|
+
|
|
154
|
+
// SIGKILL backstop: reap Camoufox processes orphaned by a PRIOR run that
|
|
155
|
+
// died abruptly (kill -9, crash) before its graceful shutdown could fire.
|
|
156
|
+
// The sweep is pi-research-specific (matches our camoufox-bin / profile
|
|
157
|
+
// marker, never the user's system Firefox) and orphan-aware (only kills
|
|
158
|
+
// processes whose parent is dead), so camoufox owned by THIS or a concurrent
|
|
159
|
+
// live instance is spared. Fire-and-forget; never block pool startup.
|
|
160
|
+
void cleanupOrphanedCamoufoxProcesses()
|
|
161
|
+
.catch((e) => logger.debug('[WorkerPoolManager] Startup orphan sweep failed:', e));
|
|
162
|
+
|
|
163
|
+
const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
|
|
164
|
+
|
|
165
|
+
// thread-worker.mjs is the esbuild-compiled bundle of thread-worker.ts and its
|
|
166
|
+
// local imports. Using a pre-compiled JS file eliminates any TypeScript loading
|
|
167
|
+
// concern in cluster child processes — no execArgv loader flags needed.
|
|
168
|
+
const workerPath = join(__dirname, './thread-worker.mjs');
|
|
169
|
+
|
|
170
|
+
// Fail loud and actionable if the worker bundle is missing. It is built by the
|
|
171
|
+
// `prepare` lifecycle (scripts/build.cjs), not committed to git, so a clone that
|
|
172
|
+
// skipped install scripts (or a partial install) leaves it absent. Without this
|
|
173
|
+
// guard poolifier throws an opaque ERR_MODULE_NOT_FOUND for the worker FILE and
|
|
174
|
+
// then respawns workers forever — the exact crash-loop that masks the real cause.
|
|
175
|
+
if (!existsSync(workerPath)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Browser worker bundle is missing at ${workerPath}. ` +
|
|
178
|
+
`It is produced by the build step; reinstall the extension (e.g. \`pi update --extensions\`) ` +
|
|
179
|
+
`or run \`npm install\` in the extension directory so the prepare/build script regenerates it.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Identity token for THIS pool instance. The handlers below compare it against
|
|
184
|
+
// the live `this.poolEpoch` so a superseded pool's trailing events are ignored.
|
|
185
|
+
const myEpoch = ++this.poolEpoch;
|
|
186
|
+
this.pool = new FixedClusterPool(maxWorkers, workerPath, {
|
|
187
|
+
env: browserEnv,
|
|
188
|
+
// Prevent query leakage via process.argv in forked workers
|
|
189
|
+
workerOptions: { execArgv: [] },
|
|
190
|
+
errorHandler: (e: Error) => {
|
|
191
|
+
// Ignore events from a pool that has since been replaced/destroyed — a
|
|
192
|
+
// trailing error from an old pool's dying worker must not affect the live one.
|
|
193
|
+
if (this.poolEpoch !== myEpoch) return;
|
|
194
|
+
// A benign IPC teardown error (EPIPE/ECONNRESET/channel-closed) from a worker
|
|
195
|
+
// that is shutting down is not a health signal; counting it toward the
|
|
196
|
+
// consecutive-error threshold can trip auto-recovery on a healthy pool.
|
|
197
|
+
if (isBenignClusterIpcError(e)) {
|
|
198
|
+
logger.debug('[WorkerPoolManager] Ignoring benign cluster IPC error:', e);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.consecutiveErrors++;
|
|
202
|
+
metrics.increment('browser_pool_errors_total', 1);
|
|
203
|
+
logger.error('[WorkerPoolManager] Cluster Error:', e);
|
|
204
|
+
if (this.consecutiveErrors >= 3) {
|
|
205
|
+
metrics.increment('browser_pool_unhealthy_events_total', 1);
|
|
206
|
+
logger.error(`[WorkerPoolManager] Worker pool may be unhealthy: ${this.consecutiveErrors} consecutive errors. Consider restarting.`);
|
|
207
|
+
if (this.onPoolError) {
|
|
208
|
+
this.onPoolError(e, this.consecutiveErrors);
|
|
209
|
+
}
|
|
210
|
+
this.schedulePoolReset();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
exitHandler: (code: number) => {
|
|
214
|
+
// null exit code means the worker was killed intentionally (e.g. pool.destroy()).
|
|
215
|
+
// Ignore exits from a superseded pool instance (see errorHandler).
|
|
216
|
+
if (code !== 0 && code !== null && this.poolEpoch === myEpoch) {
|
|
217
|
+
logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
|
|
218
|
+
this.consecutiveErrors++;
|
|
219
|
+
if (this.consecutiveErrors >= 3) {
|
|
220
|
+
logger.error(`[WorkerPoolManager] Worker pool unhealthy due to 3 consecutive exits. Scheduling auto-recovery...`);
|
|
221
|
+
if (this.onPoolError) {
|
|
222
|
+
this.onPoolError(new Error(`Worker exited with code ${code}`), this.consecutiveErrors);
|
|
223
|
+
}
|
|
224
|
+
this.schedulePoolReset();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
|
|
229
|
+
enableTasksQueue: true,
|
|
230
|
+
tasksQueueOptions: {
|
|
231
|
+
concurrency: workerConcurrency, // configurable via PI_RESEARCH_WORKER_CONCURRENCY
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Secondary race check: if a shutdown() was called concurrent with
|
|
236
|
+
// the async pool construction above (between the early check and
|
|
237
|
+
// here). Checking the generation — not just isShuttingDown — catches
|
|
238
|
+
// a shutdown that has already STARTED AND FINISHED during our await
|
|
239
|
+
// (which resets isShuttingDown back to false). shutdown() also awaits
|
|
240
|
+
// this promise, so whichever side runs the destroy, the pool never
|
|
241
|
+
// survives un-referenced.
|
|
242
|
+
if (this.isShuttingDown || this.generation !== myGeneration) {
|
|
243
|
+
logger.warn('[WorkerPoolManager] Pool initialized but a shutdown intervened. Destroying...');
|
|
244
|
+
const built = this.pool;
|
|
245
|
+
// Only null the shared field if it still points at the pool we built —
|
|
246
|
+
// a concurrent path may already have moved on.
|
|
247
|
+
if (this.pool === built) this.pool = null;
|
|
248
|
+
await built.destroy().catch((err: any) => logger.debug('Swallowed pool destroy error:', err));
|
|
249
|
+
throw new Error('Worker pool is shutting down');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
metrics.setGauge('browser_pool_workers', maxWorkers);
|
|
253
|
+
metrics.increment('browser_pool_initializations_total', 1, { success: 'true' });
|
|
254
|
+
|
|
255
|
+
if (this.lifecycle === ServiceLifecycle.UNINITIALIZED) {
|
|
256
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Clear the coalescing promise on success too (not only on failure).
|
|
260
|
+
// Leaving a resolved promise here means a later ensurePool() call with a
|
|
261
|
+
// CHANGED worker count — which skips the line-79 fast path — would return
|
|
262
|
+
// this stale promise and hand back the OLD pool, silently ignoring the new
|
|
263
|
+
// count. Concurrent callers already hold their own reference to this promise,
|
|
264
|
+
// so clearing it is safe.
|
|
265
|
+
this.poolInitializationPromise = null;
|
|
266
|
+
|
|
267
|
+
return this.pool;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
metrics.increment('browser_pool_initializations_total', 1, { success: 'false' });
|
|
270
|
+
// Clean up the promise ONLY on failure, so next caller can retry.
|
|
271
|
+
// On success, we keep it so concurrent callers get the same resolution.
|
|
272
|
+
this.poolInitializationPromise = null;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
|
|
277
|
+
return this.poolInitializationPromise;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get the current pool instance.
|
|
282
|
+
*/
|
|
283
|
+
getPool(): any | null {
|
|
284
|
+
return this.pool;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reset consecutive errors counter.
|
|
289
|
+
*/
|
|
290
|
+
resetConsecutiveErrors(): void {
|
|
291
|
+
this.consecutiveErrors = 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Decay the consecutive-error counter by one. Called on each healthy leadership
|
|
296
|
+
* tick so a single transient blip fades over time WITHOUT fully zeroing the
|
|
297
|
+
* counter. Blind-resetting to zero every tick (the previous behavior) defeated
|
|
298
|
+
* auto-recovery for any crash-loop slower than the threshold-per-tick window: a
|
|
299
|
+
* worker dying once every few seconds never reached `consecutiveErrors >= 3`
|
|
300
|
+
* because the ~5 s leadership tick kept resetting it. Decay lets a sustained slow
|
|
301
|
+
* failure accumulate to the reset threshold while still forgiving isolated blips.
|
|
302
|
+
*/
|
|
303
|
+
decayConsecutiveErrors(): void {
|
|
304
|
+
if (this.consecutiveErrors > 0) this.consecutiveErrors--;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Schedule an out-of-band pool reset. Called from poolifier event handlers
|
|
309
|
+
* where destroying the pool synchronously would deadlock. Waits 1 s so the
|
|
310
|
+
* current event-handler call-stack unwinds before the pool is destroyed.
|
|
311
|
+
*/
|
|
312
|
+
private schedulePoolReset(): void {
|
|
313
|
+
if (this.isShuttingDown) return;
|
|
314
|
+
// Single-shot: poolifier fires BOTH errorHandler and exitHandler for the same
|
|
315
|
+
// pool failure, and each call would overwrite this._resetTimer — orphaning the
|
|
316
|
+
// first timer so shutdown() can no longer clear it (it then fires late and
|
|
317
|
+
// re-destroys an already-dead pool or races a rebuilt one). Bail if a reset is
|
|
318
|
+
// already pending; the flag is cleared once the reset completes.
|
|
319
|
+
if (this._resetInProgress) return;
|
|
320
|
+
const deadPool = this.pool;
|
|
321
|
+
// FIX (#12): Don't nullify pool immediately — mark it as dead but keep the
|
|
322
|
+
// reference so ensurePool() doesn't create a duplicate while the old one
|
|
323
|
+
// is still being destroyed. Instead, use a flag to signal reset is in progress.
|
|
324
|
+
this._resetInProgress = true;
|
|
325
|
+
this.currentWorkerCount = null;
|
|
326
|
+
this.consecutiveErrors = 0;
|
|
327
|
+
// Pin the generation this reset belongs to. shutdown() bumps generation, so if a
|
|
328
|
+
// shutdown (and a subsequent re-init) happens while this timer is pending, the
|
|
329
|
+
// generation no longer matches and the timer must NOT touch the new pool's state.
|
|
330
|
+
const myGen = this.generation;
|
|
331
|
+
metrics.increment('browser_pool_auto_recoveries_total', 1);
|
|
332
|
+
logger.info('[WorkerPoolManager] Pool scheduled for auto-recovery; next ensurePool() will wait for old pool destruction.');
|
|
333
|
+
// Destroy the old pool asynchronously after the event handler returns.
|
|
334
|
+
const t = setTimeout(async () => {
|
|
335
|
+
try {
|
|
336
|
+
// If shutdown() ran while this timer was pending it already destroyed the
|
|
337
|
+
// pool (and bumped the generation), so skip — destroying again is spurious
|
|
338
|
+
// and nulling shared state would clobber a pool rebuilt after the shutdown.
|
|
339
|
+
if (this.isShuttingDown || this.generation !== myGen) return;
|
|
340
|
+
if (deadPool) await deadPool.destroy();
|
|
341
|
+
logger.info('[WorkerPoolManager] Auto-recovery: old pool destroyed.');
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logger.warn('[WorkerPoolManager] Auto-recovery: error destroying old pool:', err);
|
|
344
|
+
} finally {
|
|
345
|
+
this._resetTimer = null;
|
|
346
|
+
// Only clear shared state if we still own this generation (re-check: the
|
|
347
|
+
// await above could have straddled a shutdown).
|
|
348
|
+
if (!this.isShuttingDown && this.generation === myGen) {
|
|
349
|
+
// Now nullify the dead pool and clear the flag
|
|
350
|
+
if (this.pool === deadPool) this.pool = null;
|
|
351
|
+
// Clear the cached init promise too — it still resolves to the now
|
|
352
|
+
// DESTROYED pool. Without this, the next ensurePool() skips the
|
|
353
|
+
// (null) fast-path and returns the stale promise, handing callers a
|
|
354
|
+
// dead pool ("Cannot execute a task on destroying pool"). shutdown()
|
|
355
|
+
// already clears it; auto-recovery must as well.
|
|
356
|
+
this.poolInitializationPromise = null;
|
|
357
|
+
this._resetInProgress = false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}, 1000);
|
|
361
|
+
if (t.unref) t.unref();
|
|
362
|
+
this._resetTimer = t;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Check if the pool is shutting down.
|
|
367
|
+
*/
|
|
368
|
+
isPoolShuttingDown(): boolean {
|
|
369
|
+
return this.isShuttingDown;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Destroy the pool and clean up resources.
|
|
374
|
+
*/
|
|
375
|
+
async shutdown(): Promise<void> {
|
|
376
|
+
if (this.isShuttingDown) return;
|
|
377
|
+
this.isShuttingDown = true;
|
|
378
|
+
// Invalidate any in-flight ensurePool() so the pool it builds is torn down
|
|
379
|
+
// by its own generation check rather than surviving past this shutdown.
|
|
380
|
+
this.generation++;
|
|
381
|
+
// Cancel a pending auto-recovery destroy timer: with the generation now bumped it
|
|
382
|
+
// would no-op anyway, but cancelling frees the handle immediately and avoids a
|
|
383
|
+
// late destroy() racing this teardown.
|
|
384
|
+
if (this._resetTimer) { clearTimeout(this._resetTimer); this._resetTimer = null; }
|
|
385
|
+
|
|
386
|
+
// Await an in-flight initialization before tearing down. Without this, a
|
|
387
|
+
// pool whose construction had not yet assigned this.pool when we entered
|
|
388
|
+
// would be built AFTER we returned and leak (cluster workers + Camoufox).
|
|
389
|
+
// The init either self-destroys (generation check) or assigns this.pool,
|
|
390
|
+
// which we then destroy below. Its rejection from the generation check is
|
|
391
|
+
// expected — swallow it.
|
|
392
|
+
const initInFlight = this.poolInitializationPromise;
|
|
393
|
+
if (initInFlight) {
|
|
394
|
+
await initInFlight.catch(() => { /* generation-aborted or failed init — nothing to keep */ });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (this.pool) {
|
|
398
|
+
try {
|
|
399
|
+
// Use a timeout for pool destruction. Workers run async browser teardown
|
|
400
|
+
// in their killHandler (context.close / browser.close via Playwright), so
|
|
401
|
+
// allow enough time for those to complete before the IPC channel closes.
|
|
402
|
+
const destroyPromise = this.pool.destroy();
|
|
403
|
+
destroyPromise.catch((err: Error) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
|
|
404
|
+
await raceWithDeadline(destroyPromise, 5000);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
logger.warn('[WorkerPoolManager] Pool destruction error:', e);
|
|
407
|
+
}
|
|
408
|
+
// Allow a brief moment for IPC channels and worker browser teardown to complete.
|
|
409
|
+
// Reduced from 1500ms to 200ms because browser processes are now also
|
|
410
|
+
// explicitly cleaned up by the scheduler via killBrowserProcesses.
|
|
411
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
412
|
+
this.pool = null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.poolInitializationPromise = null;
|
|
416
|
+
this.currentWorkerCount = null;
|
|
417
|
+
this.consecutiveErrors = 0;
|
|
418
|
+
// Clear any in-flight auto-recovery flag too: if a schedulePoolReset() timer
|
|
419
|
+
// was pending when shutdown ran, leaving this true makes the next ensurePool()
|
|
420
|
+
// needlessly busy-wait (up to 3 s) and possibly throw "being reset" even though
|
|
421
|
+
// the pool is ready to re-init.
|
|
422
|
+
this._resetInProgress = false;
|
|
423
|
+
// Reset so this instance can be re-used after shutdown — the service
|
|
424
|
+
// registry keeps the same WorkerPoolManager instance across scheduler
|
|
425
|
+
// restarts, so without this reset ensurePool() would permanently throw.
|
|
426
|
+
this.isShuttingDown = false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async initialize(): Promise<void> {
|
|
430
|
+
if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.lifecycle = ServiceLifecycle.INITIALIZING;
|
|
434
|
+
logger.debug('[WorkerPoolManager] Initializing...');
|
|
435
|
+
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
436
|
+
logger.debug('[WorkerPoolManager] Initialized');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async dispose(): Promise<void> {
|
|
440
|
+
if (this.lifecycle === ServiceLifecycle.DISPOSED) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.lifecycle = ServiceLifecycle.DISPOSING;
|
|
444
|
+
logger.debug('[WorkerPoolManager] Disposing...');
|
|
445
|
+
await this.shutdown();
|
|
446
|
+
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
447
|
+
logger.debug('[WorkerPoolManager] Disposed');
|
|
448
|
+
}
|
|
449
|
+
}
|