@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.
Files changed (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. 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
- let body = '';
101
- res.on('data', (chunk: Buffer) => { body += chunk; });
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(body) as T;
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: ${body}`));
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
- let responseBody = '';
168
- res.on('data', (chunk: Buffer) => { responseBody += chunk; });
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(responseBody) as T;
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: ${responseBody}`));
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 _embeddingInstance.dispose?.();
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
- if (_embeddingInitPromise) return _embeddingInitPromise;
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
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
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
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
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
- _embeddingInitPromise = null;
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
- await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
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
- p.then(() => {
301
- _embeddingInitPromise = null;
302
- }).catch(() => {
303
- _embeddingInitPromise = null;
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
- const serverInfo = await this.stateManager.getEmbeddingServer();
311
- if (serverInfo?.serverId === this.serverId) {
312
- await this.stateManager.clearEmbeddingServer().catch((err) => {
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
- let body = '';
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
- body += chunk;
437
- if (body.length > MAX_BODY_SIZE) {
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(body);
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;