tywrap 0.1.0 → 0.1.2

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 (63) hide show
  1. package/README.md +54 -221
  2. package/dist/cli.js +70 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +56 -12
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +6 -2
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/core/mapper.d.ts.map +1 -1
  11. package/dist/core/mapper.js +20 -5
  12. package/dist/core/mapper.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/bridge-core.d.ts +64 -0
  18. package/dist/runtime/bridge-core.d.ts.map +1 -0
  19. package/dist/runtime/bridge-core.js +344 -0
  20. package/dist/runtime/bridge-core.js.map +1 -0
  21. package/dist/runtime/node.d.ts +5 -6
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +95 -193
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +4 -7
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +117 -186
  28. package/dist/runtime/optimized-node.js.map +1 -1
  29. package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
  30. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
  31. package/dist/runtime/timed-out-request-tracker.js +48 -0
  32. package/dist/runtime/timed-out-request-tracker.js.map +1 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/tywrap.d.ts +17 -4
  36. package/dist/tywrap.d.ts.map +1 -1
  37. package/dist/tywrap.js +68 -35
  38. package/dist/tywrap.js.map +1 -1
  39. package/dist/utils/codec.d.ts +22 -0
  40. package/dist/utils/codec.d.ts.map +1 -1
  41. package/dist/utils/codec.js +245 -55
  42. package/dist/utils/codec.js.map +1 -1
  43. package/dist/utils/logger.d.ts.map +1 -1
  44. package/dist/utils/logger.js +7 -4
  45. package/dist/utils/logger.js.map +1 -1
  46. package/dist/utils/python.d.ts.map +1 -1
  47. package/dist/utils/python.js.map +1 -1
  48. package/package.json +11 -2
  49. package/runtime/python_bridge.py +450 -69
  50. package/src/cli.ts +81 -5
  51. package/src/config/index.ts +67 -16
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +21 -8
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +117 -224
  57. package/src/runtime/optimized-node.ts +160 -235
  58. package/src/runtime/timed-out-request-tracker.ts +58 -0
  59. package/src/types/index.ts +3 -0
  60. package/src/tywrap.ts +91 -36
  61. package/src/utils/codec.ts +306 -83
  62. package/src/utils/logger.ts +11 -7
  63. package/src/utils/python.ts +0 -1
@@ -5,35 +5,30 @@
5
5
 
6
6
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createRequire } from 'node:module';
8
9
  import type { ChildProcess } from 'child_process';
9
10
  import { EventEmitter } from 'events';
10
11
 
11
12
  import { globalCache } from '../utils/cache.js';
12
- import { decodeValueAsync } from '../utils/codec.js';
13
+ import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
13
14
  import { getDefaultPythonPath } from '../utils/python.js';
14
15
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
15
16
 
16
17
  import { RuntimeBridge } from './base.js';
17
- import { BridgeExecutionError, BridgeProtocolError } from './errors.js';
18
- import { TYWRAP_PROTOCOL } from './protocol.js';
18
+ import { BridgeProtocolError } from './errors.js';
19
+ import {
20
+ BridgeCore,
21
+ type RpcRequest,
22
+ ensureJsonFallback,
23
+ ensurePythonEncoding,
24
+ getMaxLineLengthFromEnv,
25
+ getPathKey,
26
+ normalizeEnv,
27
+ } from './bridge-core.js';
19
28
  import { getComponentLogger } from '../utils/logger.js';
20
29
 
21
30
  const log = getComponentLogger('OptimizedBridge');
22
31
 
