@lincoln504/pi-research 1.1.2 → 1.3.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 +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- 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 +114 -12
- 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/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- 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-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as http from 'node:http';
|
|
9
9
|
import { logger } from '../../logger.ts';
|
|
10
10
|
import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
|
|
11
|
+
import { Utf8Body } from '../../utils/http-body.ts';
|
|
11
12
|
|
|
12
13
|
export class EmbeddingClient implements IEmbedder {
|
|
13
14
|
// Public so store.ts can set the dimension from existing table schema
|
|
@@ -17,10 +18,21 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
17
18
|
constructor(
|
|
18
19
|
private readonly port: number,
|
|
19
20
|
private _device: string = 'unknown',
|
|
21
|
+
/**
|
|
22
|
+
* Shared secret published by the leader in state. Undefined when the leader
|
|
23
|
+
* predates auth (it does not check a header either), so the request simply
|
|
24
|
+
* carries none — an older leader keeps working rather than 403-ing.
|
|
25
|
+
*/
|
|
26
|
+
private readonly authSecret?: string,
|
|
20
27
|
) {
|
|
21
28
|
logger.log(`[EmbeddingClient] Connecting to embedding server at http://127.0.0.1:${port}`);
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
/** Auth header for the leader, or nothing when connecting to a pre-auth leader. */
|
|
32
|
+
private authHeaders(): Record<string, string> {
|
|
33
|
+
return this.authSecret ? { 'x-embedding-auth': this.authSecret } : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
// ---- IEmbedder ----
|
|
25
37
|
|
|
26
38
|
getDevice(): string {
|
|
@@ -93,25 +105,26 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
93
105
|
port: this.port,
|
|
94
106
|
path,
|
|
95
107
|
method: 'GET',
|
|
108
|
+
headers: this.authHeaders(),
|
|
96
109
|
},
|
|
97
110
|
(res) => {
|
|
98
111
|
// Keep the timeout armed through the body read — clearing it on headers
|
|
99
112
|
// would let a stalled body hang for the full process lifetime.
|
|
100
|
-
|
|
101
|
-
res.on('data', (chunk: Buffer) => {
|
|
113
|
+
const collected = new Utf8Body();
|
|
114
|
+
res.on('data', (chunk: Buffer) => { collected.push(chunk); });
|
|
102
115
|
res.on('end', () => {
|
|
103
116
|
if (resolved) return;
|
|
104
117
|
resolved = true;
|
|
105
118
|
clearTimeout(timer);
|
|
106
119
|
try {
|
|
107
|
-
const parsed = JSON.parse(
|
|
120
|
+
const parsed = JSON.parse(collected.toString()) as T;
|
|
108
121
|
if (res.statusCode !== 200) {
|
|
109
122
|
reject(new Error(`[EmbeddingClient] GET ${path} returned HTTP ${res.statusCode}`));
|
|
110
123
|
} else {
|
|
111
124
|
resolve(parsed);
|
|
112
125
|
}
|
|
113
126
|
} catch (_e) {
|
|
114
|
-
reject(new Error(`[EmbeddingClient] Failed to parse GET ${path} response: ${
|
|
127
|
+
reject(new Error(`[EmbeddingClient] Failed to parse GET ${path} response: ${collected.toString()}`));
|
|
115
128
|
}
|
|
116
129
|
});
|
|
117
130
|
res.on('error', (err) => {
|
|
@@ -160,18 +173,19 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
160
173
|
headers: {
|
|
161
174
|
'Content-Type': 'application/json',
|
|
162
175
|
'Content-Length': Buffer.byteLength(body),
|
|
176
|
+
...this.authHeaders(),
|
|
163
177
|
},
|
|
164
178
|
},
|
|
165
179
|
(res) => {
|
|
166
180
|
// Keep the timeout armed through the body read (see getRequest).
|
|
167
|
-
|
|
168
|
-
res.on('data', (chunk: Buffer) => {
|
|
181
|
+
const collected = new Utf8Body();
|
|
182
|
+
res.on('data', (chunk: Buffer) => { collected.push(chunk); });
|
|
169
183
|
res.on('end', () => {
|
|
170
184
|
if (resolved) return;
|
|
171
185
|
resolved = true;
|
|
172
186
|
clearTimeout(timer);
|
|
173
187
|
try {
|
|
174
|
-
const parsed = JSON.parse(
|
|
188
|
+
const parsed = JSON.parse(collected.toString()) as T;
|
|
175
189
|
if (res.statusCode !== 200) {
|
|
176
190
|
const msg = (parsed as Record<string, unknown>)?.['error'];
|
|
177
191
|
reject(new Error(typeof msg === 'string' ? msg : `HTTP ${res.statusCode}`));
|
|
@@ -179,7 +193,7 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
179
193
|
resolve(parsed);
|
|
180
194
|
}
|
|
181
195
|
} catch (_e) {
|
|
182
|
-
reject(new Error(`[EmbeddingClient] Failed to parse POST ${path} response: ${
|
|
196
|
+
reject(new Error(`[EmbeddingClient] Failed to parse POST ${path} response: ${collected.toString()}`));
|
|
183
197
|
}
|
|
184
198
|
});
|
|
185
199
|
res.on('error', (err) => {
|
|
@@ -19,7 +19,7 @@ import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
|
|
|
19
19
|
import { Embedder } from '../../knowledge/embedder.ts';
|
|
20
20
|
import { getModelEmbedderConfig } from '../../knowledge/model-config.ts';
|
|
21
21
|
|
|
22
|
-
import { EmbeddingServer } from './embedding-server.ts';
|
|
22
|
+
import { EmbeddingServer, getEmbeddingServerAuthSecret } from './embedding-server.ts';
|
|
23
23
|
import { EmbeddingClient } from './embedding-client.ts';
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
@@ -76,6 +76,13 @@ export async function isEmbeddingLeaderAlive(
|
|
|
76
76
|
|
|
77
77
|
let _embeddingInstance: IEmbedder | null = null;
|
|
78
78
|
let _embeddingInitPromise: Promise<IEmbedder> | null = null;
|
|
79
|
+
// Config the in-flight init was started for. The join below must compare against
|
|
80
|
+
// these — the cached-instance path checks model/device before reuse, and the
|
|
81
|
+
// adoption paths refuse mismatched leaders, but a caller joining a promise built
|
|
82
|
+
// for a different config would silently receive (and stamp the cache with) the
|
|
83
|
+
// wrong-model embedder, defeating both guards.
|
|
84
|
+
let _initModel: string | null = null;
|
|
85
|
+
let _initDevice: string | null = null;
|
|
79
86
|
let _cachedModel: string | null = null;
|
|
80
87
|
let _cachedDevice: string | null = null;
|
|
81
88
|
|
|
@@ -119,11 +126,29 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
119
126
|
return _embeddingInstance;
|
|
120
127
|
}
|
|
121
128
|
logger.info(`[EmbeddingFactory] Configuration change detected (${_cachedModel} on ${_cachedDevice} -> ${cfg.EMBEDDING_MODEL} on ${cfg.EMBEDDING_DEVICE}). Disposing stale instance.`);
|
|
122
|
-
await
|
|
129
|
+
// Claim the instance SYNCHRONOUSLY, before the first await. Two callers that
|
|
130
|
+
// resolve different EMBEDDING_MODEL/EMBEDDING_DEVICE values would otherwise both
|
|
131
|
+
// observe it, both await dispose() on the same live EmbeddingServer (double
|
|
132
|
+
// close(), double embedder dispose) and both proceed to init().
|
|
133
|
+
const stale = _embeddingInstance;
|
|
123
134
|
_embeddingInstance = null;
|
|
135
|
+
await stale.dispose?.();
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
|
|
138
|
+
// An internal retry (_attempt > 0) is re-entering from INSIDE the promise stored
|
|
139
|
+
// here, so handing that promise back would make it await itself. Only a fresh
|
|
140
|
+
// external call may join an in-flight init — and only one started for the SAME
|
|
141
|
+
// config: a mismatched joiner would get the wrong-model embedder (same-dimension
|
|
142
|
+
// models cross-embed silently). On mismatch, wait the in-flight init out (never
|
|
143
|
+
// race a second election in this process), then re-resolve for this config — the
|
|
144
|
+
// cached-instance check above disposes the mismatched instance.
|
|
145
|
+
if (_embeddingInitPromise && _attempt === 0) {
|
|
146
|
+
if (_initModel === cfg.EMBEDDING_MODEL && _initDevice === cfg.EMBEDDING_DEVICE) {
|
|
147
|
+
return _embeddingInitPromise;
|
|
148
|
+
}
|
|
149
|
+
await _embeddingInitPromise.catch(() => {});
|
|
150
|
+
return getEmbedder(cfg, _attempt);
|
|
151
|
+
}
|
|
127
152
|
|
|
128
153
|
let p: Promise<IEmbedder>;
|
|
129
154
|
|
|
@@ -166,6 +191,9 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
166
191
|
pid: process.pid,
|
|
167
192
|
serverId,
|
|
168
193
|
model: cfg.EMBEDDING_MODEL,
|
|
194
|
+
// Published with the claim, not with the port, so a follower that adopts
|
|
195
|
+
// this leader always has the secret available whenever the port is.
|
|
196
|
+
authSecret: getEmbeddingServerAuthSecret(),
|
|
169
197
|
...(myStartTime !== null ? { startTime: myStartTime } : {}),
|
|
170
198
|
};
|
|
171
199
|
iAmCandidate = true;
|
|
@@ -211,7 +239,12 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
211
239
|
if (!(await isEmbeddingLeaderAlive(info, processLifecycle))) {
|
|
212
240
|
// Candidate process died without cleaning up (or its PID was recycled by
|
|
213
241
|
// an unrelated process — the start-time pairing catches that too).
|
|
214
|
-
|
|
242
|
+
// serverId-scoped: several waiters typically detect the death in the same
|
|
243
|
+
// poll tick; the first one's clear+claim can commit before this one's clear
|
|
244
|
+
// runs, and an unconditional delete here would deregister that NEW claimant
|
|
245
|
+
// (whose Phase-3 port publish is CAS-guarded and silently no-ops) — leaving
|
|
246
|
+
// two concurrent model inits and an invisible leader.
|
|
247
|
+
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
215
248
|
break;
|
|
216
249
|
}
|
|
217
250
|
// Re-check model identity every poll: the leader may have changed while
|
|
@@ -223,7 +256,7 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
223
256
|
const portOk = await isPortListening(info.port);
|
|
224
257
|
if (portOk) {
|
|
225
258
|
logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
|
|
226
|
-
const client = new EmbeddingClient(info.port);
|
|
259
|
+
const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
|
|
227
260
|
await client.fetchHealth();
|
|
228
261
|
_embeddingInstance = client;
|
|
229
262
|
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
@@ -235,14 +268,21 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
235
268
|
const secondCheck = await isPortListening(info.port);
|
|
236
269
|
if (!secondCheck) {
|
|
237
270
|
logger.warn(`[EmbeddingFactory] Registered port ${info.port} is unreachable after two checks — clearing stale state`);
|
|
238
|
-
|
|
271
|
+
// serverId-scoped: this snapshot is up to ~2.5s old (two probes + sleep);
|
|
272
|
+
// the entry may already belong to a new claimant. See the dead-leader
|
|
273
|
+
// clear above for the failure this prevents.
|
|
274
|
+
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
239
275
|
break;
|
|
240
276
|
}
|
|
241
277
|
}
|
|
242
278
|
}
|
|
243
279
|
|
|
244
|
-
// Timed out or candidate died — attempt to claim candidacy ourselves (tail-call)
|
|
245
|
-
|
|
280
|
+
// Timed out or candidate died — attempt to claim candidacy ourselves (tail-call).
|
|
281
|
+
// The in-flight marker is deliberately LEFT in place: clearing it here, while
|
|
282
|
+
// this very promise is still running, let a concurrent getEmbedder() see no
|
|
283
|
+
// instance and no init in flight and start a second, competing election in the
|
|
284
|
+
// same process. The retry re-enters with _attempt > 0, which skips the join
|
|
285
|
+
// above, so leaving it set cannot deadlock.
|
|
246
286
|
if (_attempt + 1 >= MAX_LEADER_WAIT_RETRIES) {
|
|
247
287
|
throw new Error(
|
|
248
288
|
`[EmbeddingFactory] Could not obtain an embedding server after ${MAX_LEADER_WAIT_RETRIES} attempts ` +
|
|
@@ -267,8 +307,10 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
267
307
|
try {
|
|
268
308
|
port = await server.startServer();
|
|
269
309
|
} catch (err) {
|
|
270
|
-
// Init failed — clear our placeholder so others can try
|
|
271
|
-
|
|
310
|
+
// Init failed — clear our placeholder so others can try. serverId-scoped so a
|
|
311
|
+
// concurrent steal-and-reclaim (a waiter that judged us dead and claimed) is
|
|
312
|
+
// never deregistered by our own failure path.
|
|
313
|
+
await stateManager.clearEmbeddingServer({ serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
272
314
|
throw err;
|
|
273
315
|
}
|
|
274
316
|
|
|
@@ -292,16 +334,20 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
292
334
|
|
|
293
335
|
p = init();
|
|
294
336
|
_embeddingInitPromise = p;
|
|
337
|
+
_initModel = cfg.EMBEDDING_MODEL;
|
|
338
|
+
_initDevice = cfg.EMBEDDING_DEVICE;
|
|
295
339
|
// init() already assigns _embeddingInstance before it resolves, so do NOT
|
|
296
340
|
// re-assign it here. If clearEmbeddingInstance() ran (and disposed the server)
|
|
297
341
|
// between init resolving and this microtask, re-assigning would resurrect the
|
|
298
342
|
// disposed instance and hand it to the next getEmbedder() caller. Only clear
|
|
299
343
|
// the in-flight promise marker.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
344
|
+
// Clear the marker only if it is STILL ours. An unconditional clear let a settling
|
|
345
|
+
// promise wipe a newer caller's in-flight marker — admitting yet another election,
|
|
346
|
+
// and cascading. Same guard as scheduler-factory's initialization promise.
|
|
347
|
+
const clearIfCurrent = (): void => {
|
|
348
|
+
if (_embeddingInitPromise === p) _embeddingInitPromise = null;
|
|
349
|
+
};
|
|
350
|
+
p.then(clearIfCurrent).catch(clearIfCurrent);
|
|
305
351
|
return p;
|
|
306
352
|
}
|
|
307
353
|
|
|
@@ -6,14 +6,67 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as http from 'node:http';
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
9
10
|
import * as path from 'node:path';
|
|
10
11
|
import { logger } from '../../logger.ts';
|
|
11
12
|
import { captureStdio } from '../../utils/stdio-capture.ts';
|
|
12
13
|
import { buildDefaultDebugLogPath } from '../../utils/log-utils.ts';
|
|
13
14
|
import { DiskSpaceChecker } from '../../utils/disk-space-checker.ts';
|
|
15
|
+
import { Utf8Body } from '../../utils/http-body.ts';
|
|
14
16
|
import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
|
|
15
17
|
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
16
18
|
import type { Embedder } from '../../knowledge/embedder.ts';
|
|
19
|
+
import { markWebGpuFallback } from '../../knowledge/embedder-utils.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-process shared secret every embed request must present.
|
|
23
|
+
*
|
|
24
|
+
* The server binds 127.0.0.1, but loopback is not an authorization boundary —
|
|
25
|
+
* any local process could otherwise enqueue embedding work. That matters more
|
|
26
|
+
* than raw GPU cost: the serial queue steps the leader down when it poisons, so
|
|
27
|
+
* sustained abuse denies the knowledge store cluster-wide. The browser server has
|
|
28
|
+
* had this since #21; the embedding server was the asymmetric gap.
|
|
29
|
+
*
|
|
30
|
+
* Generated lazily and held in memory only — it is published to followers through
|
|
31
|
+
* the state file, which is already 0o600 and already carries the browser secret.
|
|
32
|
+
*/
|
|
33
|
+
let _embeddingAuthSecret: string | null = null;
|
|
34
|
+
|
|
35
|
+
export function getEmbeddingServerAuthSecret(): string {
|
|
36
|
+
if (!_embeddingAuthSecret) {
|
|
37
|
+
_embeddingAuthSecret = crypto.randomBytes(32).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
return _embeddingAuthSecret;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Constant-time comparison of a presented header against the expected secret.
|
|
44
|
+
* Length is compared first because timingSafeEqual throws on a length mismatch.
|
|
45
|
+
*/
|
|
46
|
+
function isAuthorized(presented: string | string[] | undefined): boolean {
|
|
47
|
+
const header = Array.isArray(presented) ? presented[0] : presented;
|
|
48
|
+
const expected = Buffer.from(getEmbeddingServerAuthSecret(), 'utf8');
|
|
49
|
+
const actual = Buffer.from(header ?? '', 'utf8');
|
|
50
|
+
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Refuse to serialize a vector containing NaN/±Infinity. JSON.stringify emits
|
|
55
|
+
* `null` for non-finite numbers, and the client's `new Float32Array(resp.data)`
|
|
56
|
+
* coerces null back to 0 — a silently part-zeroed vector that passes every
|
|
57
|
+
* downstream width/finite gate (0 IS finite) and lands in the ANN index. Failing
|
|
58
|
+
* the request instead surfaces it as an ordinary embed failure on the client.
|
|
59
|
+
*/
|
|
60
|
+
function assertFiniteVector(vec: ArrayLike<number>, vectorIndex?: number): void {
|
|
61
|
+
for (let i = 0; i < vec.length; i++) {
|
|
62
|
+
if (!Number.isFinite(vec[i]!)) {
|
|
63
|
+
const where = vectorIndex === undefined ? `position ${i}` : `vector ${vectorIndex}, position ${i}`;
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[EmbeddingServer] embedder produced a non-finite value (${vec[i]}) at ${where} — refusing to serialize (JSON would launder it to null/0)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
17
70
|
|
|
18
71
|
// ---------------------------------------------------------------------------
|
|
19
72
|
// SerialQueue — ensures embed/embedMany never run concurrently on the GPU
|
|
@@ -240,6 +293,15 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
240
293
|
'Stepping down as leader so a fresh process can re-elect with a clean GPU context. ' +
|
|
241
294
|
err.message,
|
|
242
295
|
);
|
|
296
|
+
// INVARIANT: after a poisoned (permanently hung) inference, THIS process must
|
|
297
|
+
// never touch WebGPU again. The wedged native call still holds the same-PID
|
|
298
|
+
// re-entrant GPU lock, so if this process wins the next election and
|
|
299
|
+
// re-initializes WebGPU it would run a second inference beside the stuck one —
|
|
300
|
+
// the concurrent-GPU-inference segfault class the SerialQueue exists to
|
|
301
|
+
// prevent. Latch the process-wide fallback so any embedder built in this
|
|
302
|
+
// process from here on comes up on CPU; only a FRESH process may re-elect
|
|
303
|
+
// with a clean GPU context.
|
|
304
|
+
markWebGpuFallback();
|
|
243
305
|
// Step down WITHOUT disposing the embedder: pipeline.dispose() would join the
|
|
244
306
|
// stuck native thread (itself hanging) or touch a device-lost context. Clearing
|
|
245
307
|
// the state registration + closing the HTTP server is enough for re-election;
|
|
@@ -305,14 +367,14 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
305
367
|
this.leadershipTimer = null;
|
|
306
368
|
}
|
|
307
369
|
|
|
308
|
-
// Clear our registration if we still own it
|
|
370
|
+
// Clear our registration if we still own it. The serverId CAS runs inside the
|
|
371
|
+
// state lock, so a successor that claimed the slot between our last check and
|
|
372
|
+
// this shutdown can never be deregistered by us (the old read-then-clear had
|
|
373
|
+
// exactly that window).
|
|
309
374
|
try {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
|
|
314
|
-
});
|
|
315
|
-
}
|
|
375
|
+
await this.stateManager.clearEmbeddingServer({ serverId: this.serverId }).catch((err) => {
|
|
376
|
+
logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
|
|
377
|
+
});
|
|
316
378
|
} catch (err) {
|
|
317
379
|
const msg = err instanceof Error ? err.message : String(err);
|
|
318
380
|
// State manager is torn down before the embedding server during process exit —
|
|
@@ -428,13 +490,27 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
428
490
|
// ---- HTTP request handler ----
|
|
429
491
|
|
|
430
492
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
493
|
+
// Authorize BEFORE reading the body, so an unauthenticated caller cannot make
|
|
494
|
+
// us buffer up to MAX_BODY_SIZE, and before routing, so it cannot probe
|
|
495
|
+
// leadership/health state either. Mirrors the browser server.
|
|
496
|
+
if (!isAuthorized(req.headers['x-embedding-auth'])) {
|
|
497
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
498
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
499
|
+
req.resume(); // drain so the socket is released rather than left half-read
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
431
503
|
const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
432
504
|
|
|
433
|
-
|
|
505
|
+
// Buffered, then decoded once: `body += chunk` would split multi-byte UTF-8
|
|
506
|
+
// across chunk boundaries and silently replace both halves with U+FFFD — and
|
|
507
|
+
// the text arriving here is exactly what gets embedded, so that corruption
|
|
508
|
+
// would be baked into the vectors. See Utf8Body.
|
|
509
|
+
const collected = new Utf8Body();
|
|
434
510
|
let oversized = false;
|
|
435
511
|
req.on('data', (chunk: Buffer) => {
|
|
436
|
-
|
|
437
|
-
if (
|
|
512
|
+
collected.push(chunk);
|
|
513
|
+
if (collected.byteLength > MAX_BODY_SIZE) {
|
|
438
514
|
oversized = true;
|
|
439
515
|
req.destroy(new Error('Payload too large'));
|
|
440
516
|
}
|
|
@@ -459,6 +535,19 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
459
535
|
finish();
|
|
460
536
|
});
|
|
461
537
|
|
|
538
|
+
// A client that goes away mid-request emits 'aborted'/'close' and does NOT
|
|
539
|
+
// reliably emit 'error', so neither handler above would fire and this promise
|
|
540
|
+
// would dangle forever — retaining req, res and the buffered body for the rest
|
|
541
|
+
// of the process's life. That is not an exotic case: EmbeddingClient destroys
|
|
542
|
+
// the request on its own 120s timeout, which is the same as this server's
|
|
543
|
+
// SerialQueue soft timeout, so a slow embed reliably produces one. handleRequest
|
|
544
|
+
// is invoked with `void`, so nothing else would ever observe the leak.
|
|
545
|
+
// On a normal request this fires after 'end' and finish() is already a no-op;
|
|
546
|
+
// nothing follows this await (the caller invokes handleRequest with `void`), so
|
|
547
|
+
// an early resolve is inert either way — the 'end' handler still writes the
|
|
548
|
+
// response on its own.
|
|
549
|
+
req.on('close', finish);
|
|
550
|
+
|
|
462
551
|
req.on('end', async () => {
|
|
463
552
|
try {
|
|
464
553
|
if (req.method === 'GET' && req.url === '/health') {
|
|
@@ -480,7 +569,7 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
480
569
|
return;
|
|
481
570
|
}
|
|
482
571
|
|
|
483
|
-
const data = JSON.parse(
|
|
572
|
+
const data = JSON.parse(collected.toString());
|
|
484
573
|
|
|
485
574
|
switch (req.url) {
|
|
486
575
|
case '/embed': {
|
|
@@ -491,6 +580,9 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
491
580
|
// message drives the client to reconnect to the new leader.
|
|
492
581
|
this.assertServing();
|
|
493
582
|
const result = await this.queue.enqueue(() => this.embedder.embed(data.text as string));
|
|
583
|
+
// Scan BEFORE serialization: a device-lost mid-inference yields NaN
|
|
584
|
+
// entries that JSON would launder into null → 0 on the client.
|
|
585
|
+
assertFiniteVector(result);
|
|
494
586
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
495
587
|
res.end(JSON.stringify({ data: Array.from(result) }));
|
|
496
588
|
break;
|
|
@@ -498,6 +590,8 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
498
590
|
case '/embedMany': {
|
|
499
591
|
this.assertServing();
|
|
500
592
|
const results = await this.queue.enqueue(() => this.embedder.embedMany(data.texts as string[]));
|
|
593
|
+
// Same non-finite scan as /embed, per vector (see assertFiniteVector).
|
|
594
|
+
for (let i = 0; i < results.length; i++) assertFiniteVector(results[i]!, i);
|
|
501
595
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
502
596
|
res.end(JSON.stringify({ results: results.map(r => Array.from(r)) }));
|
|
503
597
|
break;
|