@reftrixmcp/ml 0.3.0 → 0.6.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 (42) hide show
  1. package/dist/config/worker-resource-limits.d.ts +89 -0
  2. package/dist/config/worker-resource-limits.d.ts.map +1 -0
  3. package/dist/config/worker-resource-limits.js +156 -0
  4. package/dist/config/worker-resource-limits.js.map +1 -0
  5. package/dist/dinov2/service.d.ts.map +1 -1
  6. package/dist/dinov2/service.js +70 -10
  7. package/dist/dinov2/service.js.map +1 -1
  8. package/dist/dinov2/worker-thread.js +72 -9
  9. package/dist/dinov2/worker-thread.js.map +1 -1
  10. package/dist/embeddings/service.d.ts +19 -0
  11. package/dist/embeddings/service.d.ts.map +1 -1
  12. package/dist/embeddings/service.js +56 -1
  13. package/dist/embeddings/service.js.map +1 -1
  14. package/dist/embeddings/worker-thread-device.d.ts +59 -0
  15. package/dist/embeddings/worker-thread-device.d.ts.map +1 -0
  16. package/dist/embeddings/worker-thread-device.js +63 -0
  17. package/dist/embeddings/worker-thread-device.js.map +1 -0
  18. package/dist/embeddings/worker-thread.d.ts.map +1 -1
  19. package/dist/embeddings/worker-thread.js +22 -85
  20. package/dist/embeddings/worker-thread.js.map +1 -1
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +10 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/onnx-availability.d.ts +29 -0
  26. package/dist/onnx-availability.d.ts.map +1 -0
  27. package/dist/onnx-availability.js +69 -0
  28. package/dist/onnx-availability.js.map +1 -0
  29. package/dist/onnx-provider-detect.d.ts +47 -0
  30. package/dist/onnx-provider-detect.d.ts.map +1 -0
  31. package/dist/onnx-provider-detect.js +111 -0
  32. package/dist/onnx-provider-detect.js.map +1 -0
  33. package/package.json +8 -5
  34. package/src/config/worker-resource-limits.ts +176 -0
  35. package/src/dinov2/service.ts +88 -10
  36. package/src/dinov2/worker-thread.ts +85 -10
  37. package/src/embeddings/service.ts +63 -1
  38. package/src/embeddings/worker-thread-device.ts +68 -0
  39. package/src/embeddings/worker-thread.ts +30 -104
  40. package/src/index.ts +27 -0
  41. package/src/onnx-availability.ts +78 -0
  42. package/src/onnx-provider-detect.ts +134 -0
@@ -28,16 +28,20 @@ import type {
28
28
  } from "./worker-thread-types.js";
29
29
 
30
30
  // =====================================================
31
- // Dynamic import of onnxruntime-node
31
+ // Dynamic import of onnxruntime-node (optional dependency)
32
32
  // =====================================================
33
33
 
34
+ import { safeImportOnnx } from "../onnx-availability.js";
35
+ import { detectExecutionProvider, isLdLibraryPathSetAtOsLevel } from "../onnx-provider-detect.js";
36
+ import type { ExecutionProvider } from "../onnx-provider-detect.js";
37
+
34
38
  // eslint-disable-next-line @typescript-eslint/consistent-type-imports
35
39
  type OrtModule = typeof import("onnxruntime-node");
36
40
  let ort: OrtModule | null = null;
37
41
 
38
42
  async function getOrt(): Promise<OrtModule> {
39
43
  if (!ort) {
40
- ort = await import("onnxruntime-node");
44
+ ort = await safeImportOnnx();
41
45
  }
42
46
  return ort;
43
47
  }
