tywrap 0.1.2 → 0.2.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.
@@ -1,12 +1,21 @@
1
1
  /**
2
- * Node.js runtime bridge (minimal MVP)
2
+ * Node.js Runtime Bridge with Optional Connection Pooling
3
+ *
4
+ * NodeBridge is the unified Node.js runtime bridge that supports both single-process
5
+ * (correctness-first) and multi-process (pooled) configurations. By default, it runs
6
+ * in single-process mode for maximum compatibility with the original NodeBridge behavior.
7
+ *
8
+ * For high-throughput workloads, configure pooling via minProcesses/maxProcesses options.
3
9
  */
4
10
 
5
11
  import { existsSync } from 'node:fs';
6
12
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
7
13
  import { fileURLToPath } from 'node:url';
8
14
  import { createRequire } from 'node:module';
15
+ import type { ChildProcess } from 'child_process';
16
+ import { EventEmitter } from 'events';
9
17
 
18
+ import { globalCache } from '../utils/cache.js';
10
19
  import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
11
20
  import { getDefaultPythonPath } from '../utils/python.js';
12
21
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
@@ -24,27 +33,57 @@ import {
24
33
  normalizeEnv,
25
34
  validateBridgeInfo,
26
35
  } from './bridge-core.js';
36
+ import { getComponentLogger } from '../utils/logger.js';
27
37
 
38
+ const log = getComponentLogger('NodeBridge');
39
+
40
+ /**
41
+ * Configuration options for NodeBridge.
42
+ *
43
+ * By default, NodeBridge runs in single-process mode (minProcesses=1, maxProcesses=1).
44
+ * For high-throughput workloads, increase these values to enable process pooling.
45
+ */
28
46
  export interface NodeBridgeOptions {
47
+ /** Minimum number of Python processes to keep alive. Default: 1 */
48
+ minProcesses?: number;
49
+ /** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
50
+ maxProcesses?: number;
51
+ /** Time in ms to keep idle processes alive before termination. Default: 300000 (5 min) */
52
+ maxIdleTime?: number;
53
+ /** Restart process after this many requests. Default: 1000 */
54
+ maxRequestsPerProcess?: number;
55
+ /** Path to Python executable. Auto-detected if not specified. */
29
56
  pythonPath?: string;
30
- scriptPath?: string; // path to python_bridge.py
57
+ /** Path to python_bridge.py script. Auto-detected if not specified. */
58
+ scriptPath?: string;
59
+ /** Path to Python virtual environment. */
31
60
  virtualEnv?: string;
61
+ /** Working directory for Python process. Default: process.cwd() */
32
62
  cwd?: string;
63
+ /** Timeout in ms for Python calls. Default: 30000 */
33
64
  timeoutMs?: number;
65
+ /** Maximum line length for JSONL protocol. */
34
66
  maxLineLength?: number;
67
+ /** Inherit all environment variables from parent process. Default: false */
35
68
  inheritProcessEnv?: boolean;
36
69
  /**
37
70
  * When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
38
71
  * for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
39
72
  */
40
73
  enableJsonFallback?: boolean;
41
- /**
42
- * Optional extra environment variables to pass to the Python subprocess.
43
- */
74
+ /** Enable result caching for pure functions. Default: false */
75
+ enableCache?: boolean;
76
+ /** Optional extra environment variables to pass to the Python subprocess. */
44
77
  env?: Record<string, string | undefined>;
78
+ /** Commands to run on each process at startup for warming up. */
79
+ warmupCommands?: Array<{ method: string; params: unknown }>;
45
80
  }
46
81
 
