specmem-hardwicksoftware 3.5.99 → 3.6.1

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/dist/index.js CHANGED
@@ -286,6 +286,12 @@ class LocalEmbeddingProvider {
286
286
  socketConnected = false;
287
287
  socketReconnecting = false;
288
288
  pendingRequests = new Map();
289
+ // FIX Issue #1: Track active sockets to prevent FD leaks over 24h+ sessions
290
+ // Each entry: { socket, createdAt, label }
291
+ activeSockets = new Set();
292
+ _socketCleanupInterval = null;
293
+ // FIX Issue #6: Track pending request ages for stale cleanup
294
+ _pendingCleanupInterval = null;
289
295
  // Timeout values - now centralized in config/embeddingTimeouts.ts
290
296
  // Set SPECMEM_EMBEDDING_TIMEOUT (in seconds) to control ALL timeouts at once
291
297
  // See config/embeddingTimeouts.ts for full documentation
@@ -327,6 +333,131 @@ class LocalEmbeddingProvider {
327
333
  }
328
334
  // Initialize persistent socket connection
329
335
  this.initPersistentSocket();
336
+ // FIX Issue #1: Start periodic socket FD cleanup
337
+ this._startSocketCleanup();
338
+ // FIX Issue #6: Start periodic stale pendingRequests cleanup
339
+ this._startPendingRequestsCleanup();
340
+ }
341
+ /**
342
+ * FIX Issue #1: Track a socket in the active set for FD leak prevention.
343
+ * Every socket created via createConnection() MUST be registered here.
344
+ */
345
+ _trackSocket(socket, label) {
346
+ const entry = { socket, createdAt: Date.now(), label };
347
+ this.activeSockets.add(entry);
348
+ // Auto-remove from tracking when socket is fully closed/destroyed
349
+ const removeFromTracking = () => {
350
+ this.activeSockets.delete(entry);
351
+ };
352
+ socket.once('close', removeFromTracking);
353
+ // If socket is already destroyed, remove immediately
354
+ if (socket.destroyed) {
355
+ this.activeSockets.delete(entry);
356
+ }
357
+ return entry;
358
+ }
359
+ /**
360
+ * FIX Issue #1: Periodic cleanup of leaked socket FDs.
361
+ * Destroys sockets that have been open longer than SPECMEM_SOCKET_MAX_AGE_MS.
362
+ * Runs every SPECMEM_SOCKET_CLEANUP_INTERVAL_MS (default 5min).
363
+ */
364
+ _startSocketCleanup() {
365
+ if (this._socketCleanupInterval) {
366
+ clearInterval(this._socketCleanupInterval);
367
+ }
368
+ const cleanupIntervalMs = parseInt(process.env['SPECMEM_SOCKET_CLEANUP_INTERVAL_MS'] || '300000', 10);
369
+ const maxAgeMs = parseInt(process.env['SPECMEM_SOCKET_MAX_AGE_MS'] || '60000', 10);
370
+ this._socketCleanupInterval = setInterval(() => {
371
+ const now = Date.now();
372
+ let cleaned = 0;
373
+ for (const entry of this.activeSockets) {
374
+ const age = now - entry.createdAt;
375
+ if (age > maxAgeMs) {
376
+ try {
377
+ if (!entry.socket.destroyed) {
378
+ entry.socket.destroy();
379
+ cleaned++;
380
+ logger.debug({
381
+ label: entry.label,
382
+ ageMs: age,
383
+ maxAgeMs
384
+ }, 'LocalEmbeddingProvider: cleaned up stale socket (FD leak prevention)');
385
+ }
386
+ }
387
+ catch (e) {
388
+ // Ignore destroy errors during cleanup
389
+ }
390
+ this.activeSockets.delete(entry);
391
+ }
392
+ }
393
+ if (cleaned > 0) {
394
+ logger.info({
395
+ cleaned,
396
+ remaining: this.activeSockets.size,
397
+ maxAgeMs,
398
+ cleanupIntervalMs
399
+ }, 'LocalEmbeddingProvider: periodic socket FD cleanup completed');
400
+ }
401
+ }, cleanupIntervalMs);
402
+ // Don't let cleanup interval prevent process exit
403
+ if (this._socketCleanupInterval.unref) {
404
+ this._socketCleanupInterval.unref();
405
+ }
406
+ logger.debug({
407
+ cleanupIntervalMs,
408
+ maxAgeMs
409
+ }, 'LocalEmbeddingProvider: socket FD cleanup started');
410
+ }
411
+ /**
412
+ * FIX Issue #6: Periodic cleanup of stale pendingRequests entries.
413
+ * Rejects and removes requests older than SPECMEM_PENDING_REQUEST_MAX_AGE_MS.
414
+ * Runs every SPECMEM_PENDING_CLEANUP_INTERVAL_MS (default 60s).
415
+ */
416
+ _startPendingRequestsCleanup() {
417
+ if (this._pendingCleanupInterval) {
418
+ clearInterval(this._pendingCleanupInterval);
419
+ }
420
+ const cleanupIntervalMs = parseInt(process.env['SPECMEM_PENDING_CLEANUP_INTERVAL_MS'] || '60000', 10);
421
+ const maxAgeMs = parseInt(process.env['SPECMEM_PENDING_REQUEST_MAX_AGE_MS'] || '120000', 10);
422
+ this._pendingCleanupInterval = setInterval(() => {
423
+ const now = Date.now();
424
+ let cleaned = 0;
425
+ for (const [requestId, pending] of this.pendingRequests) {
426
+ const age = now - (pending.createdAt || 0);
427
+ if (age > maxAgeMs) {
428
+ clearTimeout(pending.timeout);
429
+ this.pendingRequests.delete(requestId);
430
+ cleaned++;
431
+ try {
432
+ pending.reject(new Error(
433
+ `Stale pending request cleaned up after ${Math.round(age / 1000)}s ` +
434
+ `(max age: ${Math.round(maxAgeMs / 1000)}s). ` +
435
+ `Request ID: ${requestId}. ` +
436
+ `Increase SPECMEM_PENDING_REQUEST_MAX_AGE_MS if embeddings are legitimately slow.`
437
+ ));
438
+ }
439
+ catch (e) {
440
+ // Ignore rejection errors (promise may already be settled)
441
+ }
442
+ }
443
+ }
444
+ if (cleaned > 0) {
445
+ logger.warn({
446
+ cleaned,
447
+ remaining: this.pendingRequests.size,
448
+ maxAgeMs,
449
+ cleanupIntervalMs
450
+ }, 'LocalEmbeddingProvider: cleaned up stale pending requests (memory leak prevention)');
451
+ }
452
+ }, cleanupIntervalMs);
453
+ // Don't let cleanup interval prevent process exit
454
+ if (this._pendingCleanupInterval.unref) {
455
+ this._pendingCleanupInterval.unref();
456
+ }
457
+ logger.debug({
458
+ cleanupIntervalMs,
459
+ maxAgeMs
460
+ }, 'LocalEmbeddingProvider: pending requests cleanup started');
330
461
  }
