tywrap 0.1.1 → 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.
Files changed (63) hide show
  1. package/README.md +32 -5
  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 +47 -11
  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 +16 -4
  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 +161 -15
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +542 -218
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +19 -128
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +19 -627
  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 +1 -0
  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 +75 -3
  51. package/src/config/index.ts +58 -15
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +16 -4
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +725 -253
  57. package/src/runtime/optimized-node.ts +19 -844
  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 +299 -82
  62. package/src/utils/logger.ts +1 -0
  63. package/src/utils/python.ts +0 -1
@@ -1,64 +1,143 @@
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';
14
+ import { createRequire } from 'node:module';
15
+ import type { ChildProcess } from 'child_process';
16
+ import { EventEmitter } from 'events';
8
17
 
9
- import { decodeValueAsync } from '../utils/codec.js';
18
+ import { globalCache } from '../utils/cache.js';
19
+ import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
10
20
  import { getDefaultPythonPath } from '../utils/python.js';
11
21
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
12
22
  import type { BridgeInfo } from '../types/index.js';
13
23
 
14
24
  import { RuntimeBridge } from './base.js';
25
+ import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
15
26
  import {
16
- BridgeDisposedError,
17
- BridgeExecutionError,
18
- BridgeProtocolError,
19
- BridgeTimeoutError,
20
- } from './errors.js';
21
- import { TYWRAP_PROTOCOL, TYWRAP_PROTOCOL_VERSION } from './protocol.js';
22
-
23
- interface RpcRequest {
24
- id: number;
25
- protocol: string;
26
- method: 'call' | 'instantiate' | 'call_method' | 'dispose_instance' | 'meta';
27
- params: unknown;
28
- }
27
+ BridgeCore,
28
+ type RpcRequest,
29
+ ensureJsonFallback,
30
+ ensurePythonEncoding,
31
+ getMaxLineLengthFromEnv,
32
+ getPathKey,
33
+ normalizeEnv,
34
+ validateBridgeInfo,
35
+ } from './bridge-core.js';
36
+ import { getComponentLogger } from '../utils/logger.js';
29
37
 
30
- interface RpcResponse<T = unknown> {
31
- id: number;
32
- protocol: string;
33
- result?: T;
34
- error?: { type: string; message: string; traceback?: string };
35
- }
38
+ const log = getComponentLogger('NodeBridge');
36
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
+ */
37
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. */
38
56
  pythonPath?: string;
39
- 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. */
40
60
  virtualEnv?: string;
61
+ /** Working directory for Python process. Default: process.cwd() */
41
62
  cwd?: string;
63
+ /** Timeout in ms for Python calls. Default: 30000 */
42
64
  timeoutMs?: number;
65
+ /** Maximum line length for JSONL protocol. */
66
+ maxLineLength?: number;
67
+ /** Inherit all environment variables from parent process. Default: false */
68
+ inheritProcessEnv?: boolean;
43
69
  /**
44
70
  * When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
45
71
  * for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
46
72
  */
47
73
  enableJsonFallback?: boolean;
48
- /**
49
- * Optional extra environment variables to pass to the Python subprocess.
50
- */
74
+ /** Enable result caching for pure functions. Default: false */
75
+ enableCache?: boolean;
76
+ /** Optional extra environment variables to pass to the Python subprocess. */
51
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 }>;
52
80
  }
53
81
 
54
- interface ResolvedNodeBridgeOptions {
82
+ interface ResolvedOptions {
83
+ minProcesses: number;
84
+ maxProcesses: number;
85
+ maxIdleTime: number;
86
+ maxRequestsPerProcess: number;
55
87
  pythonPath: string;
56
88
  scriptPath: string;
57
89
  virtualEnv?: string;
58
90
  cwd: string;
59
91
  timeoutMs: number;
92
+ maxLineLength?: number;
93
+ inheritProcessEnv: boolean;
60
94
  enableJsonFallback: boolean;
95
+ enableCache: boolean;
61
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
+ }>;
62
141
  }
63
142
 