@@ -137,18 +141,71 @@ function l2Normalize(vec: Float32Array): Float32Array {
137
141
  // Session management
138
142
  // =====================================================
139
143
 
144
+ /** Resolved execution provider for the current session. */
145
+ let resolvedProvider: ExecutionProvider = "cpu";
146
+
140
147
  async function initializeSession(modelPath: string): Promise<void> {
141
148
  if (session) return;
142
149
 
150
+ const provider = detectExecutionProvider("DINOv2Worker");
151
+
152
+ // Safety check: if CUDA is requested but LD_LIBRARY_PATH was not set at
153
+ // the OS level (only set via loadEnvLocal at runtime), CUDA init will crash
154
+ // because dlopen() can't find CUDA shared libraries. Fall back to CPU.
155
+ let effectiveProvider = provider;
156
+ if (effectiveProvider === "cuda" && !isLdLibraryPathSetAtOsLevel()) {
157
+ console.warn(
158
+ "[DINOv2Worker] CUDA requested but LD_LIBRARY_PATH not set at OS level. " +
159
+ "dlopen() cannot find CUDA libraries. Falling back to CPU. " +
160
+ "To use CUDA, set LD_LIBRARY_PATH before starting the Node.js process."
161
+ );
162
+ effectiveProvider = "cpu";
163
+ }
164
+
165
+ const sessionOptions =
166
+ effectiveProvider === "cuda"
167
+ ? {
168
+ executionProviders: ["CUDAExecutionProvider", "CPUExecutionProvider"],
169
+ enableMemPattern: false,
170
+ }
171
+ : {
172
+ executionProviders: ["cpu"],
173
+ enableCpuMemArena: false,
174
+ enableMemPattern: false,
175
+ };
176
+
143
177
  const ortModule = await getOrt();
144
- session = (await ortModule.InferenceSession.create(modelPath, {
145
- executionProviders: ["cpu"],
146
- enableCpuMemArena: false,
147
- enableMemPattern: false,
148
- })) as unknown as OrtSession;
178
+
179
+ // Attempt to create session with the resolved provider.
180
+ // If CUDA init fails (e.g. library not found), catch and retry with CPU.
181
+ try {
182
+ session = (await ortModule.InferenceSession.create(
183
+ modelPath,
184
+ sessionOptions
185
+ )) as unknown as OrtSession;
186
+ resolvedProvider = effectiveProvider;
187
+ } catch (providerError) {
188
+ if (effectiveProvider !== "cpu") {
189
+ // CUDA init failed — fallback to CPU
190
+ const errorMsg =
191
+ providerError instanceof Error ? providerError.message : String(providerError);
192
+ console.warn(
193
+ "[DINOv2Worker] CUDA session creation failed, falling back to CPU: %s",
194
+ errorMsg
195
+ );
196
+ session = (await ortModule.InferenceSession.create(modelPath, {
197
+ executionProviders: ["cpu"],
198
+ enableCpuMemArena: false,
199
+ enableMemPattern: false,
200
+ })) as unknown as OrtSession;
201
+ resolvedProvider = "cpu";
202
+ } else {
203
+ throw providerError;
204
+ }
205
+ }
149
206
 
150
207
  // eslint-disable-next-line no-console
151
- console.log("[DINOv2Worker] Session initialized:", modelPath);
208
+ console.log("[DINOv2Worker] Session initialized (provider: %s):", resolvedProvider, modelPath);
152
209
  }
153
210
 
154
211
  async function disposeSession(): Promise<void> {
@@ -187,8 +244,18 @@ async function runInference(
187
244
  // Create input tensor [1, 3, height, width]
188
245
  const inputTensor = new ortModule.Tensor("float32", inputData, [1, 3, height, width]);
189
246
 
190
- // Run inference
191
- const output = await session.run({ pixel_values: inputTensor });
247
+ // Run inference — use try/finally to ensure tensor disposal (OOM-FIX-1)
248
+ // ONNX Tensor holds native C++ memory that V8 GC cannot reclaim.
249
+ // Without explicit dispose(), ~1.4MB/inference leaks as native memory.
250
+ let output: Record<string, { data: Float32Array; dims: readonly number[] }>;
251
+ try {
252
+ output = await session.run({ pixel_values: inputTensor });
253
+ } finally {
254
+ // Dispose input tensor immediately after inference completes
255
+ if (typeof (inputTensor as unknown as { dispose?: () => void }).dispose === "function") {
256
+ (inputTensor as unknown as { dispose: () => void }).dispose();
257
+ }
258
+ }
192
259
 
193
260
  // Extract CLS token from last_hidden_state [1, 257, 768]
194
261
  const lastHiddenState = output.last_hidden_state;
@@ -212,6 +279,14 @@ async function runInference(
212
279
  clsToken[i] = data[i]!;
213
280
  }
214
281
 
282
+ // Dispose output tensors after data extraction (OOM-FIX-1)
283
+ for (const key of Object.keys(output)) {
284
+ const tensor = output[key] as unknown as { dispose?: () => void };
285
+ if (typeof tensor?.dispose === "function") {
286
+ tensor.dispose();
287
+ }
288
+ }
289
+
215
290
  // L2 normalize with NaN/Infinity defense
216
291
  const normalized = l2Normalize(clsToken);
217
292
 
@@ -21,6 +21,9 @@ import { fileURLToPath } from "node:url";
21
21
  import path from "node:path";
22
22
  import type { EmbeddingTextType, EmbeddingServiceConfig, CacheStats } from "./types.js";
23
23
  import type { WorkerMessage, WorkerResponse } from "./worker-thread-types.js";
24
+ import { OnnxRuntimeUnavailableError } from "../onnx-availability.js";
25
+ import { getMLWorkerThreadOptions } from "../config/worker-resource-limits.js";
26
+ import { detectExecutionProvider, isLdLibraryPathSetAtOsLevel } from "../onnx-provider-detect.js";
24
27
 
25
28
  /**
26
29
  * Disposable pipeline interface (used only in in-process fallback mode).
@@ -306,7 +309,13 @@ export class EmbeddingService {
306
309
  // worker_threads Workers, so we must explicitly pass an empty array
307
310
  // to prevent the parent's flags (set by WorkerSupervisor fork())
308
311
  // from propagating.
309
- this.worker = new Worker(scriptPath, { execArgv: [] });
312
+ //
313
+ // PR7e-β1: Add explicit resourceLimits to prevent OOM when ONNX
314
+ // falls back to CPU (onnxruntime-node). Without resourceLimits,
315
+ // V8 old-space defaults to < 4GB and e5-base CPU inference OOMs.
316
+ // See ADR-0012 §3 BLOCKER 4 and config/worker-resource-limits.ts.
317
+ const workerOptions = { execArgv: [], ...getMLWorkerThreadOptions() };
318
+ this.worker = new Worker(scriptPath, workerOptions);
310
319
 
311
320
  // Handle worker messages
312
321
  this.worker.on("message", (response: WorkerResponse) => {
@@ -445,10 +454,59 @@ export class EmbeddingService {
445
454
  * If the configured device is 'cuda' but initialization fails (e.g. because
446
455
  * LD_LIBRARY_PATH was not set at the OS level), automatically falls back to CPU.
447
456
  */
457
+ /**
458
+ * Resolve the safe execution device for the in-process pipeline.
459
+ *
460
+ * PR-1 GPU-COORD H regression fix (FIND-IMPL-PR1-H-NEW-01): the Phase 5 fork
461
+ * child's VRAM probe wires `ONNX_EXECUTION_PROVIDER=cuda` when free VRAM is
462
+ * available, but that does NOT guarantee the CUDA EP shared library
463
+ * (`libonnxruntime_providers_cuda.so`) is installed on the host. Previously
464
+ * the in-process path trusted `this.config.device` (set straight from the env
465
+ * var in the constructor) and passed `device:"cuda"` to transformers, which
466
+ * raw-throws on the native dlopen failure when the EP `.so` is absent.
467
+ *
468
+ * This gate mirrors the DINOv2 in-process path (`dinov2/worker-thread.ts`
469
+ * `initializeSession`): it resolves via `detectExecutionProvider` (which calls
470
+ * `verifyCudaAvailability` for an EP `.so` filesystem check) and also requires
471
+ * `LD_LIBRARY_PATH` at the OS level. If either fails, it returns "cpu" so the
472
+ * pipeline is never asked to create a CUDA session it cannot back. When the
473
+ * configured device is not "cuda", the configured device is returned as-is.
474
+ */
475
+ private resolveInProcessDevice(): "cpu" | "cuda" {
476
+ if (this.config.device !== "cuda") {
477
+ return this.config.device === undefined ? "cpu" : (this.config.device as "cpu" | "cuda");
478
+ }
479
+
480
+ // CUDA requested — gate on EP `.so` availability and OS-level LD_LIBRARY_PATH.
481
+ const resolved = detectExecutionProvider("EmbeddingService");
482
+ if (resolved === "cuda" && !isLdLibraryPathSetAtOsLevel()) {
483
+ console.warn(
484
+ "[ML] CUDA requested but LD_LIBRARY_PATH not set at OS level. " +
485
+ "dlopen() cannot find CUDA libraries. Falling back to CPU."
486
+ );
487
+ return "cpu";
488
+ }
489
+ if (resolved !== "cuda") {
490
+ console.warn(
491
+ "[ML] CUDA requested but CUDA EP not available (libonnxruntime_providers_cuda.so absent). " +
492
+ "Falling back to CPU."
493
+ );
494
+ }
495
+ return resolved;
496
+ }
497
+
448
498
  private async initializeInProcess(): Promise<void> {
449
499
  if (this.pipeline) return;
450
500
  if (this.initPromise) return this.initPromise;
451
501
 
502
+ // Resolve the safe device BEFORE init so we never pass an unbacked CUDA
503
+ // device to the pipeline (FIND-IMPL-PR1-H-NEW-01 — gate parity with DINOv2).
504
+ const effectiveDevice = this.resolveInProcessDevice();
505
+ if (effectiveDevice !== this.config.device) {
506
+ this.config = { ...this.config, device: effectiveDevice };
507
+ this.currentProvider = effectiveDevice;
508
+ }
509
+
452
510
  this.initPromise = (async (): Promise<void> => {
453
511
  try {
454
512
  if (process.env.NODE_ENV === "development") {
@@ -502,6 +560,10 @@ export class EmbeddingService {
502
560
  }
503
561
  } catch (error) {
504
562
  this.initPromise = null;
563
+ const code = (error as { code?: string }).code;
564
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") {
565
+ throw new OnnxRuntimeUnavailableError("text embedding");
566
+ }
505
567
  const message = error instanceof Error ? error.message : "Unknown error";
506
568
  throw new Error(`Failed to load embedding model: ${message}`);
507
569
  }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ /**
3
+ * Worker-thread ONNX device gate helpers
4
+ *
5
+ * Pure, side-effect-free helpers that decide the effective ONNX execution
6
+ * device for the e5-base embedding worker thread. Extracted from
7
+ * `worker-thread.ts` so the gate logic is directly unit-testable — the worker
8
+ * module itself only runs as a Worker Thread (it requires `parentPort` at
9
+ * import time), so its gate could otherwise not be exercised in-place.
10
+ *
11
+ * The contract mirrors the in-process gate
12
+ * (`service.ts` `resolveInProcessDevice`, FIND-IMPL-PR1-H-NEW-01): the effective
13
+ * device must HONOR the already-resolved provider (the result of
14
+ * `detectExecutionProvider()` + the OS-level `LD_LIBRARY_PATH` check) and must
15
+ * NOT fall back to a raw `config.device` value (which is set straight from the
16
+ * `ONNX_EXECUTION_PROVIDER` env var). Letting `config.device="cuda"` override a
17
+ * gate decision of `"cpu"` passes an unbacked CUDA device to transformers, which
18
+ * raw-throws on the native `dlopen()` failure when the EP `.so` is absent
19
+ * (root-cause decision `019ec56b`).
20
+ *
21
+ * @module embeddings/worker-thread-device
22
+ */
23
+
24
+ import type { ExecutionProvider } from "../onnx-provider-detect.js";
25
+
26
+ /**
27
+ * Resolve the effective ONNX device for the worker-thread pipeline.
28
+ *
29
+ * `resolvedProvider` is already the result of the init gate
30
+ * (`detectExecutionProvider()` — a CUDA EP `.so` filesystem check — plus the
31
+ * OS-level `LD_LIBRARY_PATH` check in `initializePipeline`). This helper simply
32
+ * honors that decision. It deliberately does NOT consult `config.device`: doing
33
+ * so was the line-97 bug that let a `"cuda"` env override a `"cpu"` gate result.
34
+ *
35
+ * Parity: `service.ts` `resolveInProcessDevice()` likewise returns the gate
36
+ * result (`resolved`), never a raw configured device when CUDA is unbacked.
37
+ *
38
+ * @param resolvedProvider - The provider already resolved by the init gate.
39
+ * @returns The effective device to pass to the transformers pipeline.
40
+ */
41
+ export function resolveWorkerEffectiveDevice(
42
+ resolvedProvider: ExecutionProvider
43
+ ): ExecutionProvider {
44
+ return resolvedProvider;
45
+ }
46
+
47
+ /**
48
+ * Decide whether a `switch-provider` request to CUDA may proceed.
49
+ *
50
+ * Mirrors the init-time AND gate (`initializePipeline`, line ~88): CUDA may be
51
+ * used only if BOTH the CUDA EP shared library is present
52
+ * (`verifyCudaAvailability`) AND `LD_LIBRARY_PATH` was set at the OS level
53
+ * (`isLdLibraryPathSetAtOsLevel`). Pre-fix, `switch-provider` gated on
54
+ * `verifyCudaAvailability` alone — so a host with the EP `.so` but an unset
55
+ * `LD_LIBRARY_PATH` would let `resolvedProvider="cuda"` through, producing an
56
+ * unbacked CUDA device and a `dlopen()` raw throw (UB-6 init parity).
57
+ *
58
+ * The two checks are passed in as functions so this helper stays pure and
59
+ * deterministically testable (the production caller passes the real
60
+ * `verifyCudaAvailability` / `isLdLibraryPathSetAtOsLevel`).
61
+ *
62
+ * @param verifyCuda - CUDA EP `.so` filesystem availability check.
63
+ * @param isLdSet - OS-level `LD_LIBRARY_PATH` presence check.
64
+ * @returns true only if both checks pass (CUDA switch permitted).
65
+ */
66
+ export function canSwitchToCuda(verifyCuda: () => boolean, isLdSet: () => boolean): boolean {
67
+ return verifyCuda() && isLdSet();
68
+ }
@@ -20,10 +20,15 @@
20
20
  */
21
21
 
22
22
  import { parentPort } from "node:worker_threads";
23
- import { createRequire } from "node:module";
24
- import fs from "node:fs";
25
- import nodePath from "node:path";
26
23
  import type { WorkerMessage, WorkerResponse, WorkerErrorResponse } from "./worker-thread-types.js";
24
+ import { OnnxRuntimeUnavailableError } from "../onnx-availability.js";
25
+ import {
26
+ detectExecutionProvider,
27
+ verifyCudaAvailability,
28
+ isLdLibraryPathSetAtOsLevel,
29
+ } from "../onnx-provider-detect.js";
30
+ import type { ExecutionProvider } from "../onnx-provider-detect.js";
31
+ import { resolveWorkerEffectiveDevice, canSwitchToCuda } from "./worker-thread-device.js";
27
32
 
28
33
  // =====================================================
29
34
  // Pipeline types (mirrors service.ts DisposablePipeline)
@@ -38,83 +43,6 @@ interface DisposablePipeline {
38
43
  dispose?: () => Promise<unknown[]>;
39
44
  }
40
45
 
41
- // =====================================================
42
- // GPU execution provider detection
43
- // =====================================================
44
-
45
- type ExecutionProvider = "cpu" | "cuda";
46
-
47
- /**
48
- * Detect the ONNX execution provider based on environment configuration.
49
- *
50
- * Checks ONNX_EXECUTION_PROVIDER env var and verifies that the CUDA
51
- * provider shared library is available on disk. Falls back to CPU
52
- * gracefully if the provider is not installed.
53
- */
54
- function detectExecutionProvider(): ExecutionProvider {
55
- const envProvider = process.env.ONNX_EXECUTION_PROVIDER;
56
-
57
- if (envProvider === "cuda" || envProvider === "rocm") {
58
- const cudaAvailable = verifyCudaAvailability();
59
- if (cudaAvailable) {
60
- return "cuda";
61
- }
62
- console.warn(
63
- "[EmbeddingWorker] ONNX_EXECUTION_PROVIDER=%s but CUDA provider not available, falling back to CPU",
64
- envProvider
65
- );
66
- return "cpu";
67
- }
68
-
69
- return "cpu";
70
- }
71
-
72
- /**
73
- * Verify that CUDA provider (libonnxruntime_providers_cuda.so) is available.
74
- *
75
- * onnxruntime-node can download the CUDA provider shared library via
76
- * `ONNXRUNTIME_NODE_INSTALL_CUDA=v12 node .../install.js`. This function
77
- * checks for its presence on disk rather than trying to resolve a
78
- * non-existent npm package (onnxruntime-gpu is Python-only).
79
- */
80
- function verifyCudaAvailability(): boolean {
81
- try {
82
- const esmRequire = createRequire(import.meta.url);
83
- const ortNodePath = esmRequire.resolve("onnxruntime-node");
84
- // require.resolve returns e.g. .../onnxruntime-node/dist/index.js
85
- // Walk up to package root by finding the directory containing package.json
86
- let packageDir = nodePath.dirname(ortNodePath);
87
- for (let i = 0; i < 5; i++) {
88
- if (fs.existsSync(nodePath.join(packageDir, "package.json"))) break;
89
- packageDir = nodePath.dirname(packageDir);
90
- }
91
-
92
- // Search across napi versions (v3, v6, etc.) for CUDA provider
93
- const binDir = nodePath.join(packageDir, "bin");
94
- if (fs.existsSync(binDir)) {
95
- const napiDirs = fs.readdirSync(binDir).filter((d) => d.startsWith("napi-v"));
96
- for (const napiDir of napiDirs) {
97
- const cudaProviderPath = nodePath.join(
98
- binDir,
99
- napiDir,
100
- "linux",
101
- "x64",
102
- "libonnxruntime_providers_cuda.so"
103
- );
104
- if (fs.existsSync(cudaProviderPath)) {
105
- return true;
106
- }
107
- }
108
- }
109
-
110
- console.warn("[EmbeddingWorker] CUDA provider not found in: %s", binDir);
111
- return false;
112
- } catch {
113
- console.warn("[EmbeddingWorker] Cannot verify CUDA provider: onnxruntime-node not found");
114
- return false;
115
- }
116
- }
117
-
118
46
  // =====================================================
119
47
  // Worker state
120
48
  // =====================================================
@@ -146,26 +74,6 @@ function normalizeVector(vector: number[]): number[] {
146
74
  return vector.map((val) => val / norm);
147
75
  }
148
76
 
149
- /**
150
- * Verify that LD_LIBRARY_PATH is set at the OS level (not just process.env).
151
- *
152
- * dlopen() reads LD_LIBRARY_PATH from the kernel environment (/proc/self/environ),
153
- * NOT from process.env modifications made at runtime. If LD_LIBRARY_PATH was set
154
- * after process startup (e.g. by loadEnvLocal()), dlopen() cannot find CUDA
155
- * libraries and ONNX Runtime CUDA provider initialization will segfault or throw.
156
- *
157
- * Returns true only if LD_LIBRARY_PATH was present in the original process environment.
158
- */
159
- function isLdLibraryPathSetAtOsLevel(): boolean {
160
- try {
161
- const procEnv = fs.readFileSync("/proc/self/environ", "utf-8");
162
- return procEnv.includes("LD_LIBRARY_PATH");
163
- } catch {
164
- // /proc/self/environ not available (non-Linux) — assume set
165
- return true;
166
- }
167
- }
168
-
169
77
  async function initializePipeline(): Promise<void> {
170
78
  if (pipeline) return;
171
79
  if (initPromise) return initPromise;
@@ -175,7 +83,7 @@ async function initializePipeline(): Promise<void> {
175
83
  // Only auto-detect from env on first init. If provider was explicitly
176
84
  // set by a switch-provider message, respect that setting.
177
85
  if (!providerExplicitlySet) {
178
- resolvedProvider = detectExecutionProvider();
86
+ resolvedProvider = detectExecutionProvider("EmbeddingWorker");
179
87
  }
180
88
 
181
89
  // Safety check: if CUDA is requested but LD_LIBRARY_PATH was not set at
@@ -190,7 +98,13 @@ async function initializePipeline(): Promise<void> {
190
98
  resolvedProvider = "cpu";
191
99
  }
192
100
 
193
- const effectiveDevice = resolvedProvider === "cuda" ? "cuda" : config.device;
101
+ // Honor the gate decision. resolvedProvider is already the result of
102
+ // detectExecutionProvider() (CUDA EP .so check) + the LD_LIBRARY_PATH OS-level
103
+ // check above (line 88-95). config.device (set straight from the env var) must
104
+ // NOT override the gate — otherwise a "cuda" env on a host without the EP .so
105
+ // passes device:"cuda" to transformers, which raw-throws on dlopen. Parity with
106
+ // the in-process resolveInProcessDevice() gate (service.ts:472, FIND-IMPL-PR1-H-NEW-01).
107
+ const effectiveDevice: ExecutionProvider = resolveWorkerEffectiveDevice(resolvedProvider);
194
108
 
195
109
  // eslint-disable-next-line no-console
196
110
  console.log("[EmbeddingWorker] Initializing ONNX pipeline", {
@@ -236,6 +150,10 @@ async function initializePipeline(): Promise<void> {
236
150
  console.log("[EmbeddingWorker] ONNX pipeline ready (provider: %s)", resolvedProvider);
237
151
  } catch (error) {
238
152
  initPromise = null;
153
+ const code = (error as { code?: string }).code;
154
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") {
155
+ throw new OnnxRuntimeUnavailableError("text embedding");
156
+ }
239
157
  const message = error instanceof Error ? error.message : "Unknown error";
240
158
  throw new Error(`Failed to load embedding model in worker thread: ${message}`);
241
159
  }
@@ -444,9 +362,17 @@ async function handleMessage(message: WorkerMessage): Promise<void> {
444
362
  case "switch-provider": {
445
363
  const targetProvider = message.provider;
446
364
 
447
- // If switching to CUDA, verify availability first
365
+ // If switching to CUDA, verify availability first. Mirror the init-time
366
+ // AND gate (line ~88): CUDA may proceed only if BOTH the EP .so is present
367
+ // AND LD_LIBRARY_PATH was set at the OS level. Gating on
368
+ // verifyCudaAvailability ALONE (the pre-fix gap, UB-6) would let a host
369
+ // with the EP .so but unset LD_LIBRARY_PATH pass device:"cuda" through →
370
+ // dlopen raw throw. canSwitchToCuda ANDs both checks (init parity).
448
371
  if (targetProvider === "cuda") {
449
- const canUseCuda = verifyCudaAvailability();
372
+ const canUseCuda = canSwitchToCuda(
373
+ () => verifyCudaAvailability("EmbeddingWorker"),
374
+ isLdLibraryPathSetAtOsLevel
375
+ );
450
376
  if (!canUseCuda) {
451
377
  // Cannot switch to CUDA — respond with current (cpu) provider
452
378
  sendResponse({
package/src/index.ts CHANGED
@@ -49,6 +49,33 @@ export { DINOv2Service, DINOV2_EMBEDDING_DIMENSION, DINOV2_INPUT_SIZE } from "./
49
49
  export type { DINOv2ServiceConfig } from "./dinov2/index.js";
50
50
  export type { DINOv2WorkerMessage, DINOv2WorkerResponse } from "./dinov2/index.js";
51
51
 
52
+ // ONNX Runtime availability checker
53
+ export {
54
+ isOnnxRuntimeAvailable,
55
+ OnnxRuntimeUnavailableError,
56
+ safeImportOnnx,
57
+ } from "./onnx-availability.js";
58
+
59
+ // ONNX execution provider detection (CUDA EP `.so` availability gate)
60
+ // Exported so the Phase 5 fork-child VRAM probe can gate CUDA selection on the
61
+ // CUDA EP shared-library availability (FIND-IMPL-PR1-H-NEW-01), not just on
62
+ // free VRAM. `verifyCudaAvailability` is a pure filesystem check (no GPU/native
63
+ // init), safe to call from the fork-child leaf path.
64
+ export {
65
+ detectExecutionProvider,
66
+ verifyCudaAvailability,
67
+ isLdLibraryPathSetAtOsLevel,
68
+ } from "./onnx-provider-detect.js";
69
+ export type { ExecutionProvider } from "./onnx-provider-detect.js";
70
+
71
+ // ML Worker Thread resource limits (PR7e-β1)
72
+ export {
73
+ loadMLWorkerResourceLimits,
74
+ getMLWorkerThreadOptions,
75
+ MLWorkerResourceLimitsSchema,
76
+ } from "./config/worker-resource-limits.js";
77
+ export type { MLWorkerResourceLimits } from "./config/worker-resource-limits.js";
78
+
52
79
  // Search utilities exports (SearchService removed in v0.1.0)
53
80
  export { calculateRRF, mergeWithRRF, normalizeRRFScore, toRankedItems } from "./search/index.js";
54
81
  export { executeHybridSearch } from "./search/index.js";
@@ -0,0 +1,78 @@
1
+ // SPDX-FileCopyrightText: 2026 TKMD and Reftrix Contributors
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * Cached availability checker for onnxruntime-node (optionalDependency).
6
+ * When unavailable, ML features degrade gracefully.
7
+ *
8
+ * @module onnx-availability
9
+ */
10
+
11
+ let _available: boolean | null = null;
12
+ let _unavailableReason: string | null = null;
13
+
14
+ /**
15
+ * Check if onnxruntime-node is available (async, caches result).
16
+ */
17
+ export async function isOnnxRuntimeAvailable(): Promise<boolean> {
18
+ if (_available !== null) return _available;
19
+ try {
20
+ await import("onnxruntime-node");
21
+ _available = true;
22
+ _unavailableReason = null;
23
+ } catch {
24
+ _available = false;
25
+ _unavailableReason = "onnxruntime-node is not installed";
26
+ }
27
+ return _available;
28
+ }
29
+
30
+ /**
31
+ * Synchronous check — returns null if not yet probed.
32
+ */
33
+ export function isOnnxRuntimeAvailableSync(): boolean | null {
34
+ return _available;
35
+ }
36
+
37
+ /**
38
+ * Returns the reason onnxruntime-node is unavailable, or null if available.
39
+ */
40
+ export function getOnnxUnavailableReason(): string | null {
41
+ return _unavailableReason;
42
+ }
43
+
44
+ /**
45
+ * Error thrown when ML features require onnxruntime-node but it is not installed.
46
+ */
47
+ export class OnnxRuntimeUnavailableError extends Error {
48
+ constructor(context?: string) {
49
+ super(
50
+ `ML features require onnxruntime-node which is not available. ` +
51
+ `Install with: pnpm add onnxruntime-node` +
52
+ (context ? ` (${context})` : "")
53
+ );
54
+ this.name = "OnnxRuntimeUnavailableError";
55
+ }
56
+ }
57
+
58
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
59
+ type OnnxRuntimeModule = typeof import("onnxruntime-node");
60
+
61
+ /**
62
+ * Safely import onnxruntime-node with a sanitized error on failure.
63
+ */
64
+ export async function safeImportOnnx(): Promise<OnnxRuntimeModule> {
65
+ try {
66
+ return await import("onnxruntime-node");
67
+ } catch {
68
+ throw new OnnxRuntimeUnavailableError();
69
+ }
70
+ }
71
+
72
+ /**
73
+ * For testing: reset cached state.
74
+ */
75
+ export function _resetForTesting(): void {
76
+ _available = null;
77
+ _unavailableReason = null;
78
+ }