331
462
  /**
332
463
  * Initialize the persistent socket connection
@@ -392,6 +523,8 @@ class LocalEmbeddingProvider {
392
523
  logger.debug({ socketPath: this.sandboxSocketPath }, 'LocalEmbeddingProvider: initializing persistent socket');
393
524
  try {
394
525
  this.persistentSocket = createConnection(this.sandboxSocketPath);
526
+ // FIX Issue #1: Track persistent socket for FD leak prevention
527
+ this._trackSocket(this.persistentSocket, 'persistent');
395
528
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'INIT_PERSISTENT_SOCKET_CONNECTION_CREATED', {
396
529
  elapsedMs: Date.now() - methodStart
397
530
  });
@@ -580,6 +713,18 @@ class LocalEmbeddingProvider {
580
713
  pending.reject(new Error('Socket reset by user'));
581
714
  }
582
715
  this.pendingRequests.clear();
716
+ // FIX Issue #1: Destroy all tracked active sockets on reset
717
+ for (const entry of this.activeSockets) {
718
+ try {
719
+ if (!entry.socket.destroyed) {
720
+ entry.socket.destroy();
721
+ }
722
+ }
723
+ catch (e) {
724
+ // Ignore cleanup errors
725
+ }
726
+ }
727
+ this.activeSockets.clear();
583
728
  // Re-detect socket path and reinitialize
584
729
  this.sandboxSocketPath = getEmbeddingSocketPath();
585
730
  this.initPersistentSocket();
@@ -1243,6 +1388,41 @@ class LocalEmbeddingProvider {
1243
1388
  }
1244
1389
  catch { /* proceed to spawn */ }