64
143
  function resolveDefaultScriptPath(): string {
@@ -83,19 +162,48 @@ function resolveVirtualEnv(
83
162
  return { venvPath, binDir, pythonPath };
84
163
  }
85
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
+ */
86
184
  export class NodeBridge extends RuntimeBridge {
87
- private child?: import('child_process').ChildProcess;
88
- private nextId = 1;
89
- private readonly pending = new Map<
90
- number,
91
- { resolve: (v: unknown) => void; reject: (e: unknown) => void; timer?: NodeJS.Timeout }
92
- >();
93
- private readonly options: ResolvedNodeBridgeOptions;
94
- private stderrBuffer = '';
185
+ private processPool: WorkerProcess[] = [];
186
+ private roundRobinIndex = 0;
187
+ private cleanupTimer?: NodeJS.Timeout;
188
+ private options: ResolvedOptions;
189
+ private emitter = new EventEmitter();
95
190
  private disposed = false;
96
- private protocolError = false;
97
- private initPromise?: Promise<void>;
98
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
+ };
99
207
 
100
208
  constructor(options: NodeBridgeOptions = {}) {
101
209
  super();
@@ -105,32 +213,65 @@ export class NodeBridge extends RuntimeBridge {
105
213
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
106
214
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
107
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,
108
221
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
109
222
  scriptPath: resolvedScriptPath,
110
223
  virtualEnv,
111
224
  cwd,
112
225
  timeoutMs: options.timeoutMs ?? 30000,
226
+ maxLineLength: options.maxLineLength,
227
+ inheritProcessEnv: options.inheritProcessEnv ?? false,
113
228
  enableJsonFallback: options.enableJsonFallback ?? false,
229
+ enableCache: options.enableCache ?? false,
114
230
  env: options.env ?? {},
231
+ warmupCommands: options.warmupCommands ?? [],
115
232
  };
233
+
234
+ // Start cleanup scheduler for pooled mode
235
+ this.startCleanupScheduler();
116
236
  }
117
237
 
118
238
  async init(): Promise<void> {
119
239
  if (this.disposed) {
120
240
  throw new BridgeDisposedError('Bridge has been disposed');
121
241
  }
122
- if (this.child) {
123
- return;
242
+ if (this.processPool.length >= this.options.minProcesses) {
243
+ return; // Already initialized
124
244
  }
125
245
  if (this.initPromise) {
126
246
  return this.initPromise;
127
247
  }
248
+ this.initPromise = this.doInit();
249
+ return this.initPromise;
250
+ }
251
+
252
+ private async doInit(): Promise<void> {
128
253
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
129
254
  if (!existsSync(this.options.scriptPath)) {
130
255
  throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
131
256
  }
132
- this.initPromise = this.startProcess();
133
- 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
+ }
134
275
  }
135
276
 
136
277
  async getBridgeInfo(options: { refresh?: boolean } = {}): Promise<BridgeInfo> {
@@ -151,7 +292,42 @@ export class NodeBridge extends RuntimeBridge {
151
292
  kwargs?: Record<string, unknown>
152
293
  ): Promise<T> {
153
294
  await this.init();
154
- 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
+ }
155
331
  }
156
332
 
157
333
  async instantiate<T = unknown>(
@@ -161,7 +337,20 @@ export class NodeBridge extends RuntimeBridge {
161
337
  kwargs?: Record<string, unknown>
162
338
  ): Promise<T> {
163
339
  await this.init();
164
- 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
+ }
165
354
  }
166
355
 
167
356
  async callMethod<T = unknown>(
@@ -171,258 +360,541 @@ export class NodeBridge extends RuntimeBridge {
171
360
  kwargs?: Record<string, unknown>
172
361
  ): Promise<T> {
173
362
  await this.init();
174
- 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
+ }
175
377
  }
176
378
 
177
379
  async disposeInstance(handle: string): Promise<void> {
178
380
  await this.init();
179
- await this.send<void>({ method: 'dispose_instance', params: { handle } });
381
+ await this.executeRequest<void>({
382
+ method: 'dispose_instance',
383
+ params: { handle },
384
+ });
180
385
  }
181
386
 
182
- async dispose(): Promise<void> {
183
- this.disposed = true;
184
- this.initPromise = undefined;
185
- this.bridgeInfo = undefined;
186
- if (!this.child) {
187
- return;
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
+ }
188
401
  }
189
- this.child.kill('SIGTERM');
190
- this.child = undefined;
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);
191
408
  }