47
- interface ResolvedNodeBridgeOptions {
82
+ interface ResolvedOptions {
83
+ minProcesses: number;
84
+ maxProcesses: number;
85
+ maxIdleTime: number;
86
+ maxRequestsPerProcess: number;
48
87
  pythonPath: string;
49
88
  scriptPath: string;
50
89
  virtualEnv?: string;
@@ -53,7 +92,52 @@ interface ResolvedNodeBridgeOptions {
53
92
  maxLineLength?: number;
54
93
  inheritProcessEnv: boolean;
55
94
  enableJsonFallback: boolean;
95
+ enableCache: boolean;
56
96
  env: Record<string, string | undefined>;
97
+ warmupCommands: Array<{ method: string; params: unknown }>;
98
+ }
99
+
100
+ interface WorkerProcess {
101
+ process: ChildProcess;
102
+ id: string;
103
+ requestCount: number;
104
+ lastUsed: number;
105
+ busy: boolean;
106
+ quarantined: boolean;
107
+ core: BridgeCore;
108
+ stats: {
109
+ totalRequests: number;
110
+ totalTime: number;
111
+ averageTime: number;
112
+ errorCount: number;
113
+ };
114
+ }
115
+
116
+ interface BridgeStats {
117
+ totalRequests: number;
118
+ totalTime: number;
119
+ cacheHits: number;
120
+ poolHits: number;
121
+ poolMisses: number;
122
+ processSpawns: number;
123
+ processDeaths: number;
124
+ memoryPeak: number;
125
+ averageTime: number;
126
+ cacheHitRate: number;
127
+ }
128
+
129
+ interface BridgeStatsSnapshot extends BridgeStats {
130
+ poolSize: number;
131
+ busyWorkers: number;
132
+ memoryUsage: NodeJS.MemoryUsage;
133
+ workerStats: Array<{
134
+ id: string;
135
+ requestCount: number;
136
+ averageTime: number;
137
+ errorCount: number;
138
+ busy: boolean;
139
+ pendingRequests: number;
140
+ }>;
57
141
  }
58
142
 
59
143
  function resolveDefaultScriptPath(): string {
@@ -78,13 +162,48 @@ function resolveVirtualEnv(
78
162
  return { venvPath, binDir, pythonPath };
79
163
  }
80
164
 
165
+ /**
166
+ * Node.js runtime bridge for executing Python code.
167
+ *
168
+ * By default, runs in single-process mode for correctness-first behavior.
169
+ * Configure minProcesses/maxProcesses for process pooling in high-throughput scenarios.
170
+ *
171
+ * @example
172
+ * ```typescript
173
+ * // Single-process mode (default)
174
+ * const bridge = new NodeBridge();
175
+ *
176
+ * // Multi-process pooling for high throughput
177
+ * const pooledBridge = new NodeBridge({
178
+ * minProcesses: 2,
179
+ * maxProcesses: 8,
180
+ * enableCache: true,
181
+ * });
182
+ * ```
183
+ */
81
184
  export class NodeBridge extends RuntimeBridge {
82
- private child?: import('child_process').ChildProcess;
83
- private core?: BridgeCore;
84
- private readonly options: ResolvedNodeBridgeOptions;
185
+ private processPool: WorkerProcess[] = [];
186
+ private roundRobinIndex = 0;
187
+ private cleanupTimer?: NodeJS.Timeout;
188
+ private options: ResolvedOptions;
189
+ private emitter = new EventEmitter();
85
190
  private disposed = false;
86
- private initPromise?: Promise<void>;
87
191
  private bridgeInfo?: BridgeInfo;
192
+ private initPromise?: Promise<void>;
193
+
194
+ // Performance monitoring
195
+ private stats: BridgeStats = {
196
+ totalRequests: 0,
197
+ totalTime: 0,
198
+ cacheHits: 0,
199
+ poolHits: 0,
200
+ poolMisses: 0,
201
+ processSpawns: 0,
202
+ processDeaths: 0,
203
+ memoryPeak: 0,
204
+ averageTime: 0,
205
+ cacheHitRate: 0,
206
+ };
88
207
 
89
208
  constructor(options: NodeBridgeOptions = {}) {
90
209
  super();
@@ -94,6 +213,11 @@ export class NodeBridge extends RuntimeBridge {
94
213
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
95
214
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
96
215
  this.options = {
216
+ // Default to single-process mode for backward compatibility
217
+ minProcesses: options.minProcesses ?? 1,
218
+ maxProcesses: options.maxProcesses ?? 1,
219
+ maxIdleTime: options.maxIdleTime ?? 300000, // 5 minutes
220
+ maxRequestsPerProcess: options.maxRequestsPerProcess ?? 1000,
97
221
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
98
222
  scriptPath: resolvedScriptPath,
99
223
  virtualEnv,
@@ -102,26 +226,52 @@ export class NodeBridge extends RuntimeBridge {
102
226
  maxLineLength: options.maxLineLength,
103
227
  inheritProcessEnv: options.inheritProcessEnv ?? false,
104
228
  enableJsonFallback: options.enableJsonFallback ?? false,
229
+ enableCache: options.enableCache ?? false,
105
230
  env: options.env ?? {},
231
+ warmupCommands: options.warmupCommands ?? [],
106
232
  };
233
+
234
+ // Start cleanup scheduler for pooled mode
235
+ this.startCleanupScheduler();
107
236
  }
108
237
 
109
238
  async init(): Promise<void> {
110
239
  if (this.disposed) {
111
240
  throw new BridgeDisposedError('Bridge has been disposed');
112
241
  }
113
- if (this.child) {
114
- return;
242
+ if (this.processPool.length >= this.options.minProcesses) {
243
+ return; // Already initialized
115
244
  }
116
245
  if (this.initPromise) {
117
246
  return this.initPromise;
118
247
  }
248
+ this.initPromise = this.doInit();
249
+ return this.initPromise;
250
+ }
251
+
252
+ private async doInit(): Promise<void> {
119
253
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
120
254
  if (!existsSync(this.options.scriptPath)) {
121
255
  throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
122
256
  }
123
- this.initPromise = this.startProcess();
124
- return this.initPromise;
257
+
258
+ const require = createRequire(import.meta.url);
259
+ await autoRegisterArrowDecoder({
260
+ loader: () => require('apache-arrow'),
261
+ });
262
+
263
+ // Ensure minimum processes are available
264
+ while (this.processPool.length < this.options.minProcesses) {
265
+ await this.spawnProcess();
266
+ }
267
+
268
+ // Validate protocol version
269
+ await this.refreshBridgeInfo();
270
+
271
+ // Warm up processes if configured
272
+ if (this.options.warmupCommands.length > 0) {
273
+ await this.warmupProcesses();
274
+ }
125
275
  }
126
276
 
127
277
  async getBridgeInfo(options: { refresh?: boolean } = {}): Promise<BridgeInfo> {
@@ -142,7 +292,42 @@ export class NodeBridge extends RuntimeBridge {
142
292
  kwargs?: Record<string, unknown>
143
293
  ): Promise<T> {
144
294
  await this.init();
145
- return this.send<T>({ method: 'call', params: { module, functionName, args, kwargs } });
295
+ const startTime = performance.now();
296
+
297
+ const cacheKey = this.options.enableCache
298
+ ? this.safeCacheKey('runtime_call', module, functionName, args, kwargs)
299
+ : null;
300
+ if (cacheKey) {
301
+ const cached = await globalCache.get<T>(cacheKey);
302
+ if (cached !== null) {
303
+ this.stats.cacheHits++;
304
+ this.updateStats(performance.now() - startTime);
305
+ return cached;
306
+ }
307
+ }
308
+
309
+ try {
310
+ const result = await this.executeRequest<T>({
311
+ method: 'call',
312
+ params: { module, functionName, args, kwargs },
313
+ });
314
+
315
+ const duration = performance.now() - startTime;
316
+
317
+ // Cache result for pure functions (simple heuristic)
318
+ if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
319
+ await globalCache.set(cacheKey, result, {
320
+ computeTime: duration,
321
+ dependencies: [module],
322
+ });
323
+ }
324
+
325
+ this.updateStats(duration);
326
+ return result;
327
+ } catch (error) {
328
+ this.updateStats(performance.now() - startTime, true);
329
+ throw error;
330
+ }
146
331
  }
147
332
 
148
333
  async instantiate<T = unknown>(
@@ -152,7 +337,20 @@ export class NodeBridge extends RuntimeBridge {
152
337
  kwargs?: Record<string, unknown>
153
338
  ): Promise<T> {
154
339
  await this.init();
155
- return this.send<T>({ method: 'instantiate', params: { module, className, args, kwargs } });
340
+ const startTime = performance.now();
341
+
342
+ try {
343
+ const result = await this.executeRequest<T>({
344
+ method: 'instantiate',
345
+ params: { module, className, args, kwargs },
346
+ });
347
+
348
+ this.updateStats(performance.now() - startTime);
349
+ return result;
350
+ } catch (error) {
351
+ this.updateStats(performance.now() - startTime, true);
352
+ throw error;
353
+ }
156
354
  }
157
355
 
158
356
  async callMethod<T = unknown>(
@@ -162,102 +360,487 @@ export class NodeBridge extends RuntimeBridge {
162
360
  kwargs?: Record<string, unknown>
163
361
  ): Promise<T> {
164
362
  await this.init();
165
- return this.send<T>({ method: 'call_method', params: { handle, methodName, args, kwargs } });
363
+ const startTime = performance.now();
364
+
365
+ try {
366
+ const result = await this.executeRequest<T>({
367
+ method: 'call_method',
368
+ params: { handle, methodName, args, kwargs },
369
+ });
370
+
371
+ this.updateStats(performance.now() - startTime);
372
+ return result;
373
+ } catch (error) {
374
+ this.updateStats(performance.now() - startTime, true);
375
+ throw error;
376
+ }
166
377
  }
167
378
 
168
379
  async disposeInstance(handle: string): Promise<void> {
169
380
  await this.init();
170
- await this.send<void>({ method: 'dispose_instance', params: { handle } });
381
+ await this.executeRequest<void>({
382
+ method: 'dispose_instance',
383
+ params: { handle },
384
+ });
171
385
  }
172
386
 
173
- async dispose(): Promise<void> {
174
- this.disposed = true;
175
- this.core?.handleProcessExit();
176
- this.resetProcess();
387
+ /**
388
+ * Execute request with intelligent process selection
389
+ */
390
+ private async executeRequest<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
391
+ let worker = this.selectOptimalWorker();
392
+
393
+ // Spawn new process if none available and under limit
394
+ if (!worker && this.processPool.length < this.options.maxProcesses) {
395
+ try {
396
+ worker = await this.spawnProcess();
397
+ this.stats.poolMisses++;
398
+ } catch (error) {
399
+ throw new Error(`Failed to spawn worker process: ${error}`);
400
+ }
401
+ }
402
+
403
+ // Wait for worker if all are busy
404
+ worker ??= await this.waitForAvailableWorker();
405
+
406
+ this.stats.poolHits++;
407
+ return this.sendToWorker<T>(worker, payload);
177
408
  }
178
409
 
179
- private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
180
- if (this.disposed) {
181
- throw new BridgeDisposedError('Bridge has been disposed');
410
+ /**
411
+ * Select optimal worker based on load and performance
412
+ */
413
+ private selectOptimalWorker(): WorkerProcess | null {
414
+ // For single-process mode, allow concurrent requests to the same worker.
415
+ // BridgeCore handles request ID multiplexing, so multiple in-flight requests are safe.
416
+ // This preserves original NodeBridge behavior where concurrent calls were allowed.
417
+ if (this.options.maxProcesses === 1 && this.processPool.length === 1) {
418
+ const worker = this.processPool[0];
419
+ if (worker && !worker.quarantined && worker.process.exitCode === null) {
420
+ return worker;
421
+ }
422
+ return null;
182
423
  }
183
- if (!this.core) {
184
- throw new BridgeProtocolError('Python process not available');
424
+
425
+ // For multi-worker pools, use busy status for load balancing
426
+ const availableWorkers = this.processPool.filter(
427
+ w => !w.busy && !w.quarantined && w.process.exitCode === null
428
+ );
429
+
430
+ if (availableWorkers.length === 0) {
431
+ return null;
185
432
  }
186
- return this.core.send<T>(payload);
433
+
434
+ // Simple round-robin for now, could be enhanced with load-based selection
435
+ const worker = availableWorkers[this.roundRobinIndex % availableWorkers.length];
436
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % availableWorkers.length;
437
+
438
+ return worker ?? null;
439
+ }
440
+
441
+ /**
442
+ * Wait for any worker to become available
443
+ */
444
+ private async waitForAvailableWorker(timeoutMs: number = 5000): Promise<WorkerProcess> {
445
+ return new Promise((resolvePromise, reject) => {
446
+ const timeout = setTimeout(() => {
447
+ reject(new Error('Timeout waiting for available worker'));
448
+ }, timeoutMs);
449
+
450
+ const checkWorker = (): void => {
451
+ const worker = this.selectOptimalWorker();
452
+ if (worker) {
453
+ clearTimeout(timeout);
454
+ resolvePromise(worker);
455
+ } else {
456
+ // Check again in 10ms
457
+ setTimeout(checkWorker, 10);
458
+ }
459
+ };
460
+
461
+ checkWorker();
462
+ });
187
463
  }
188
464
 
189
- private async startProcess(): Promise<void> {
465
+ /**
466
+ * Send request to specific worker
467
+ */
468
+ private async sendToWorker<T>(
469
+ worker: WorkerProcess,
470
+ payload: Omit<RpcRequest, 'id' | 'protocol'>
471
+ ): Promise<T> {
472
+ worker.busy = true;
473
+ worker.requestCount++;
474
+ worker.lastUsed = Date.now();
475
+
476
+ const startTime = performance.now();
477
+
190
478
  try {
191
- const require = createRequire(import.meta.url);
192
- await autoRegisterArrowDecoder({
193
- loader: () => require('apache-arrow'),
479
+ const result = await worker.core.send<T>(payload);
480
+ const duration = performance.now() - startTime;
481
+ worker.stats.totalTime += duration;
482
+ worker.stats.totalRequests++;
483
+ worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
484
+ return result;
485
+ } catch (error) {
486
+ worker.stats.errorCount++;
487
+ throw error;
488
+ } finally {
489
+ worker.busy = false;
490
+ }
491
+ }
492
+
493
+ private quarantineWorker(worker: WorkerProcess, error: Error): void {
494
+ if (worker.quarantined) {
495
+ return;
496
+ }
497
+ worker.quarantined = true;
498
+ log.warn('Quarantining worker', { workerId: worker.id, error: String(error) });
499
+ this.terminateWorker(worker, { force: true })
500
+ .then(() => {
501
+ if (!this.disposed && this.processPool.length < this.options.minProcesses) {
502
+ this.spawnProcess().catch(spawnError => {
503
+ log.error('Failed to spawn replacement worker after quarantine', {
504
+ error: String(spawnError),
505
+ });
506
+ });
507
+ }
508
+ })
509
+ .catch(terminateError => {
510
+ log.warn('Failed to terminate quarantined worker', {
511
+ workerId: worker.id,
512
+ error: String(terminateError),
513
+ });
194
514
  });
195
- const { spawn } = await import('child_process');
515
+ }
196
516
 
197
- const env = this.buildEnv();
198
- const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
517
+ /**
518
+ * Spawn new worker process with optimizations
519
+ */
520
+ private async spawnProcess(): Promise<WorkerProcess> {
521
+ const { spawn } = await import('child_process');
522
+
523
+ const workerId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
524
+
525
+ const env = this.buildEnv();
526
+ env.PYTHONUNBUFFERED = '1'; // Ensure immediate output
527
+ env.PYTHONDONTWRITEBYTECODE = '1'; // Skip .pyc files for faster startup
528
+ const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
529
+
530
+ const childProcess = spawn(this.options.pythonPath, [this.options.scriptPath], {
531
+ cwd: this.options.cwd,
532
+ stdio: ['pipe', 'pipe', 'pipe'],
533
+ env,
534
+ });
535
+
536
+ const worker: WorkerProcess = {
537
+ process: childProcess,
538
+ id: workerId,
539
+ requestCount: 0,
540
+ lastUsed: Date.now(),
541
+ busy: false,
542
+ quarantined: false,
543
+ core: null as unknown as BridgeCore,
544
+ stats: {
545
+ totalRequests: 0,
546
+ totalTime: 0,
547
+ averageTime: 0,
548
+ errorCount: 0,
549
+ },
550
+ };
199
551
 
200
- let child: ReturnType<typeof spawn>;
201
- try {
202
- child = spawn(this.options.pythonPath, [this.options.scriptPath], {
203
- cwd: this.options.cwd,
204
- stdio: ['pipe', 'pipe', 'pipe'],
205
- env,
206
- });
207
- } catch (err) {
208
- throw new BridgeProtocolError(`Failed to start Python process: ${(err as Error).message}`);
552
+ worker.core = new BridgeCore(
553
+ {
554
+ write: (data: string): void => {
555
+ if (!worker.process.stdin?.writable) {
556
+ throw new BridgeProtocolError('Worker process stdin not writable');
557
+ }
558
+ worker.process.stdin.write(data);
559
+ },
560
+ },
561
+ {
562
+ timeoutMs: this.options.timeoutMs,
563
+ maxLineLength,
564
+ decodeValue: decodeValueAsync,
565
+ onFatalError: (error: Error): void => this.quarantineWorker(worker, error),
566
+ onTimeout: (error: Error): void => this.quarantineWorker(worker, error),
209
567
  }
568
+ );
210
569
 
211
- const startupError = await new Promise<Error | null>(done => {
212
- child.once('error', e => done(e));
213
- child.once('spawn', () => done(null));
214
- });
215
- if (startupError) {
216
- throw new BridgeProtocolError(`Failed to start Python process: ${startupError.message}`);
570
+ // Setup process event handlers
571
+ this.setupProcessHandlers(worker);
572
+
573
+ this.processPool.push(worker);
574
+ this.stats.processSpawns++;
575
+
576
+ return worker;
577
+ }
578
+
579
+ /**
580
+ * Setup event handlers for worker process
581
+ */
582
+ private setupProcessHandlers(worker: WorkerProcess): void {
583
+ const childProcess = worker.process;
584
+
585
+ childProcess.stdout?.on('data', (chunk: Buffer) => {
586
+ worker.core.handleStdoutData(chunk);
587
+ });
588
+
589
+ childProcess.stderr?.on('data', (chunk: Buffer) => {
590
+ worker.core.handleStderrData(chunk);
591
+ const errorText = chunk.toString().trim();
592
+ if (errorText) {
593
+ log.warn('Worker stderr', { workerId: worker.id, output: errorText });
217
594
  }
595
+ });
596
+
597
+ // Handle process exit
598
+ childProcess.on('exit', code => {
599
+ log.warn('Worker exited', { workerId: worker.id, code });
600
+ worker.core.handleProcessExit();
601
+ this.handleWorkerExit(worker, code);
602
+ });
603
+
604
+ // Handle process errors
605
+ childProcess.on('error', error => {
606
+ log.error('Worker error', { workerId: worker.id, error: String(error) });
607
+ worker.core.handleProcessError(error);
608
+ this.handleWorkerExit(worker, -1);
609
+ });
610
+ }
218
611
 
219
- this.child = child;
220
- this.core = new BridgeCore(
221
- {
222
- write: (data: string): void => {
223
- if (!this.child?.stdin) {
224
- throw new BridgeProtocolError('Python process not available');
225
- }
226
- this.child.stdin.write(data);
227
- },
228
- },
229
- {
230
- timeoutMs: this.options.timeoutMs,
231
- maxLineLength,
232
- decodeValue: decodeValueAsync,
233
- onFatalError: (): void => this.resetProcess(),
234
- }
235
- );
612
+ /**
613
+ * Handle worker process exit
614
+ */
615
+ private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
616
+ if (!this.processPool.includes(worker)) {
617
+ return;
618
+ }
236
619
 
237
- this.child.stdout?.on('data', chunk => {
238
- this.core?.handleStdoutData(chunk);
239
- });
620
+ worker.core.clear();
240
621
 
241
- this.child.stderr?.on('data', chunk => {
242
- this.core?.handleStderrData(chunk);
243
- });
622
+ // Remove from pool
623
+ const index = this.processPool.indexOf(worker);
624
+ if (index >= 0) {
625
+ this.processPool.splice(index, 1);
626
+ this.stats.processDeaths++;
627
+ }
244
628
 
245
- this.child.on('error', err => {
246
- this.core?.handleProcessError(err);
629
+ // Spawn replacement if needed and not disposing
630
+ if (!this.disposed && this.processPool.length < this.options.minProcesses) {
631
+ this.spawnProcess().catch(error => {
632
+ log.error('Failed to spawn replacement worker', { error: String(error) });
247
633
  });
634
+ }
635
+ }
248
636
 
249
- this.child.on('exit', () => {
250
- this.core?.handleProcessExit();
251
- this.resetProcess();
252
- });
637
+ /**
638
+ * Warm up processes with configured commands
639
+ */
640
+ private async warmupProcesses(): Promise<void> {
641
+ const warmupPromises = this.processPool.map(async worker => {
642
+ for (const cmd of this.options.warmupCommands) {
643
+ try {
644
+ await this.sendToWorker(worker, {
645
+ method: cmd.method as 'call' | 'instantiate' | 'call_method' | 'dispose_instance',
646
+ params: cmd.params,
647
+ });
648
+ } catch (error) {
649
+ log.warn('Warmup command failed', { workerId: worker.id, error: String(error) });
650
+ }
651
+ }
652
+ });
253
653
 
254
- await this.refreshBridgeInfo();
255
- } catch (err) {
256
- this.resetProcess();
257
- throw err;
654
+ await Promise.all(warmupPromises);
655
+ }
656
+
657
+ private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
658
+ try {
659
+ return globalCache.generateKey(prefix, ...inputs);
660
+ } catch {
661
+ return null;
258
662
  }
259
663
  }
260
664
 
665
+ /**
666
+ * Heuristic to determine if function result should be cached
667
+ */
668
+ private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
669
+ // Simple heuristics - could be made more sophisticated
670
+ const pureFunctionPatterns = [
671
+ /^(get|fetch|read|load|find|search|query|select)_/i,
672
+ /^(compute|calculate|process|transform|convert)_/i,
673
+ /^(encode|decode|serialize|deserialize)_/i,
674
+ ];
675
+
676
+ const impureFunctionPatterns = [
677
+ /^(set|save|write|update|insert|delete|create|modify)_/i,
678
+ /^(send|post|put|patch)_/i,
679
+ /random|uuid|timestamp|now|current/i,
680
+ ];
681
+
682
+ // Don't cache if function name suggests mutation
683
+ if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
684
+ return false;
685
+ }
686
+
687
+ // Cache if function name suggests pure computation
688
+ if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
689
+ return true;
690
+ }
691
+
692
+ // Don't cache if args contain mutable objects (very basic check)
693
+ const hasComplexArgs = args.some(
694
+ arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
695
+ );
696
+
697
+ return !hasComplexArgs && args.length <= 3; // Cache simple calls with few args
698
+ }
699
+
700
+ /**
701
+ * Update performance statistics
702
+ */
703
+ private updateStats(duration: number, _error: boolean = false): void {
704
+ this.stats.totalRequests++;
705
+ this.stats.totalTime += duration;
706
+
707
+ const currentMemory = process.memoryUsage().heapUsed;
708
+ if (currentMemory > this.stats.memoryPeak) {
709
+ this.stats.memoryPeak = currentMemory;
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Get performance statistics
715
+ */
716
+ getStats(): BridgeStatsSnapshot {
717
+ const avgTime =
718
+ this.stats.totalRequests > 0 ? this.stats.totalTime / this.stats.totalRequests : 0;
719
+ const hitRate =
720
+ this.stats.totalRequests > 0 ? this.stats.cacheHits / this.stats.totalRequests : 0;
721
+
722
+ return {
723
+ ...this.stats,
724
+ averageTime: avgTime,
725
+ cacheHitRate: hitRate,
726
+ poolSize: this.processPool.length,
727
+ busyWorkers: this.processPool.filter(w => w.busy).length,
728
+ memoryUsage: process.memoryUsage(),
729
+ workerStats: this.processPool.map(w => ({
730
+ id: w.id,
731
+ requestCount: w.requestCount,
732
+ averageTime: w.stats.averageTime,
733
+ errorCount: w.stats.errorCount,
734
+ busy: w.busy,
735
+ pendingRequests: w.core.getPendingCount(),
736
+ })),
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Cleanup idle processes
742
+ */
743
+ private async cleanup(): Promise<void> {
744
+ const now = Date.now();
745
+ const idleWorkers = this.processPool.filter(
746
+ w =>
747
+ !w.busy &&
748
+ now - w.lastUsed > this.options.maxIdleTime &&
749
+ this.processPool.length > this.options.minProcesses
750
+ );
751
+
752
+ for (const worker of idleWorkers) {
753
+ await this.terminateWorker(worker);
754
+ }
755
+
756
+ // Restart workers that have handled too many requests
757
+ const overusedWorkers = this.processPool.filter(
758
+ w => !w.busy && w.requestCount >= this.options.maxRequestsPerProcess
759
+ );
760
+
761
+ for (const worker of overusedWorkers) {
762
+ await this.terminateWorker(worker);
763
+ if (this.processPool.length < this.options.minProcesses) {
764
+ await this.spawnProcess();
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Gracefully terminate a worker
771
+ */
772
+ private async terminateWorker(
773
+ worker: WorkerProcess,
774
+ options: { force?: boolean } = {}
775
+ ): Promise<void> {
776
+ if (worker.busy && !options.force) {
777
+ return;
778
+ }
779
+
780
+ const index = this.processPool.indexOf(worker);
781
+ if (index >= 0) {
782
+ this.processPool.splice(index, 1);
783
+ this.stats.processDeaths++;
784
+ }
785
+
786
+ worker.core.handleProcessExit();
787
+ worker.core.clear();
788
+
789
+ // Graceful termination
790
+ try {
791
+ if (worker.process.exitCode === null) {
792
+ worker.process.kill('SIGTERM');
793
+
794
+ // Force kill if not terminated in 5 seconds
795
+ setTimeout(() => {
796
+ if (worker.process.exitCode === null) {
797
+ worker.process.kill('SIGKILL');
798
+ }
799
+ }, 5000);
800
+ }
801
+ } catch (error) {
802
+ log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Start cleanup scheduler
808
+ */
809
+ private startCleanupScheduler(): void {
810
+ this.cleanupTimer = setInterval(async () => {
811
+ try {
812
+ await this.cleanup();
813
+ } catch (error) {
814
+ log.error('Cleanup error', { error: String(error) });
815
+ }
816
+ }, 60000); // Cleanup every minute
817
+ }
818
+
819
+ /**
820
+ * Dispose all resources
821
+ */
822
+ async dispose(): Promise<void> {
823
+ if (this.disposed) {
824
+ return;
825
+ }
826
+
827
+ this.disposed = true;
828
+
829
+ if (this.cleanupTimer) {
830
+ clearInterval(this.cleanupTimer);
831
+ this.cleanupTimer = undefined;
832
+ }
833
+
834
+ // Terminate all workers
835
+ const terminationPromises = this.processPool.map(worker =>
836
+ this.terminateWorker(worker, { force: true })
837
+ );
838
+ await Promise.all(terminationPromises);
839
+
840
+ this.processPool.length = 0;
841
+ this.emitter.removeAllListeners();
842
+ }
843
+
261
844
  private buildEnv(): NodeJS.ProcessEnv {
262
845
  const allowedPrefixes = ['TYWRAP_'];
263
846
  const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
@@ -292,33 +875,26 @@ export class NodeBridge extends RuntimeBridge {
292
875
  }
293
876
 
294
877
  ensurePythonEncoding(env);
295
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
296
878
  ensureJsonFallback(env, this.options.enableJsonFallback);
297
879
 
298
880
  env = normalizeEnv(env, {});
299
881
  return env;
300
882
  }
301
883
 
302
- private resetProcess(): void {
303
- this.core?.clear();
304
- this.core = undefined;
305
- if (this.child) {
306
- try {
307
- if (this.child.exitCode === null) {
308
- this.child.kill('SIGTERM');
309
- }
310
- } catch {
311
- // ignore
312
- }
313
- }
314
- this.child = undefined;
315
- this.initPromise = undefined;
316
- this.bridgeInfo = undefined;
317
- }
318
-
319
884
  private async refreshBridgeInfo(): Promise<void> {
320
- const info = await this.send<BridgeInfo>({ method: 'meta', params: {} });
885
+ const info = await this.executeRequest<BridgeInfo>({ method: 'meta', params: {} });
321
886
  validateBridgeInfo(info);
322
887
  this.bridgeInfo = info;
323
888
  }
324
889
  }
890
+
891
+ /**
892
+ * @deprecated Use NodeBridge with minProcesses/maxProcesses options instead.
893
+ * This alias is provided for backward compatibility.
894
+ */
895
+ export { NodeBridge as OptimizedNodeBridge };
896
+
897
+ /**
898
+ * @deprecated Use NodeBridgeOptions instead.
899
+ */
900
+ export type { NodeBridgeOptions as ProcessPoolOptions };