1245
1390
  }
1391
+ // ═══════════════════════════════════════════════════════════════════════════
1392
+ // ORPHAN KILL: Kill any existing Frankenstein process BEFORE spawning new one
1393
+ // This prevents multiple processes fighting over the same socket
1394
+ // ═══════════════════════════════════════════════════════════════════════════
1395
+ const pidFile = join(socketDir, 'embedding.pid');
1396
+ try {
1397
+ if (existsSync(pidFile)) {
1398
+ const pidContent = readFileSync(pidFile, 'utf8').trim();
1399
+ const oldPid = parseInt(pidContent.split(':')[0], 10);
1400
+ if (oldPid && !isNaN(oldPid)) {
1401
+ try {
1402
+ process.kill(oldPid, 0); // Check if alive
1403
+ logger.info({ pid: oldPid }, '[LocalEmbeddingProvider] Killing existing embedding process before respawn');
1404
+ process.kill(oldPid, 'SIGTERM');
1405
+ // Give it 2s to die gracefully
1406
+ const killWaitMs = parseInt(process.env['SPECMEM_ORPHAN_KILL_WAIT_MS'] || '2000', 10);
1407
+ await new Promise(r => setTimeout(r, killWaitMs));
1408
+ try {
1409
+ process.kill(oldPid, 0); // Still alive?
1410
+ process.kill(oldPid, 'SIGKILL');
1411
+ logger.warn({ pid: oldPid }, '[LocalEmbeddingProvider] Force killed stubborn embedding process');
1412
+ } catch { /* dead - good */ }
1413
+ } catch { /* not running - fine */ }
1414
+ }
1415
+ }
1416
+ } catch (pidErr) {
1417
+ logger.debug({ error: pidErr }, '[LocalEmbeddingProvider] PID file cleanup failed (non-fatal)');
1418
+ }
1419
+ // Also remove stale socket file to prevent bind failures
1420
+ if (existsSync(socketPath)) {
1421
+ try {
1422
+ unlinkSync(socketPath);
1423
+ logger.debug('[LocalEmbeddingProvider] Removed stale socket before respawn');
1424
+ } catch { /* ignore */ }
1425
+ }
1246
1426
  // Find the embedding server script
1247
1427
  // Try multiple locations: SPECMEM_PKG env, relative to this file, project specmem dir
1248
1428
  // Docker location via env var (defaults to /opt/specmem if SPECMEM_DOCKER_PATH set)