192
409
 
193
- private async send<T>(payload: Omit<RpcRequest, 'id' | 'protocol'>): Promise<T> {
194
- if (this.disposed) {
195
- 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;
423
+ }
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;
196
432
  }
197
- const id = this.nextId++;
198
- const message: RpcRequest = { id, protocol: TYWRAP_PROTOCOL, ...payload } as RpcRequest;
199
- const text = `${JSON.stringify(message)}\n`;
200
- let timer: NodeJS.Timeout | undefined;
201
- const promise = new Promise<T>((resolvePromise, reject) => {
202
- timer = setTimeout(() => {
203
- this.pending.delete(id);
204
- const stderrTail = this.stderrBuffer.trim();
205
- const msg = stderrTail
206
- ? `Python call timed out. Recent stderr from Python:\n${stderrTail}`
207
- : 'Python call timed out';
208
- reject(new BridgeTimeoutError(msg));
209
- }, this.options.timeoutMs);
210
- const resolveWrapped = (v: unknown): void => {
211
- resolvePromise(v as T);
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
+ }
212
459
  };
213
- this.pending.set(id, { resolve: resolveWrapped, reject, timer });
460
+
461
+ checkWorker();
214
462
  });
463
+ }
464
+
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
+
215
478
  try {
216
- if (!this.child?.stdin) {
217
- throw new BridgeProtocolError('Python process not available');
218
- }
219
- this.child.stdin.write(text);
220
- } catch (err) {
221
- this.pending.delete(id);
222
- if (timer) {
223
- clearTimeout(timer);
224
- }
225
- throw new BridgeProtocolError(`IPC failure: ${(err as Error).message}`);
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;
226
490
  }
227
- return promise;
228
491
  }
229
492
 
230
- private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
231
- const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
232
- e.traceback = err.traceback;
233
- return e;
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
+ });
514
+ });
234
515
  }
235
516
 
236
- private handleProtocolError(details: string, line?: string): void {
237
- if (this.protocolError) {
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
+ };
551
+
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),
567
+ }
568
+ );
569
+
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 });
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
+ }
611
+
612
+ /**
613
+ * Handle worker process exit
614
+ */
615
+ private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
616
+ if (!this.processPool.includes(worker)) {
238
617
  return;
239
618
  }