23
- interface RpcRequest {
24
- id: number;
25
- protocol: string;
26
- method: 'call' | 'instantiate' | 'call_method' | 'dispose_instance';
27
- params: unknown;
28
- }
29
-
30
- interface RpcResponse<T = unknown> {
31
- id: number;
32
- protocol: string;
33
- result?: T;
34
- error?: { type: string; message: string; traceback?: string };
35
- }
36
-
37
32
  interface ProcessPoolOptions {
38
33
  minProcesses?: number;
39
34
  maxProcesses?: number;
@@ -44,27 +39,38 @@ interface ProcessPoolOptions {
44
39
  virtualEnv?: string | undefined;
45
40
  cwd?: string;
46
41
  timeoutMs?: number;
42
+ maxLineLength?: number;
47
43
  enableJsonFallback?: boolean;
44
+ enableCache?: boolean;
48
45
  env?: Record<string, string | undefined>;
49
46
  warmupCommands?: Array<{ method: string; params: unknown }>; // Commands to warm up processes
50
47
  }
51
48
 
49
+ interface ResolvedProcessPoolOptions {
50
+ minProcesses: number;
51
+ maxProcesses: number;
52
+ maxIdleTime: number;
53
+ maxRequestsPerProcess: number;
54
+ pythonPath: string;
55
+ scriptPath: string;
56
+ virtualEnv?: string;
57
+ cwd: string;
58
+ timeoutMs: number;
59
+ maxLineLength?: number;
60
+ enableJsonFallback: boolean;
61
+ enableCache: boolean;
62
+ env: Record<string, string | undefined>;
63
+ warmupCommands: Array<{ method: string; params: unknown }>;
64
+ }
65
+
52
66
  interface WorkerProcess {
53
67
  process: ChildProcess;
54
68
  id: string;
55
69
  requestCount: number;
56
70
  lastUsed: number;
57
71
  busy: boolean;
58
- buffer: string;
59
- pendingRequests: Map<
60
- number,
61
- {
62
- resolve: (value: unknown) => void;
63
- reject: (error: unknown) => void;
64
- timer?: NodeJS.Timeout;
65
- startTime: number;
66
- }
67
- >;
72
+ quarantined: boolean;
73
+ core: BridgeCore;
68
74
  stats: {
69
75
  totalRequests: number;
70
76
  totalTime: number;
@@ -125,9 +131,8 @@ function resolveVirtualEnv(
125
131
  export class OptimizedNodeBridge extends RuntimeBridge {
126
132
  private processPool: WorkerProcess[] = [];
127
133
  private roundRobinIndex = 0;
128
- private nextId = 1;
129
134
  private cleanupTimer?: NodeJS.Timeout;
130
- private options: Required<ProcessPoolOptions>;
135
+ private options: ResolvedProcessPoolOptions;
131
136
  private emitter = new EventEmitter();
132
137
  private disposed = false;
133
138
 
@@ -148,7 +153,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
148
153
  constructor(options: ProcessPoolOptions = {}) {
149
154
  super();
150
155
  const cwd = options.cwd ?? process.cwd();
151
- const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : '';
156
+ const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
152
157
  const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
153
158
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
154
159
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
@@ -162,7 +167,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
162
167
  virtualEnv,
163
168
  cwd,
164
169
  timeoutMs: options.timeoutMs ?? 30000,
170
+ maxLineLength: options.maxLineLength,
165
171
  enableJsonFallback: options.enableJsonFallback ?? false,
172
+ enableCache: options.enableCache ?? false,
166
173
  env: options.env ?? {},
167
174
  warmupCommands: options.warmupCommands ?? [],
168
175
  };
@@ -176,6 +183,11 @@ export class OptimizedNodeBridge extends RuntimeBridge {
176
183
  throw new Error('Bridge has been disposed');
177
184
  }
178
185
 
186
+ const require = createRequire(import.meta.url);
187
+ await autoRegisterArrowDecoder({
188
+ loader: () => require('apache-arrow'),
189
+ });
190
+
179
191
  // Ensure minimum processes are available
180
192
  while (this.processPool.length < this.options.minProcesses) {
181
193
  await this.spawnProcess();
@@ -195,13 +207,17 @@ export class OptimizedNodeBridge extends RuntimeBridge {
195
207
  ): Promise<T> {
196
208
  const startTime = performance.now();
197
209
 
198
- // Try cache first for pure functions
199
- const cacheKey = globalCache.generateKey('runtime_call', module, functionName, args, kwargs);
200
- const cached = await globalCache.get<T>(cacheKey);
201
- if (cached !== null) {
202
- this.stats.cacheHits++;
203
- // Runtime cache HIT for ${module}.${functionName}
204
- return cached;
210
+ const cacheKey = this.options.enableCache
211
+ ? this.safeCacheKey('runtime_call', module, functionName, args, kwargs)
212
+ : null;
213
+ if (cacheKey) {
214
+ const cached = await globalCache.get<T>(cacheKey);
215
+ if (cached !== null) {
216
+ this.stats.cacheHits++;
217
+ this.updateStats(performance.now() - startTime);
218
+ // Runtime cache HIT for ${module}.${functionName}
219
+ return cached;
220
+ }
205
221
  }
206
222
 
207
223
  try {
@@ -213,7 +229,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
213
229
  const duration = performance.now() - startTime;
214
230
 
215
231
  // Cache result for pure functions (simple heuristic)
216
- if (this.isPureFunctionCandidate(functionName, args)) {
232
+ if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
217
233
  await globalCache.set(cacheKey, result, {
218
234
  computeTime: duration,
219
235
  dependencies: [module],
@@ -306,7 +322,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
306
322
  * Select optimal worker based on load and performance
307
323
  */
308
324
  private selectOptimalWorker(): WorkerProcess | null {
309
- const availableWorkers = this.processPool.filter(w => !w.busy && w.process.connected);
325
+ const availableWorkers = this.processPool.filter(
326
+ w => !w.busy && !w.quarantined && w.process.exitCode === null
327
+ );
310
328
 
311
329
  if (availableWorkers.length === 0) {
312
330
  return null;
@@ -350,58 +368,49 @@ export class OptimizedNodeBridge extends RuntimeBridge {
350
368
  worker: WorkerProcess,
351
369
  payload: Omit<RpcRequest, 'id' | 'protocol'>
352
370
  ): Promise<T> {
353
- const id = this.nextId++;
354
- const message: RpcRequest = { id, protocol: TYWRAP_PROTOCOL, ...payload } as RpcRequest;
355
- const text = `${JSON.stringify(message)}\n`;
356
-
357
371
  worker.busy = true;
358
372
  worker.requestCount++;
359
373
  worker.lastUsed = Date.now();
360
374
 
361
- return new Promise<T>((resolvePromise, reject) => {
362
- const startTime = performance.now();
363
-
364
- const timer = setTimeout(() => {
365
- worker.pendingRequests.delete(id);
366
- worker.busy = false;
367
- reject(new Error(`Request ${id} timed out after ${this.options.timeoutMs}ms`));
368
- }, this.options.timeoutMs);
369
-
370
- worker.pendingRequests.set(id, {
371
- resolve: (value: unknown) => {
372
- const duration = performance.now() - startTime;
373
- worker.stats.totalTime += duration;
374
- worker.stats.totalRequests++;
375
- worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
376
- worker.busy = false;
377
- resolvePromise(value as T);
378
- },
379
- reject: (error: unknown) => {
380
- worker.stats.errorCount++;
381
- worker.busy = false;
382
- reject(error);
383
- },
384
- timer,
385
- startTime,
386
- });
375
+ const startTime = performance.now();
387
376
 
388
- if (!worker.process.stdin?.writable) {
389
- worker.pendingRequests.delete(id);
390
- worker.busy = false;
391
- clearTimeout(timer);
392
- reject(new Error('Worker process stdin not writable'));
393
- return;
394
- }
377
+ try {
378
+ const result = await worker.core.send<T>(payload);
379
+ const duration = performance.now() - startTime;
380
+ worker.stats.totalTime += duration;
381
+ worker.stats.totalRequests++;
382
+ worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
383
+ return result;
384
+ } catch (error) {
385
+ worker.stats.errorCount++;
386
+ throw error;
387
+ } finally {
388
+ worker.busy = false;
389
+ }
390
+ }
395
391
 
396
- try {
397
- worker.process.stdin.write(text);
398
- } catch (error) {
399
- worker.pendingRequests.delete(id);
400
- worker.busy = false;
401
- clearTimeout(timer);
402
- reject(error);
403
- }
404
- });
392
+ private quarantineWorker(worker: WorkerProcess, error: Error): void {
393
+ if (worker.quarantined) {
394
+ return;
395
+ }
396
+ worker.quarantined = true;
397
+ log.warn('Quarantining worker', { workerId: worker.id, error: String(error) });
398
+ this.terminateWorker(worker, { force: true })
399
+ .then(() => {
400
+ if (!this.disposed && this.processPool.length < this.options.minProcesses) {
401
+ this.spawnProcess().catch(spawnError => {
402
+ log.error('Failed to spawn replacement worker after quarantine', {
403
+ error: String(spawnError),
404
+ });
405
+ });
406
+ }
407
+ })
408
+ .catch(terminateError => {
409
+ log.warn('Failed to terminate quarantined worker', {
410
+ workerId: worker.id,
411
+ error: String(terminateError),
412
+ });
413
+ });
405
414
  }
406
415
 
407
416
  /**
@@ -412,27 +421,24 @@ export class OptimizedNodeBridge extends RuntimeBridge {
412
421
 
413
422
  const workerId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
414
423
 
415
- const env: NodeJS.ProcessEnv = {
416
- ...process.env,
417
- ...this.options.env,
418
- PYTHONUNBUFFERED: '1', // Ensure immediate output
419
- PYTHONDONTWRITEBYTECODE: '1', // Skip .pyc files for faster startup
420
- };
421
- if (!env.PYTHONUTF8) {
422
- env.PYTHONUTF8 = '1';
423
- }
424
- if (!env.PYTHONIOENCODING) {
425
- env.PYTHONIOENCODING = 'UTF-8';
426
- }
424
+ let env = normalizeEnv(process.env as Record<string, string | undefined>, this.options.env);
425
+ env.PYTHONUNBUFFERED = '1'; // Ensure immediate output
426
+ env.PYTHONDONTWRITEBYTECODE = '1'; // Skip .pyc files for faster startup
427
+ ensurePythonEncoding(env);
427
428
  if (this.options.virtualEnv) {
428
429
  const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
429
430
  env.VIRTUAL_ENV = venv.venvPath;
430
- env.PATH = `${venv.binDir}${delimiter}${env.PATH ?? ''}`;
431
+ const pathKey = getPathKey(env);
432
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
433
+ const currentPath = env[pathKey] ?? '';
434
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
435
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
431
436
  }
432
437
 
433
- if (this.options.enableJsonFallback && !env.TYWRAP_CODEC_FALLBACK) {
434
- env.TYWRAP_CODEC_FALLBACK = 'json';
435
- }
438
+ ensureJsonFallback(env, this.options.enableJsonFallback);
439
+
440
+ env = normalizeEnv(env, {});
441
+ const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
436
442
 
437
443
  const childProcess = spawn(this.options.pythonPath, [this.options.scriptPath], {
438
444
  cwd: this.options.cwd,
@@ -446,8 +452,8 @@ export class OptimizedNodeBridge extends RuntimeBridge {
446
452
  requestCount: 0,
447
453
  lastUsed: Date.now(),
448
454
  busy: false,
449
- buffer: '',
450
- pendingRequests: new Map(),
455
+ quarantined: false,
456
+ core: null as unknown as BridgeCore,
451
457
  stats: {
452
458
  totalRequests: 0,
453
459
  totalTime: 0,
@@ -456,6 +462,24 @@ export class OptimizedNodeBridge extends RuntimeBridge {
456
462
  },
457
463
  };
458
464
 
465
+ worker.core = new BridgeCore(
466
+ {
467
+ write: (data: string): void => {
468
+ if (!worker.process.stdin?.writable) {
469
+ throw new BridgeProtocolError('Worker process stdin not writable');
470
+ }
471
+ worker.process.stdin.write(data);
472
+ },
473
+ },
474
+ {
475
+ timeoutMs: this.options.timeoutMs,
476
+ maxLineLength,
477
+ decodeValue: decodeValueAsync,
478
+ onFatalError: (error: Error): void => this.quarantineWorker(worker, error),
479
+ onTimeout: (error: Error): void => this.quarantineWorker(worker, error),
480
+ }
481
+ );
482
+
459
483
  // Setup process event handlers
460
484
  this.setupProcessHandlers(worker);
461
485
 
@@ -473,27 +497,12 @@ export class OptimizedNodeBridge extends RuntimeBridge {
473
497
  private setupProcessHandlers(worker: WorkerProcess): void {
474
498
  const childProcess = worker.process;
475
499
 
476
- // Handle stdout data with efficient buffering
477
500
  childProcess.stdout?.on('data', (chunk: Buffer) => {
478
- worker.buffer += chunk.toString();
479
-
480
- let idx: number;
481
- while ((idx = worker.buffer.indexOf('\n')) !== -1) {
482
- const line = worker.buffer.slice(0, idx).trim();
483
- worker.buffer = worker.buffer.slice(idx + 1);
484
-
485
- if (!line) {
486
- continue;
487
- }
488
-
489
- this.handleWorkerResponse(worker, line).catch(error => {
490
- log.error('Error handling worker response', { workerId: worker.id, error: String(error) });
491
- });
492
- }
501
+ worker.core.handleStdoutData(chunk);
493
502
  });
494
503
 
495
- // Handle stderr for debugging
496
504
  childProcess.stderr?.on('data', (chunk: Buffer) => {
505
+ worker.core.handleStderrData(chunk);
497
506
  const errorText = chunk.toString().trim();
498
507
  if (errorText) {
499
508
  log.warn('Worker stderr', { workerId: worker.id, output: errorText });
@@ -503,81 +512,27 @@ export class OptimizedNodeBridge extends RuntimeBridge {
503
512
  // Handle process exit
504
513
  childProcess.on('exit', code => {
505
514
  log.warn('Worker exited', { workerId: worker.id, code });
515
+ worker.core.handleProcessExit();
506
516
  this.handleWorkerExit(worker, code);
507
517
  });
508
518
 
509
519
  // Handle process errors
510
520
  childProcess.on('error', error => {
511
521
  log.error('Worker error', { workerId: worker.id, error: String(error) });
522
+ worker.core.handleProcessError(error);
512
523
  this.handleWorkerExit(worker, -1);
513
524
  });
514
525
  }
515
526
 
516
- /**
517
- * Handle response from worker process
518
- */
519
- private async handleWorkerResponse(worker: WorkerProcess, line: string): Promise<void> {
520
- try {
521
- const msg = JSON.parse(line) as RpcResponse;
522
- if (msg.protocol !== TYWRAP_PROTOCOL) {
523
- this.handleProtocolError(
524
- worker,
525
- line,
526
- new BridgeProtocolError(
527
- `Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(msg.protocol)}`
528
- )
529
- );
530
- return;
531
- }
532
- if (typeof msg.id !== 'number') {
533
- this.handleProtocolError(worker, line, new BridgeProtocolError('Invalid response id'));
534
- return;
535
- }
536
- const pending = worker.pendingRequests.get(msg.id);
537
-
538
- if (!pending) {
539
- this.handleProtocolError(
540
- worker,
541
- line,
542
- new BridgeProtocolError(`Unexpected response id ${msg.id}`)
543
- );
544
- return;
545
- }
546
-
547
- worker.pendingRequests.delete(msg.id);
548
-
549
- if (pending.timer) {
550
- clearTimeout(pending.timer);
551
- }
552
-
553
- if (msg.error) {
554
- pending.reject(this.errorFrom(msg.error));
555
- } else {
556
- try {
557
- const decoded = await decodeValueAsync(msg.result);
558
- pending.resolve(decoded);
559
- } catch (decodeError) {
560
- pending.reject(new BridgeProtocolError(`Failed to decode response: ${decodeError}`));
561
- }
562
- }
563
- } catch (parseError) {
564
- this.handleProtocolError(worker, line, parseError);
565
- }
566
- }
567
-
568
527
  /**
569
528
  * Handle worker process exit
570
529
  */
571
- private handleWorkerExit(worker: WorkerProcess, code: number | null): void {
572
- // Reject all pending requests
573
- for (const [, pending] of worker.pendingRequests) {
574
- if (pending.timer) {
575
- clearTimeout(pending.timer);
576
- }
577
- pending.reject(new Error(`Worker process exited with code ${code}`));
530
+ private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
531
+ if (!this.processPool.includes(worker)) {
532
+ return;
578
533
  }
579
534
 
580
- worker.pendingRequests.clear();
535
+ worker.core.clear();
581
536
 
582
537
  // Remove from pool
583
538
  const index = this.processPool.indexOf(worker);
@@ -615,6 +570,14 @@ export class OptimizedNodeBridge extends RuntimeBridge {
615
570
  // Warmed up ${this.processPool.length} worker processes
616
571
  }
617
572
 
573
+ private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
574
+ try {
575
+ return globalCache.generateKey(prefix, ...inputs);
576
+ } catch {
577
+ return null;
578
+ }
579
+ }
580
+
618
581
  /**
619
582
  * Heuristic to determine if function result should be cached
620
583
  */
@@ -685,7 +648,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
685
648
  averageTime: w.stats.averageTime,
686
649
  errorCount: w.stats.errorCount,
687
650
  busy: w.busy,
688
- pendingRequests: w.pendingRequests.size,
651
+ pendingRequests: w.core.getPendingCount(),
689
652
  })),
690
653
  };
691
654
  }
@@ -722,28 +685,31 @@ export class OptimizedNodeBridge extends RuntimeBridge {
722
685
  /**
723
686
  * Gracefully terminate a worker
724
687
  */
725
- private async terminateWorker(worker: WorkerProcess): Promise<void> {
688
+ private async terminateWorker(
689
+ worker: WorkerProcess,
690
+ options: { force?: boolean } = {}
691
+ ): Promise<void> {
692
+ if (worker.busy && !options.force) {
693
+ return;
694
+ }
695
+
726
696
  const index = this.processPool.indexOf(worker);
727
697
  if (index >= 0) {
728
698
  this.processPool.splice(index, 1);
699
+ this.stats.processDeaths++;
729
700
  }
730
701
 
731
- // Reject pending requests
732
- for (const [, pending] of worker.pendingRequests) {
733
- if (pending.timer) {
734
- clearTimeout(pending.timer);
735
- }
736
- pending.reject(new Error('Worker process terminated'));
737
- }
702
+ worker.core.handleProcessExit();
703
+ worker.core.clear();
738
704
 
739
705
  // Graceful termination
740
706
  try {
741
- if (worker.process.connected) {
707
+ if (worker.process.exitCode === null) {
742
708
  worker.process.kill('SIGTERM');
743
709
 
744
710
  // Force kill if not terminated in 5 seconds
745
711
  setTimeout(() => {
746
- if (!worker.process.killed) {
712
+ if (worker.process.exitCode === null) {
747
713
  worker.process.kill('SIGKILL');
748
714
  }
749
715
  }, 5000);
@@ -784,7 +750,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
784
750
  }
785
751
 
786
752
  // Terminate all workers
787
- const terminationPromises = this.processPool.map(worker => this.terminateWorker(worker));
753
+ const terminationPromises = this.processPool.map(worker =>
754
+ this.terminateWorker(worker, { force: true })
755
+ );
788
756
  await Promise.all(terminationPromises);
789
757
 
790
758
  this.processPool.length = 0;
@@ -792,47 +760,4 @@ export class OptimizedNodeBridge extends RuntimeBridge {
792
760
 
793
761
  // Disposed optimized Node.js bridge
794
762
  }
795
-
796
- private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
797
- const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
798
- e.traceback = err.traceback;
799
- return e;
800
- }
801
-
802
- private handleProtocolError(worker: WorkerProcess, line: string, error: unknown): void {
803
- const snippet = line.length > 500 ? `${line.slice(0, 500)}…` : line;
804
- const detail = error instanceof Error ? error.message : String(error);
805
- const hint =
806
- 'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
807
- const msg = `Protocol error from Python bridge. ${detail}\n${hint}\nOffending line: ${snippet}`;
808
- const bridgeError = new BridgeProtocolError(msg);
809
-
810
- for (const [, pending] of worker.pendingRequests) {
811
- if (pending.timer) {
812
- clearTimeout(pending.timer);
813
- }
814
- pending.reject(bridgeError);
815
- }
816
- worker.pendingRequests.clear();
817
-
818
- try {
819
- if (worker.process.connected) {
820
- worker.process.kill('SIGTERM');
821
- }
822
- } catch (killError) {
823
- log.warn('Error terminating worker after protocol error', { workerId: worker.id, error: String(killError) });
824
- }
825
-
826
- const index = this.processPool.indexOf(worker);
827
- if (index >= 0) {
828
- this.processPool.splice(index, 1);
829
- this.stats.processDeaths++;
830
- }
831
-
832
- if (!this.disposed && this.processPool.length < this.options.minProcesses) {
833
- this.spawnProcess().catch(spawnError => {
834
- log.error('Failed to spawn replacement worker after protocol error', { error: String(spawnError) });
835
- });
836
- }
837
- }
838
763
  }
@@ -0,0 +1,58 @@
1
+ export interface TimedOutRequestTrackerOptions {
2
+ ttlMs: number;
3
+ maxSize?: number;
4
+ }
5
+
6
+ /**
7
+ * Track request IDs that timed out on the JS side so late Python responses can be
8
+ * safely ignored instead of treated as protocol errors.
9
+ */
10
+ export class TimedOutRequestTracker {
11
+ private readonly ttlMs: number;
12
+ private readonly maxSize: number;
13
+ private readonly items = new Map<number, number>();
14
+
15
+ constructor(options: TimedOutRequestTrackerOptions) {
16
+ this.ttlMs = options.ttlMs;
17
+ this.maxSize = options.maxSize ?? 1000;
18
+ }
19
+
20
+ clear(): void {
21
+ this.items.clear();
22
+ }
23
+
24
+ mark(id: number): void {
25
+ const now = Date.now();
26
+ this.pruneOld(now);
27
+ this.items.set(id, now);
28
+ this.pruneMax();
29
+ }
30
+
31
+ consume(id: number): boolean {
32
+ if (!this.items.has(id)) {
33
+ return false;
34
+ }
35
+ this.items.delete(id);
36
+ return true;
37
+ }
38
+
39
+ private pruneOld(now: number): void {
40
+ const cutoff = now - this.ttlMs;
41
+ for (const [key, ts] of this.items) {
42
+ if (ts >= cutoff) {
43
+ break;
44
+ }
45
+ this.items.delete(key);
46
+ }
47
+ }
48
+
49
+ private pruneMax(): void {
50
+ while (this.items.size > this.maxSize) {
51
+ const oldest = this.items.keys().next();
52
+ if (oldest.done) {
53
+ break;
54
+ }
55
+ this.items.delete(oldest.value);
56
+ }
57
+ }
58
+ }
@@ -317,6 +317,9 @@ export interface BridgeInfo {
317
317
  pid: number;
318
318
  codecFallback: 'json' | 'none';
319
319
  arrowAvailable: boolean;
320
+ scipyAvailable: boolean;
321
+ torchAvailable: boolean;
322
+ sklearnAvailable: boolean;
320
323
  instances: number;
321
324
  }
322
325