@@ -1769,9 +1949,22 @@ class LocalEmbeddingProvider {
1769
1949
  return new Promise((resolve, reject) => {
1770
1950
  const socketPath = getEmbeddingSocketPath();
1771
1951
  const socket = createConnection(socketPath);
1952
+ // FIX Issue #1: Track socket for FD leak prevention
1953
+ this._trackSocket(socket, `batch-${texts.length}`);
1772
1954
  let buffer = '';
1773
1955
  let resolved = false;
1774
1956
  const startTime = Date.now();
1957
+ // FIX Issue #1: Ensure socket is destroyed on all exit paths
1958
+ const ensureSocketCleanup = () => {
1959
+ try {
1960
+ if (!socket.destroyed) {
1961
+ socket.destroy();
1962
+ }
1963
+ }
1964
+ catch (e) {
1965
+ // Ignore cleanup errors
1966
+ }
1967
+ };
1775
1968
  // Longer timeout for batches - scale with batch size
1776
1969
  const baseTimeout = this.getAdaptiveTimeout();
1777
1970
  const timeoutMs = Math.min(baseTimeout * Math.ceil(texts.length / 10), 300000); // Max 5 min
@@ -1783,7 +1976,7 @@ class LocalEmbeddingProvider {
1783
1976
  let timeout = setTimeout(() => {
1784
1977
  if (!resolved) {
1785
1978
  resolved = true;
1786
- socket.destroy();
1979
+ ensureSocketCleanup();
1787
1980
  reject(new Error(`Batch embedding timeout after ${Math.round(timeoutMs / 1000)}s for ${texts.length} texts`));
1788
1981
  }
1789
1982
  }, timeoutMs);
@@ -1803,7 +1996,7 @@ class LocalEmbeddingProvider {
1803
1996
  timeout = setTimeout(() => {
1804
1997
  if (!resolved) {
1805
1998
  resolved = true;
1806
- socket.destroy();
1999
+ ensureSocketCleanup();
1807
2000
  reject(new Error(`Batch embedding idle timeout for ${texts.length} texts`));
1808
2001
  }
1809
2002
  }, timeoutMs);
@@ -1830,7 +2023,7 @@ class LocalEmbeddingProvider {
1830
2023
  // Got actual response - resolve or reject
1831
2024
  clearTimeout(timeout);
1832
2025
  resolved = true;
1833
- socket.destroy();
2026
+ ensureSocketCleanup();
1834
2027
  const responseTime = Date.now() - startTime;
1835
2028
  this.recordResponseTime(responseTime / texts.length); // Per-text average
1836
2029
  if (response.error) {
@@ -1852,7 +2045,7 @@ class LocalEmbeddingProvider {
1852
2045
  catch (err) {
1853
2046
  clearTimeout(timeout);
1854
2047
  resolved = true;
1855
- socket.destroy();
2048
+ ensureSocketCleanup();
1856
2049
  reject(new Error(`Failed to parse batch embedding response: ${err}`));
1857
2050
  }
1858
2051
  }
@@ -1861,6 +2054,7 @@ class LocalEmbeddingProvider {
1861
2054
  if (!resolved) {
1862
2055
  resolved = true;
1863
2056
  clearTimeout(timeout);
2057
+ ensureSocketCleanup();
1864
2058
  reject(err);
1865
2059
  }
1866
2060
  });
@@ -2161,6 +2355,8 @@ class LocalEmbeddingProvider {
2161
2355
  try {
2162
2356
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'WARMING_SOCKET', { socketPath });
2163
2357
  this.warmSocket = createConnection(socketPath);
2358
+ // FIX Issue #1: Track warm socket for FD leak prevention
2359
+ this._trackSocket(this.warmSocket, 'warm');
2164
2360
  this.warmSocketPath = socketPath;
2165
2361
  this.warmSocket.on('connect', () => {
2166
2362
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'WARM_SOCKET_CONNECTED', { socketPath });
@@ -2400,10 +2596,23 @@ class LocalEmbeddingProvider {
2400
2596
  generateWithDirectSocketAttempt(text, socketPath, attempt) {
2401
2597
  return new Promise((resolve, reject) => {
2402
2598
  const socket = createConnection(socketPath);
2599
+ // FIX Issue #1: Track socket for FD leak prevention
2600
+ this._trackSocket(socket, `direct-attempt-${attempt}`);
2403
2601
  let buffer = '';
2404
2602
  let resolved = false;
2405
2603
  const startTime = Date.now();
2406
2604
  const timeoutMs = this.getAdaptiveTimeout();
2605
+ // FIX Issue #1: Ensure socket is destroyed on all exit paths
2606
+ const ensureSocketCleanup = () => {
2607
+ try {
2608
+ if (!socket.destroyed) {
2609
+ socket.destroy();
2610
+ }
2611
+ }
2612
+ catch (e) {
2613
+ // Ignore cleanup errors
2614
+ }
2615
+ };
2407
2616
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'DIRECT_SOCKET_CONNECTING', {
2408
2617
  socketPath,
2409
2618
  attempt,
@@ -2414,7 +2623,7 @@ class LocalEmbeddingProvider {
2414
2623
  let timeout = setTimeout(() => {
2415
2624
  if (!resolved) {
2416
2625
  resolved = true;
2417
- socket.destroy();
2626
+ ensureSocketCleanup(); // FIX Issue #1: Use cleanup helper
2418
2627
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'DIRECT_SOCKET_INITIAL_TIMEOUT', {
2419
2628
  socketPath,
2420
2629
  attempt,
@@ -2443,7 +2652,7 @@ class LocalEmbeddingProvider {
2443
2652
  timeout = setTimeout(() => {
2444
2653
  if (!resolved) {
2445
2654
  resolved = true;
2446
- socket.destroy();
2655
+ ensureSocketCleanup(); // FIX Issue #1: Use cleanup helper
2447
2656
  __debugLog('[EMBEDDING DEBUG]', Date.now(), 'DIRECT_SOCKET_IDLE_TIMEOUT', {
2448
2657
  socketPath,
2449
2658
  attempt,
@@ -2500,14 +2709,14 @@ class LocalEmbeddingProvider {
2500
2709
  else {
2501
2710
  reject(new Error(`Invalid response from embedding service (socket: ${socketPath})`));
2502
2711
  }
2503
- socket.end();
2712
+ ensureSocketCleanup(); // FIX Issue #1: destroy instead of end
2504
2713
  return;
2505
2714
  }
2506
2715
  catch (err) {
2507
2716
  clearTimeout(timeout);
2508
2717
  resolved = true;
2509
2718
  reject(new Error(`Failed to parse embedding response: ${err instanceof Error ? err.message : err} (socket: ${socketPath})`));
2510
- socket.end();
2719
+ ensureSocketCleanup(); // FIX Issue #1: destroy instead of end
2511
2720
  return;
2512
2721
  }
2513
2722
  }
@@ -2521,6 +2730,7 @@ class LocalEmbeddingProvider {
2521
2730
  });
2522
2731
  if (!resolved) {
2523
2732
  resolved = true;
2733
+ ensureSocketCleanup(); // FIX Issue #1: Ensure socket destroyed on error
2524
2734
  reject(new Error(`Socket error: ${err.message} (socket: ${socketPath})`));
2525
2735
  }
2526
2736
  });
@@ -2698,7 +2908,8 @@ class LocalEmbeddingProvider {
2698
2908
  resolve(embedding);
2699
2909
  },
2700
2910
  reject,
2701
- timeout
2911
+ timeout,
2912
+ createdAt: Date.now() // FIX Issue #6: Track creation time for stale cleanup
2702
2913
  });
2703
2914
  // Send request with ID so we can match responses
2704
2915
  const request = JSON.stringify({ type: 'embed', text, requestId }) + '\n';
@@ -2765,15 +2976,28 @@ class LocalEmbeddingProvider {
2765
2976
  generateWithNewSocketAttempt(text, attempt) {
2766
2977
  return new Promise((resolve, reject) => {
2767
2978
  const socket = createConnection(this.sandboxSocketPath);
2979
+ // FIX Issue #1: Track socket for FD leak prevention
2980
+ this._trackSocket(socket, `new-attempt-${attempt}`);
2768
2981
  let buffer = '';
2769
2982
  let resolved = false;
2770
2983
  const startTime = Date.now();
2771
2984
  const timeoutMs = this.getAdaptiveTimeout();
2985
+ // FIX Issue #1: Ensure socket is destroyed on all exit paths
2986
+ const ensureSocketCleanup = () => {
2987
+ try {
2988
+ if (!socket.destroyed) {
2989
+ socket.destroy();
2990
+ }
2991
+ }
2992
+ catch (e) {
2993
+ // Ignore cleanup errors
2994
+ }
2995
+ };
2772
2996
  // IDLE-BASED TIMEOUT: Resets on any data received
2773
2997
  let timeout = setTimeout(() => {
2774
2998
  if (!resolved) {
2775
2999
  resolved = true;
2776
- socket.destroy();
3000
+ ensureSocketCleanup();
2777
3001
  reject(new Error(`Embedding idle timeout after ${Math.round(timeoutMs / 1000)}s of no activity ` +
2778
3002
  `(attempt ${attempt}/${LocalEmbeddingProvider.SOCKET_MAX_RETRIES}). ` +
2779
3003
  `Socket: ${this.sandboxSocketPath}. ` +
@@ -2791,7 +3015,7 @@ class LocalEmbeddingProvider {
2791
3015
  timeout = setTimeout(() => {
2792
3016
  if (!resolved) {
2793
3017
  resolved = true;
2794
- socket.destroy();
3018
+ ensureSocketCleanup();
2795
3019
  reject(new Error(`Embedding idle timeout after ${Math.round(timeoutMs / 1000)}s of no activity ` +
2796
3020
  `(attempt ${attempt}/${LocalEmbeddingProvider.SOCKET_MAX_RETRIES}). ` +
2797
3021
  `Socket: ${this.sandboxSocketPath}. ` +
@@ -2825,14 +3049,14 @@ class LocalEmbeddingProvider {
2825
3049
  else {
2826
3050
  reject(new Error(`Invalid response from embedding service (socket: ${this.sandboxSocketPath})`));
2827
3051
  }
2828
- socket.end();
3052
+ ensureSocketCleanup(); // FIX Issue #1: destroy instead of end
2829
3053
  return;
2830
3054
  }
2831
3055
  catch (err) {
2832
3056
  clearTimeout(timeout);
2833
3057
  resolved = true;
2834
3058
  reject(new Error(`Failed to parse embedding response: ${err instanceof Error ? err.message : err} (socket: ${this.sandboxSocketPath})`));
2835
- socket.end();
3059
+ ensureSocketCleanup(); // FIX Issue #1: destroy instead of end
2836
3060
  return;
2837
3061
  }
2838
3062
  }
@@ -2841,6 +3065,7 @@ class LocalEmbeddingProvider {
2841
3065
  clearTimeout(timeout);
2842
3066
  if (!resolved) {
2843
3067
  resolved = true;
3068
+ ensureSocketCleanup(); // FIX Issue #1: Ensure socket destroyed on error
2844
3069
  reject(new Error(`Socket error: ${err.message} (socket: ${this.sandboxSocketPath})`));
2845
3070
  }
2846
3071
  });
@@ -141,8 +141,8 @@ SPECMEM_DB_PASSWORD=specmem_westayunprofessional
141
141
  SPECMEM_COORDINATION_ENABLED=true
142
142
  SPECMEM_COORDINATION_PORT=3001
143
143
 
144
- # Watcher Configuration (OPTIONAL)
145
- SPECMEM_WATCHER_ENABLED=false
144
+ # Watcher Configuration
145
+ SPECMEM_WATCHER_ENABLED=true
146
146
  SPECMEM_WATCHER_ROOT_PATH=
147
147
  SPECMEM_WATCHER_IGNORE_PATH=
148
148