240
- this.protocolError = true;
241
- const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}…` : line) : undefined;
242
- const hint =
243
- 'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
244
- const msg = snippet
245
- ? `Protocol error from Python bridge. ${details}\n${hint}\nOffending line: ${snippet}`
246
- : `Protocol error from Python bridge. ${details}\n${hint}`;
247
- const error = new BridgeProtocolError(msg);
248
- for (const [, p] of this.pending) {
249
- p.reject(error);
250
- }
251
- this.pending.clear();
252
- this.child?.kill('SIGTERM');
253
- this.child = undefined;
254
- this.initPromise = undefined;
255
- this.bridgeInfo = undefined;
619
+
620
+ worker.core.clear();
621
+
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
+ }
628
+
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) });
633
+ });
634
+ }
256
635
  }
257
636
 
258
- private async startProcess(): Promise<void> {
259
- try {
260
- const { spawn } = await import('child_process');
261
- const allowedPrefixes = ['TYWRAP_'];
262
- const allowedKeys = new Set(['PATH', 'PYTHONPATH', 'VIRTUAL_ENV', 'PYTHONHOME']);
263
- const baseEnv = new Map<string, string | undefined>();
264
- for (const [k, v] of Object.entries(process.env)) {
265
- if (allowedKeys.has(k) || allowedPrefixes.some(p => k.startsWith(p))) {
266
- baseEnv.set(k, v);
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) });
267
650
  }
268
651
  }
269
- const env: NodeJS.ProcessEnv = {
270
- ...(Object.fromEntries(baseEnv) as NodeJS.ProcessEnv),
271
- ...this.options.env,
272
- };
273
- if (this.options.virtualEnv) {
274
- const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
275
- env.VIRTUAL_ENV = venv.venvPath;
276
- const currentPath = env.PATH ?? process.env.PATH ?? '';
277
- env.PATH = `${venv.binDir}${delimiter}${currentPath}`;
278
- }
279
- if (!env.PYTHONUTF8) {
280
- env.PYTHONUTF8 = '1';
281
- }
282
- if (!env.PYTHONIOENCODING) {
283
- env.PYTHONIOENCODING = 'UTF-8';
652
+ });
653
+
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;
662
+ }
663
+ }
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();
284
765
  }
285
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
286
- if (this.options.enableJsonFallback && !env.TYWRAP_CODEC_FALLBACK) {
287
- env.TYWRAP_CODEC_FALLBACK = 'json';
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);
288
800
  }
801
+ } catch (error) {
802
+ log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
803
+ }
804
+ }
289
805
 
290
- let child: ReturnType<typeof spawn>;
806
+ /**
807
+ * Start cleanup scheduler
808
+ */
809
+ private startCleanupScheduler(): void {
810
+ this.cleanupTimer = setInterval(async () => {
291
811
  try {
292
- child = spawn(this.options.pythonPath, [this.options.scriptPath], {
293
- cwd: this.options.cwd,
294
- stdio: ['pipe', 'pipe', 'pipe'],
295
- env,
296
- });
297
- } catch (err) {
298
- throw new BridgeProtocolError(`Failed to start Python process: ${(err as Error).message}`);
812
+ await this.cleanup();
813
+ } catch (error) {
814
+ log.error('Cleanup error', { error: String(error) });
299
815
  }
816
+ }, 60000); // Cleanup every minute
817
+ }
300
818
 
301
- const startupError = await new Promise<Error | null>(done => {
302
- child.once('error', e => done(e));
303
- child.once('spawn', () => done(null));
304
- });
305
- if (startupError) {
306
- throw new BridgeProtocolError(`Failed to start Python process: ${startupError.message}`);
307
- }
819
+ /**
820
+ * Dispose all resources
821
+ */
822
+ async dispose(): Promise<void> {
823
+ if (this.disposed) {
824
+ return;
825
+ }
308
826
 
309
- this.child = child;
310
- this.protocolError = false;
827
+ this.disposed = true;
311
828
 
312
- this.child?.on('error', err => {
313
- const msg = `Python process error: ${err instanceof Error ? err.message : String(err)}`;
314
- for (const [, p] of this.pending) {
315
- p.reject(new BridgeProtocolError(msg));
316
- }
317
- this.pending.clear();
318
- this.child = undefined;
319
- this.initPromise = undefined;
320
- this.bridgeInfo = undefined;
321
- });
829
+ if (this.cleanupTimer) {
830
+ clearInterval(this.cleanupTimer);
831
+ this.cleanupTimer = undefined;
832
+ }
322
833
 
323
- let buffer = '';
324
- this.child.stdout?.on('data', (chunk: Buffer): void => {
325
- buffer += chunk.toString();
326
- let idx: number;
327
- while ((idx = buffer.indexOf('\n')) !== -1) {
328
- const line = buffer.slice(0, idx);
329
- buffer = buffer.slice(idx + 1);
330
- if (!line.trim()) {
331
- continue;
332
- }
333
- (async (): Promise<void> => {
334
- try {
335
- const msg = JSON.parse(line) as RpcResponse;
336
- if (msg.protocol !== TYWRAP_PROTOCOL) {
337
- this.handleProtocolError(
338
- `Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(
339
- msg.protocol
340
- )}`,
341
- line
342
- );
343
- return;
344
- }
345
- if (typeof msg.id !== 'number') {
346
- this.handleProtocolError('Invalid response id', line);
347
- return;
348
- }
349
- const pending = this.pending.get(msg.id);
350
- if (!pending) {
351
- this.handleProtocolError(`Unexpected response id ${msg.id}`, line);
352
- return;
353
- }
354
- this.pending.delete(msg.id);
355
- if (pending.timer) {
356
- clearTimeout(pending.timer);
357
- }
358
- if (msg.error) {
359
- pending.reject(this.errorFrom(msg.error));
360
- } else {
361
- try {
362
- const decoded = await decodeValueAsync(msg.result);
363
- pending.resolve(decoded);
364
- } catch (err) {
365
- pending.reject(
366
- new BridgeProtocolError(
367
- `Failed to decode Python response: ${
368
- err instanceof Error ? err.message : String(err)
369
- }`
370
- )
371
- );
372
- }
373
- }
374
- } catch (err) {
375
- const parseMessage = err instanceof Error ? err.message : String(err);
376
- this.handleProtocolError(`Invalid JSON: ${parseMessage}`, line);
377
- }
378
- })().catch(() => {
379
- /* ignore */
380
- });
381
- }
382
- });
834
+ // Terminate all workers
835
+ const terminationPromises = this.processPool.map(worker =>
836
+ this.terminateWorker(worker, { force: true })
837
+ );
838
+ await Promise.all(terminationPromises);
383
839
 
384
- this.child?.stderr?.on('data', (chunk: Buffer) => {
385
- // Buffer stderr for better error diagnostics on failures/exits
386
- try {
387
- this.stderrBuffer += chunk.toString();
388
- // Truncate to last 8KB to avoid unbounded growth
389
- const MAX = 8 * 1024;
390
- if (this.stderrBuffer.length > MAX) {
391
- this.stderrBuffer = this.stderrBuffer.slice(this.stderrBuffer.length - MAX);
392
- }
393
- } catch {
394
- // ignore
395
- }
396
- });
840
+ this.processPool.length = 0;
841
+ this.emitter.removeAllListeners();
842
+ }
397
843
 
398
- this.child?.on('exit', () => {
399
- for (const [, p] of this.pending) {
400
- const stderrTail = this.stderrBuffer.trim();
401
- const msg = stderrTail
402
- ? `Python process exited. Stderr:\n${stderrTail}`
403
- : 'Python process exited';
404
- p.reject(new BridgeProtocolError(msg));
844
+ private buildEnv(): NodeJS.ProcessEnv {
845
+ const allowedPrefixes = ['TYWRAP_'];
846
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
847
+ const baseEnv: Record<string, string | undefined> = {};
848
+ if (this.options.inheritProcessEnv) {
849
+ for (const [key, value] of Object.entries(process.env)) {
850
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
851
+ baseEnv[key] = value;
852
+ }
853
+ } else {
854
+ for (const [key, value] of Object.entries(process.env)) {
855
+ if (
856
+ allowedKeys.has(key.toLowerCase()) ||
857
+ allowedPrefixes.some(prefix => key.startsWith(prefix))
858
+ ) {
859
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
860
+ baseEnv[key] = value;
405
861
  }
406
- this.pending.clear();
407
- this.child = undefined;
408
- this.initPromise = undefined;
409
- this.bridgeInfo = undefined;
410
- });
862
+ }
863
+ }
411
864
 
412
- await this.refreshBridgeInfo();
413
- } catch (err) {
414
- this.child?.kill('SIGTERM');
415
- this.child = undefined;
416
- this.initPromise = undefined;
417
- throw err;
865
+ let env = normalizeEnv(baseEnv, this.options.env);
866
+
867
+ if (this.options.virtualEnv) {
868
+ const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
869
+ env.VIRTUAL_ENV = venv.venvPath;
870
+ const pathKey = getPathKey(env);
871
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
872
+ const currentPath = env[pathKey] ?? '';
873
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
874
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
418
875
  }
876
+
877
+ ensurePythonEncoding(env);
878
+ ensureJsonFallback(env, this.options.enableJsonFallback);
879
+
880
+ env = normalizeEnv(env, {});
881
+ return env;
419
882
  }
420
883
 
421
884
  private async refreshBridgeInfo(): Promise<void> {
422
- const info = await this.send<BridgeInfo>({ method: 'meta', params: {} });
423
- if (info.protocol !== TYWRAP_PROTOCOL || info.protocolVersion !== TYWRAP_PROTOCOL_VERSION) {
424
- throw new BridgeProtocolError('Invalid bridge info payload');
425
- }
885
+ const info = await this.executeRequest<BridgeInfo>({ method: 'meta', params: {} });
886
+ validateBridgeInfo(info);
426
887
  this.bridgeInfo = info;
427
888
  }
428
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 };