tywrap 0.2.0 → 0.3.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 (163) hide show
  1. package/README.md +65 -31
  2. package/dist/cli.js +49 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts +11 -7
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +43 -7
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/analyzer.d.ts +1 -0
  9. package/dist/core/analyzer.d.ts.map +1 -1
  10. package/dist/core/analyzer.js +47 -12
  11. package/dist/core/analyzer.js.map +1 -1
  12. package/dist/core/annotation-parser.d.ts +8 -0
  13. package/dist/core/annotation-parser.d.ts.map +1 -0
  14. package/dist/core/annotation-parser.js +409 -0
  15. package/dist/core/annotation-parser.js.map +1 -0
  16. package/dist/core/discovery.d.ts +1 -0
  17. package/dist/core/discovery.d.ts.map +1 -1
  18. package/dist/core/discovery.js +9 -9
  19. package/dist/core/discovery.js.map +1 -1
  20. package/dist/core/generator.d.ts +9 -1
  21. package/dist/core/generator.d.ts.map +1 -1
  22. package/dist/core/generator.js +738 -104
  23. package/dist/core/generator.js.map +1 -1
  24. package/dist/core/mapper.d.ts +9 -7
  25. package/dist/core/mapper.d.ts.map +1 -1
  26. package/dist/core/mapper.js +129 -44
  27. package/dist/core/mapper.js.map +1 -1
  28. package/dist/index.d.ts +23 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +23 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/base.d.ts +17 -7
  33. package/dist/runtime/base.d.ts.map +1 -1
  34. package/dist/runtime/base.js +18 -1
  35. package/dist/runtime/base.js.map +1 -1
  36. package/dist/runtime/bounded-context.d.ts +252 -0
  37. package/dist/runtime/bounded-context.d.ts.map +1 -0
  38. package/dist/runtime/bounded-context.js +454 -0
  39. package/dist/runtime/bounded-context.js.map +1 -0
  40. package/dist/runtime/bridge-core.d.ts +1 -0
  41. package/dist/runtime/bridge-core.d.ts.map +1 -1
  42. package/dist/runtime/bridge-core.js +52 -17
  43. package/dist/runtime/bridge-core.js.map +1 -1
  44. package/dist/runtime/bridge-protocol.d.ts +184 -0
  45. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  46. package/dist/runtime/bridge-protocol.js +344 -0
  47. package/dist/runtime/bridge-protocol.js.map +1 -0
  48. package/dist/runtime/disposable.d.ts +40 -0
  49. package/dist/runtime/disposable.d.ts.map +1 -0
  50. package/dist/runtime/disposable.js +49 -0
  51. package/dist/runtime/disposable.js.map +1 -0
  52. package/dist/runtime/errors.d.ts +10 -0
  53. package/dist/runtime/errors.d.ts.map +1 -1
  54. package/dist/runtime/errors.js +9 -0
  55. package/dist/runtime/errors.js.map +1 -1
  56. package/dist/runtime/http-io.d.ts +91 -0
  57. package/dist/runtime/http-io.d.ts.map +1 -0
  58. package/dist/runtime/http-io.js +195 -0
  59. package/dist/runtime/http-io.js.map +1 -0
  60. package/dist/runtime/http.d.ts +48 -13
  61. package/dist/runtime/http.d.ts.map +1 -1
  62. package/dist/runtime/http.js +60 -73
  63. package/dist/runtime/http.js.map +1 -1
  64. package/dist/runtime/node.d.ts +97 -130
  65. package/dist/runtime/node.d.ts.map +1 -1
  66. package/dist/runtime/node.js +392 -521
  67. package/dist/runtime/node.js.map +1 -1
  68. package/dist/runtime/optimized-node.d.ts +1 -1
  69. package/dist/runtime/optimized-node.d.ts.map +1 -1
  70. package/dist/runtime/optimized-node.js +1 -1
  71. package/dist/runtime/optimized-node.js.map +1 -1
  72. package/dist/runtime/pooled-transport.d.ts +135 -0
  73. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  74. package/dist/runtime/pooled-transport.js +177 -0
  75. package/dist/runtime/pooled-transport.js.map +1 -0
  76. package/dist/runtime/process-io.d.ts +209 -0
  77. package/dist/runtime/process-io.d.ts.map +1 -0
  78. package/dist/runtime/process-io.js +729 -0
  79. package/dist/runtime/process-io.js.map +1 -0
  80. package/dist/runtime/pyodide-io.d.ts +154 -0
  81. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  82. package/dist/runtime/pyodide-io.js +459 -0
  83. package/dist/runtime/pyodide-io.js.map +1 -0
  84. package/dist/runtime/pyodide.d.ts +51 -19
  85. package/dist/runtime/pyodide.d.ts.map +1 -1
  86. package/dist/runtime/pyodide.js +57 -186
  87. package/dist/runtime/pyodide.js.map +1 -1
  88. package/dist/runtime/safe-codec.d.ts +103 -0
  89. package/dist/runtime/safe-codec.d.ts.map +1 -0
  90. package/dist/runtime/safe-codec.js +519 -0
  91. package/dist/runtime/safe-codec.js.map +1 -0
  92. package/dist/runtime/transport.d.ts +191 -0
  93. package/dist/runtime/transport.d.ts.map +1 -0
  94. package/dist/runtime/transport.js +86 -0
  95. package/dist/runtime/transport.js.map +1 -0
  96. package/dist/runtime/validators.d.ts +120 -0
  97. package/dist/runtime/validators.d.ts.map +1 -0
  98. package/dist/runtime/validators.js +229 -0
  99. package/dist/runtime/validators.js.map +1 -0
  100. package/dist/runtime/worker-pool.d.ts +208 -0
  101. package/dist/runtime/worker-pool.d.ts.map +1 -0
  102. package/dist/runtime/worker-pool.js +419 -0
  103. package/dist/runtime/worker-pool.js.map +1 -0
  104. package/dist/types/index.d.ts +51 -1
  105. package/dist/types/index.d.ts.map +1 -1
  106. package/dist/tywrap.d.ts.map +1 -1
  107. package/dist/tywrap.js +227 -328
  108. package/dist/tywrap.js.map +1 -1
  109. package/dist/utils/cache.d.ts.map +1 -1
  110. package/dist/utils/cache.js +53 -5
  111. package/dist/utils/cache.js.map +1 -1
  112. package/dist/utils/codec.d.ts.map +1 -1
  113. package/dist/utils/codec.js +114 -1
  114. package/dist/utils/codec.js.map +1 -1
  115. package/dist/utils/ir-cache.d.ts +28 -0
  116. package/dist/utils/ir-cache.d.ts.map +1 -0
  117. package/dist/utils/ir-cache.js +29 -0
  118. package/dist/utils/ir-cache.js.map +1 -0
  119. package/dist/utils/parallel-processor.d.ts +1 -1
  120. package/dist/utils/parallel-processor.d.ts.map +1 -1
  121. package/dist/utils/parallel-processor.js +111 -40
  122. package/dist/utils/parallel-processor.js.map +1 -1
  123. package/dist/utils/runtime.d.ts +5 -1
  124. package/dist/utils/runtime.d.ts.map +1 -1
  125. package/dist/utils/runtime.js +112 -18
  126. package/dist/utils/runtime.js.map +1 -1
  127. package/package.json +40 -18
  128. package/runtime/python_bridge.py +108 -15
  129. package/runtime/safe_codec.py +344 -0
  130. package/src/cli.ts +61 -2
  131. package/src/config/index.ts +63 -17
  132. package/src/core/analyzer.ts +52 -15
  133. package/src/core/annotation-parser.ts +500 -0
  134. package/src/core/discovery.ts +26 -12
  135. package/src/core/generator.ts +956 -116
  136. package/src/core/mapper.ts +158 -51
  137. package/src/index.ts +78 -6
  138. package/src/runtime/base.ts +18 -26
  139. package/src/runtime/bounded-context.ts +608 -0
  140. package/src/runtime/bridge-core.ts +58 -18
  141. package/src/runtime/bridge-protocol.ts +483 -0
  142. package/src/runtime/disposable.ts +65 -0
  143. package/src/runtime/errors.ts +14 -0
  144. package/src/runtime/http-io.ts +245 -0
  145. package/src/runtime/http.ts +76 -115
  146. package/src/runtime/node.ts +563 -676
  147. package/src/runtime/optimized-node.ts +4 -1
  148. package/src/runtime/pooled-transport.ts +263 -0
  149. package/src/runtime/process-io.ts +930 -0
  150. package/src/runtime/pyodide-io.ts +551 -0
  151. package/src/runtime/pyodide.ts +75 -215
  152. package/src/runtime/safe-codec.ts +651 -0
  153. package/src/runtime/transport.ts +278 -0
  154. package/src/runtime/validators.ts +252 -0
  155. package/src/runtime/worker-pool.ts +559 -0
  156. package/src/types/global.d.ts +11 -1
  157. package/src/types/index.ts +63 -0
  158. package/src/tywrap.ts +279 -358
  159. package/src/utils/cache.ts +59 -5
  160. package/src/utils/codec.ts +122 -1
  161. package/src/utils/ir-cache.ts +51 -0
  162. package/src/utils/parallel-processor.ts +119 -43
  163. package/src/utils/runtime.ts +129 -18
@@ -1,145 +1,143 @@
1
1
  /**
2
- * Node.js Runtime Bridge with Optional Connection Pooling
2
+ * Node.js runtime bridge for BridgeProtocol.
3
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.
4
+ * NodeBridge extends BridgeProtocol and uses ProcessIO transports with
5
+ * optional pooling for concurrent Python execution.
7
6
  *
8
- * For high-throughput workloads, configure pooling via minProcesses/maxProcesses options.
7
+ * @see https://github.com/bbopen/tywrap/issues/149
9
8
  */
10
9
 
11
10
  import { existsSync } from 'node:fs';
12
11
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
13
12
  import { fileURLToPath } from 'node:url';
14
13
  import { createRequire } from 'node:module';
15
- import type { ChildProcess } from 'child_process';
16
- import { EventEmitter } from 'events';
17
14
 
18
- import { globalCache } from '../utils/cache.js';
19
- import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
15
+ import { autoRegisterArrowDecoder } from '../utils/codec.js';
20
16
  import { getDefaultPythonPath } from '../utils/python.js';
21
17
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
22
- import type { BridgeInfo } from '../types/index.js';
23
-
24
- import { RuntimeBridge } from './base.js';
25
- import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
26
- import {
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';
37
-
38
- const log = getComponentLogger('NodeBridge');
18
+ import { globalCache } from '../utils/cache.js';
19
+
20
+ import { BridgeProtocol, type BridgeProtocolOptions } from './bridge-protocol.js';
21
+ import { BridgeExecutionError, BridgeProtocolError } from './errors.js';
22
+ import { ProcessIO } from './process-io.js';
23
+ import { PooledTransport } from './pooled-transport.js';
24
+ import type { CodecOptions } from './safe-codec.js';
25
+ import { PROTOCOL_ID } from './transport.js';
26
+ import type { PooledWorker } from './worker-pool.js';
27
+
28
+ // =============================================================================
29
+ // OPTIONS
30
+ // =============================================================================
39
31
 
40
32
  /**
41
33
  * 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
34
  */
46
35
  export interface NodeBridgeOptions {
47
36
  /** Minimum number of Python processes to keep alive. Default: 1 */
48
37
  minProcesses?: number;
38
+
49
39
  /** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
50
40
  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;
41
+
42
+ /** Maximum concurrent requests per process. Default: 10 */
43
+ maxConcurrentPerProcess?: number;
44
+
55
45
  /** Path to Python executable. Auto-detected if not specified. */
56
46
  pythonPath?: string;
47
+
57
48
  /** Path to python_bridge.py script. Auto-detected if not specified. */
58
49
  scriptPath?: string;
50
+
59
51
  /** Path to Python virtual environment. */
60
52
  virtualEnv?: string;
53
+
61
54
  /** Working directory for Python process. Default: process.cwd() */
62
55
  cwd?: string;
56
+
63
57
  /** Timeout in ms for Python calls. Default: 30000 */
64
58
  timeoutMs?: number;
65
- /** Maximum line length for JSONL protocol. */
66
- maxLineLength?: number;
59
+
60
+ /** Timeout in ms for waiting in pool queue. Default: 30000 */
61
+ queueTimeoutMs?: number;
62
+
67
63
  /** Inherit all environment variables from parent process. Default: false */
68
64
  inheritProcessEnv?: boolean;
69
- /**
70
- * When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
71
- * for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
72
- */
73
- enableJsonFallback?: boolean;
65
+
74
66
  /** Enable result caching for pure functions. Default: false */
75
67
  enableCache?: boolean;
68
+
76
69
  /** Optional extra environment variables to pass to the Python subprocess. */
77
70
  env?: Record<string, string | undefined>;
71
+
72
+ /** Codec options for validation/serialization */
73
+ codec?: CodecOptions;
74
+
78
75
  /** Commands to run on each process at startup for warming up. */
79
- warmupCommands?: Array<{ method: string; params: unknown }>;
76
+ warmupCommands?: Array<
77
+ { module: string; functionName: string; args?: unknown[] } | { method: string; params: unknown } // Legacy shape preserved so runtime can surface a migration error
78
+ >;
79
+
80
+ // ===========================================================================
81
+ // DEPRECATED OPTIONS (kept for backwards compatibility, ignored internally)
82
+ // ===========================================================================
83
+
84
+ /**
85
+ * @deprecated No longer used. Pool idle time is managed by WorkerPool.
86
+ */
87
+ maxIdleTime?: number;
88
+
89
+ /**
90
+ * @deprecated No longer used. Process restart is managed by ProcessIO.
91
+ */
92
+ maxRequestsPerProcess?: number;
93
+
94
+ /**
95
+ * @deprecated Use codec.bytesHandling option instead.
96
+ */
97
+ enableJsonFallback?: boolean;
98
+
99
+ /**
100
+ * @deprecated Use ProcessIO options instead.
101
+ */
102
+ maxLineLength?: number;
80
103
  }
81
104
 
105
+ // =============================================================================
106
+ // INTERNAL TYPES
107
+ // =============================================================================
108
+
82
109
  interface ResolvedOptions {
83
110
  minProcesses: number;
84
111
  maxProcesses: number;
85
- maxIdleTime: number;
86
- maxRequestsPerProcess: number;
112
+ maxConcurrentPerProcess: number;
87
113
  pythonPath: string;
88
114
  scriptPath: string;
89
115
  virtualEnv?: string;
90
116
  cwd: string;
91
117
  timeoutMs: number;
92
- maxLineLength?: number;
118
+ queueTimeoutMs: number;
93
119
  inheritProcessEnv: boolean;
94
- enableJsonFallback: boolean;
95
120
  enableCache: boolean;
96
121
  env: Record<string, string | undefined>;
97
- warmupCommands: Array<{ method: string; params: unknown }>;
122
+ codec?: CodecOptions;
123
+ warmupCommands: WarmupCommand[];
98
124
  }
99
125
 
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
- };
126
+ interface WarmupCommand {
127
+ module: string;
128
+ functionName: string;
129
+ args?: unknown[];
114
130
  }
115
131
 
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
- }
132
+ const WORKER_READY_TIMEOUT_MS = 5000;
128
133
 
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
- }>;
141
- }
134
+ // =============================================================================
135
+ // UTILITIES
136
+ // =============================================================================
142
137
 
138
+ /**
139
+ * Resolve the default bridge script path.
140
+ */
143
141
  function resolveDefaultScriptPath(): string {
144
142
  try {
145
143
  return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
@@ -148,173 +146,298 @@ function resolveDefaultScriptPath(): string {
148
146
  }
149
147
  }
150
148
 
149
+ /**
150
+ * Resolve virtual environment paths.
151
+ */
151
152
  function resolveVirtualEnv(
152
153
  virtualEnv: string,
153
154
  cwd: string
154
- ): {
155
- venvPath: string;
156
- binDir: string;
157
- pythonPath: string;
158
- } {
155
+ ): { venvPath: string; binDir: string; pythonPath: string } {
159
156
  const venvPath = resolve(cwd, virtualEnv);
160
157
  const binDir = join(venvPath, getVenvBinDir());
161
158
  const pythonPath = join(binDir, getVenvPythonExe());
162
159
  return { venvPath, binDir, pythonPath };
163
160
  }
164
161
 
162
+ /**
163
+ * Get the environment variable key for PATH (case-insensitive on Windows).
164
+ */
165
+ function getPathKey(env: Record<string, string | undefined>): string {
166
+ if (Object.prototype.hasOwnProperty.call(env, 'PATH')) {
167
+ return 'PATH';
168
+ }
169
+
170
+ for (const key of Object.keys(env)) {
171
+ if (key.toLowerCase() === 'path') {
172
+ return key;
173
+ }
174
+ }
175
+ return 'PATH';
176
+ }
177
+
178
+ function setPathValue(env: Record<string, string>, value: string): void {
179
+ setEnvValue(env, 'PATH', value);
180
+
181
+ if (process.platform !== 'win32') {
182
+ return;
183
+ }
184
+
185
+ for (const key of Object.keys(env)) {
186
+ if (key !== 'PATH' && key.toLowerCase() === 'path') {
187
+ setEnvValue(env, key, value);
188
+ }
189
+ }
190
+ }
191
+
192
+ const DANGEROUS_ENV_OVERRIDE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
193
+
194
+ function createNullPrototypeEnv(): Record<string, string> {
195
+ return Object.create(null) as Record<string, string>;
196
+ }
197
+
198
+ function setEnvValue(env: Record<string, string>, key: string, value: string): void {
199
+ Object.defineProperty(env, key, {
200
+ value,
201
+ writable: true,
202
+ enumerable: true,
203
+ configurable: true,
204
+ });
205
+ }
206
+
207
+ function getEnvValue(env: Record<string, string>, key: string): string | undefined {
208
+ const value = Reflect.get(env, key);
209
+ return typeof value === 'string' ? value : undefined;
210
+ }
211
+
212
+ function assertSafeEnvOverrideKey(key: string): void {
213
+ if (DANGEROUS_ENV_OVERRIDE_KEYS.has(key)) {
214
+ throw new BridgeProtocolError(`Invalid environment override key "${key}" in options.env`);
215
+ }
216
+ }
217
+
218
+ function normalizeWarmupCommands(commands: NodeBridgeOptions['warmupCommands']): WarmupCommand[] {
219
+ if (commands === undefined) {
220
+ return [];
221
+ }
222
+ if (!Array.isArray(commands)) {
223
+ throw new BridgeProtocolError('warmupCommands must be an array when provided');
224
+ }
225
+
226
+ const warmups = commands;
227
+ return warmups.map((command, index) => {
228
+ if (!command || typeof command !== 'object' || Array.isArray(command)) {
229
+ throw new BridgeProtocolError(
230
+ `Invalid warmup command at index ${index + 1}: expected { module, functionName, args? }`
231
+ );
232
+ }
233
+
234
+ const candidate = command as Record<string, unknown>;
235
+ if ('method' in candidate || 'params' in candidate) {
236
+ throw new BridgeProtocolError(
237
+ `Invalid warmup command at index ${index + 1}: legacy { method, params } format is no longer supported. Use { module, functionName, args? }.`
238
+ );
239
+ }
240
+
241
+ if (typeof candidate.module !== 'string' || candidate.module.trim().length === 0) {
242
+ throw new BridgeProtocolError(
243
+ `Invalid warmup command at index ${index + 1}: "module" must be a non-empty string`
244
+ );
245
+ }
246
+ if (typeof candidate.functionName !== 'string' || candidate.functionName.trim().length === 0) {
247
+ throw new BridgeProtocolError(
248
+ `Invalid warmup command at index ${index + 1}: "functionName" must be a non-empty string`
249
+ );
250
+ }
251
+ if (candidate.args !== undefined && !Array.isArray(candidate.args)) {
252
+ throw new BridgeProtocolError(
253
+ `Invalid warmup command at index ${index + 1}: "args" must be an array when provided`
254
+ );
255
+ }
256
+
257
+ return {
258
+ module: candidate.module,
259
+ functionName: candidate.functionName,
260
+ args: candidate.args as unknown[] | undefined,
261
+ };
262
+ });
263
+ }
264
+
265
+ // =============================================================================
266
+ // NODE BRIDGE
267
+ // =============================================================================
268
+
165
269
  /**
166
270
  * Node.js runtime bridge for executing Python code.
167
271
  *
168
- * By default, runs in single-process mode for correctness-first behavior.
169
- * Configure minProcesses/maxProcesses for process pooling in high-throughput scenarios.
272
+ * NodeBridge provides subprocess-based Python execution with optional pooling
273
+ * for high-throughput workloads. By default, it runs in single-process mode.
274
+ *
275
+ * Features:
276
+ * - Single or multi-process execution via process pooling
277
+ * - Virtual environment support
278
+ * - Full SafeCodec validation (NaN/Infinity rejection, key validation)
279
+ * - Automatic Arrow decoding for DataFrames/ndarrays
280
+ * - Optional result caching for pure functions
281
+ * - Process warmup commands
170
282
  *
171
283
  * @example
172
284
  * ```typescript
173
285
  * // Single-process mode (default)
174
286
  * const bridge = new NodeBridge();
287
+ * await bridge.init();
288
+ *
289
+ * const result = await bridge.call('math', 'sqrt', [16]);
290
+ * console.log(result); // 4.0
175
291
  *
292
+ * await bridge.dispose();
293
+ * ```
294
+ *
295
+ * @example
296
+ * ```typescript
176
297
  * // Multi-process pooling for high throughput
177
298
  * const pooledBridge = new NodeBridge({
178
- * minProcesses: 2,
179
- * maxProcesses: 8,
299
+ * maxProcesses: 4,
300
+ * maxConcurrentPerProcess: 2,
180
301
  * enableCache: true,
181
302
  * });
303
+ * await pooledBridge.init();
182
304
  * ```
183
305
  */
184
- export class NodeBridge extends RuntimeBridge {
185
- private processPool: WorkerProcess[] = [];
186
- private roundRobinIndex = 0;
187
- private cleanupTimer?: NodeJS.Timeout;
188
- private options: ResolvedOptions;
189
- private emitter = new EventEmitter();
190
- private disposed = false;
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
- };
306
+ export class NodeBridge extends BridgeProtocol {
307
+ private readonly resolvedOptions: ResolvedOptions;
308
+ private readonly pooledTransport: PooledTransport;
207
309
 
310
+ /**
311
+ * Create a new NodeBridge instance.
312
+ *
313
+ * @param options - Configuration options for the bridge
314
+ */
208
315
  constructor(options: NodeBridgeOptions = {}) {
209
- super();
210
316
  const cwd = options.cwd ?? process.cwd();
211
317
  const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
212
318
  const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
213
319
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
214
320
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
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,
321
+
322
+ const maxProcesses = options.maxProcesses ?? 1;
323
+ const minProcesses = Math.min(options.minProcesses ?? 1, maxProcesses);
324
+
325
+ const warmupCommands = normalizeWarmupCommands(options.warmupCommands);
326
+
327
+ const resolvedOptions: ResolvedOptions = {
328
+ minProcesses,
329
+ maxProcesses,
330
+ maxConcurrentPerProcess: options.maxConcurrentPerProcess ?? 10,
221
331
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
222
332
  scriptPath: resolvedScriptPath,
223
333
  virtualEnv,
224
334
  cwd,
225
335
  timeoutMs: options.timeoutMs ?? 30000,
226
- maxLineLength: options.maxLineLength,
336
+ queueTimeoutMs: options.queueTimeoutMs ?? 30000,
227
337
  inheritProcessEnv: options.inheritProcessEnv ?? false,
228
- enableJsonFallback: options.enableJsonFallback ?? false,
229
338
  enableCache: options.enableCache ?? false,
230
339
  env: options.env ?? {},
231
- warmupCommands: options.warmupCommands ?? [],
340
+ codec: options.codec,
341
+ warmupCommands,
232
342
  };
233
343
 
234
- // Start cleanup scheduler for pooled mode
235
- this.startCleanupScheduler();
236
- }
344
+ // Build environment for ProcessIO
345
+ const processEnv = buildProcessEnv(resolvedOptions);
346
+
347
+ // Create warmup callback for per-worker initialization
348
+ const userWarmup =
349
+ resolvedOptions.warmupCommands.length > 0
350
+ ? createWarmupCallback(resolvedOptions.warmupCommands, resolvedOptions.timeoutMs)
351
+ : undefined;
352
+ const replacementWorkerReady = createWorkerReadyCallback(resolvedOptions.timeoutMs, userWarmup);
353
+
354
+ // Create pooled transport with ProcessIO workers
355
+ const transport = new PooledTransport({
356
+ createTransport: (): ProcessIO =>
357
+ new ProcessIO({
358
+ pythonPath: resolvedOptions.pythonPath,
359
+ bridgeScript: resolvedOptions.scriptPath,
360
+ env: processEnv,
361
+ cwd: resolvedOptions.cwd,
362
+ }),
363
+ maxWorkers: resolvedOptions.maxProcesses,
364
+ minWorkers: resolvedOptions.minProcesses,
365
+ queueTimeoutMs: resolvedOptions.queueTimeoutMs,
366
+ maxConcurrentPerWorker: resolvedOptions.maxConcurrentPerProcess,
367
+ onWorkerReady: userWarmup,
368
+ onReplacementWorkerReady: replacementWorkerReady,
369
+ });
237
370
 
238
- async init(): Promise<void> {
239
- if (this.disposed) {
240
- throw new BridgeDisposedError('Bridge has been disposed');
241
- }
242
- if (this.processPool.length >= this.options.minProcesses) {
243
- return; // Already initialized
244
- }
245
- if (this.initPromise) {
246
- return this.initPromise;
247
- }
248
- this.initPromise = this.doInit();
249
- return this.initPromise;
371
+ // Initialize BridgeProtocol with pooled transport
372
+ const protocolOptions: BridgeProtocolOptions = {
373
+ transport,
374
+ codec: resolvedOptions.codec,
375
+ defaultTimeoutMs: resolvedOptions.timeoutMs,
376
+ };
377
+
378
+ super(protocolOptions);
379
+
380
+ this.resolvedOptions = resolvedOptions;
381
+ this.pooledTransport = transport;
250
382
  }
251
383
 
252
- private async doInit(): Promise<void> {
384
+ // ===========================================================================
385
+ // LIFECYCLE
386
+ // ===========================================================================
387
+
388
+ /**
389
+ * Initialize the bridge.
390
+ *
391
+ * Validates the bridge script exists, registers Arrow decoder,
392
+ * and initializes the transport pool (which runs warmup commands per-worker).
393
+ */
394
+ protected override async doInit(): Promise<void> {
395
+ // Validate script exists
253
396
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
254
- if (!existsSync(this.options.scriptPath)) {
255
- throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
397
+ if (!existsSync(this.resolvedOptions.scriptPath)) {
398
+ throw new BridgeProtocolError(
399
+ `Python bridge script not found at ${this.resolvedOptions.scriptPath}`
400
+ );
256
401
  }
257
402
 
403
+ // Register Arrow decoder for DataFrames/ndarrays
258
404
  const require = createRequire(import.meta.url);
259
405
  await autoRegisterArrowDecoder({
260
406
  loader: () => require('apache-arrow'),
261
407
  });
262
408
 
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
- }
409
+ // Initialize parent (which initializes transport and runs warmup per-worker)
410
+ await super.doInit();
275
411
  }
276
412
 
277
- async getBridgeInfo(options: { refresh?: boolean } = {}): Promise<BridgeInfo> {
278
- await this.init();
279
- if (!this.bridgeInfo || options.refresh) {
280
- await this.refreshBridgeInfo();
281
- }
282
- if (!this.bridgeInfo) {
283
- throw new BridgeProtocolError('Bridge info unavailable');
284
- }
285
- return this.bridgeInfo;
286
- }
413
+ // ===========================================================================
414
+ // CACHING OVERRIDE
415
+ // ===========================================================================
287
416
 
288
- async call<T = unknown>(
417
+ /**
418
+ * Override call() to add optional caching.
419
+ */
420
+ override async call<T = unknown>(
289
421
  module: string,
290
422
  functionName: string,
291
423
  args: unknown[],
292
424
  kwargs?: Record<string, unknown>
293
425
  ): Promise<T> {
294
- await this.init();
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;
426
+ // Check cache if enabled
427
+ if (this.resolvedOptions.enableCache) {
428
+ const cacheKey = this.safeCacheKey('runtime_call', module, functionName, args, kwargs);
429
+ if (cacheKey) {
430
+ const cached = await globalCache.get<T>(cacheKey);
431
+ if (cached !== null) {
432
+ return cached;
433
+ }
306
434
  }
307
- }
308
-
309
- try {
310
- const result = await this.executeRequest<T>({
311
- method: 'call',
312
- params: { module, functionName, args, kwargs },
313
- });
314
435
 
436
+ // Execute and cache if pure function
437
+ const startTime = performance.now();
438
+ const result = await super.call<T>(module, functionName, args, kwargs);
315
439
  const duration = performance.now() - startTime;
316
440
 
317
- // Cache result for pure functions (simple heuristic)
318
441
  if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
319
442
  await globalCache.set(cacheKey, result, {
320
443
  computeTime: duration,
@@ -322,338 +445,74 @@ export class NodeBridge extends RuntimeBridge {
322
445
  });
323
446
  }
324
447
 
325
- this.updateStats(duration);
326
- return result;
327
- } catch (error) {
328
- this.updateStats(performance.now() - startTime, true);
329
- throw error;
330
- }
331
- }
332
-
333
- async instantiate<T = unknown>(
334
- module: string,
335
- className: string,
336
- args: unknown[],
337
- kwargs?: Record<string, unknown>
338
- ): Promise<T> {
339
- await this.init();
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
448
  return result;
350
- } catch (error) {
351
- this.updateStats(performance.now() - startTime, true);
352
- throw error;
353
449
  }
354
- }
355
-
356
- async callMethod<T = unknown>(
357
- handle: string,
358
- methodName: string,
359
- args: unknown[],
360
- kwargs?: Record<string, unknown>
361
- ): Promise<T> {
362
- await this.init();
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
- }
377
- }
378
450
 
379
- async disposeInstance(handle: string): Promise<void> {
380
- await this.init();
381
- await this.executeRequest<void>({
382
- method: 'dispose_instance',
383
- params: { handle },
384
- });
451
+ // No caching - direct call
452
+ return super.call<T>(module, functionName, args, kwargs);
385
453
  }
386
454
 
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);
408
- }
455
+ // ===========================================================================
456
+ // POOL STATISTICS
457
+ // ===========================================================================
409
458
 
410
459
  /**
411
- * Select optimal worker based on load and performance
460
+ * Get current pool statistics.
412
461
  */
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;
432
- }
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
- });
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
-
478
- try {
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
- });
514
- });
515
- }
516
-
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
- },
462
+ getPoolStats(): { workerCount: number; queueLength: number; totalInFlight: number } {
463
+ return {
464
+ workerCount: this.pooledTransport.workerCount,
465
+ queueLength: this.pooledTransport.queueLength,
466
+ totalInFlight: this.pooledTransport.totalInFlight,
550
467
  };
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
468
  }
578
469
 
579
470
  /**
580
- * Setup event handlers for worker process
471
+ * Get bridge statistics.
472
+ *
473
+ * @deprecated Use getPoolStats() instead. This method is provided for
474
+ * backwards compatibility and returns a subset of the previous stats.
581
475
  */
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
- });
476
+ getStats(): {
477
+ totalRequests: number;
478
+ totalTime: number;
479
+ cacheHits: number;
480
+ poolHits: number;
481
+ poolMisses: number;
482
+ processSpawns: number;
483
+ processDeaths: number;
484
+ memoryPeak: number;
485
+ averageTime: number;
486
+ cacheHitRate: number;
487
+ poolSize: number;
488
+ busyWorkers: number;
489
+ } {
490
+ const poolStats = this.getPoolStats();
491
+ return {
492
+ // Legacy stats (no longer tracked, return 0)
493
+ totalRequests: 0,
494
+ totalTime: 0,
495
+ cacheHits: 0,
496
+ poolHits: 0,
497
+ poolMisses: 0,
498
+ processSpawns: 0,
499
+ processDeaths: 0,
500
+ memoryPeak: 0,
501
+ averageTime: 0,
502
+ cacheHitRate: 0,
503
+ // Current pool stats
504
+ poolSize: poolStats.workerCount,
505
+ busyWorkers: poolStats.totalInFlight,
506
+ };
610
507
  }
611
508
 
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
- }
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
- }
635
- }
509
+ // ===========================================================================
510
+ // PRIVATE HELPERS
511
+ // ===========================================================================
636
512
 
637
513
  /**
638
- * Warm up processes with configured commands
514
+ * Generate a cache key, returning null if generation fails.
639
515
  */
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
- });
653
-
654
- await Promise.all(warmupPromises);
655
- }
656
-
657
516
  private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
658
517
  try {
659
518
  return globalCache.generateKey(prefix, ...inputs);
@@ -663,10 +522,9 @@ export class NodeBridge extends RuntimeBridge {
663
522
  }
664
523
 
665
524
  /**
666
- * Heuristic to determine if function result should be cached
525
+ * Heuristic to determine if function result should be cached.
667
526
  */
668
527
  private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
669
- // Simple heuristics - could be made more sophisticated
670
528
  const pureFunctionPatterns = [
671
529
  /^(get|fetch|read|load|find|search|query|select)_/i,
672
530
  /^(compute|calculate|process|transform|convert)_/i,
@@ -679,222 +537,251 @@ export class NodeBridge extends RuntimeBridge {
679
537
  /random|uuid|timestamp|now|current/i,
680
538
  ];
681
539
 
682
- // Don't cache if function name suggests mutation
683
540
  if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
684
541
  return false;
685
542
  }
686
543
 
687
- // Cache if function name suggests pure computation
688
544
  if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
689
545
  return true;
690
546
  }
691
547
 
692
- // Don't cache if args contain mutable objects (very basic check)
693
548
  const hasComplexArgs = args.some(
694
549
  arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
695
550
  );
696
551
 
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
- }
552
+ return !hasComplexArgs && args.length <= 3;
711
553
  }
554
+ }
712
555
 
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;
556
+ // =============================================================================
557
+ // WARMUP CALLBACK
558
+ // =============================================================================
721
559
 
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
- );
560
+ /**
561
+ * Simple request ID generator for warmup commands.
562
+ */
563
+ let warmupRequestId = 0;
564
+ function generateWarmupId(): number {
565
+ return ++warmupRequestId;
566
+ }
751
567
 
752
- for (const worker of idleWorkers) {
753
- await this.terminateWorker(worker);
568
+ /**
569
+ * Create a callback that runs warmup commands on each worker.
570
+ *
571
+ * The callback sends warmup commands directly to the worker's transport,
572
+ * bypassing the pool to ensure each worker gets warmed up individually.
573
+ */
574
+ function createWarmupCallback(
575
+ warmupCommands: WarmupCommand[],
576
+ timeoutMs: number
577
+ ): (worker: PooledWorker) => Promise<void> {
578
+ return async (worker: PooledWorker) => {
579
+ for (const [index, cmd] of warmupCommands.entries()) {
580
+ const commandLabel = `${cmd.module}.${cmd.functionName}`;
581
+ await executeWorkerCall(
582
+ worker,
583
+ {
584
+ module: cmd.module,
585
+ functionName: cmd.functionName,
586
+ args: cmd.args ?? [],
587
+ },
588
+ timeoutMs,
589
+ {
590
+ label: `Warmup command #${index + 1} (${commandLabel})`,
591
+ invalidJsonMessage: 'returned invalid JSON response',
592
+ malformedEnvelopeMessage: requestId =>
593
+ `returned malformed response envelope for request ${requestId}`,
594
+ pythonErrorMessage: (errorType, errorMessage) => `failed: ${errorType}: ${errorMessage}`,
595
+ malformedErrorPayloadMessage: 'failed with malformed error payload',
596
+ }
597
+ );
754
598
  }
599
+ };
600
+ }
755
601
 
756
- // Restart workers that have handled too many requests
757
- const overusedWorkers = this.processPool.filter(
758
- w => !w.busy && w.requestCount >= this.options.maxRequestsPerProcess
602
+ function createWorkerReadyCallback(
603
+ timeoutMs: number,
604
+ extraWarmup?: (worker: PooledWorker) => Promise<void>
605
+ ): (worker: PooledWorker) => Promise<void> {
606
+ return async (worker: PooledWorker) => {
607
+ const result = await executeWorkerCall(
608
+ worker,
609
+ {
610
+ module: 'builtins',
611
+ functionName: 'len',
612
+ args: [[]],
613
+ },
614
+ Math.max(timeoutMs, WORKER_READY_TIMEOUT_MS),
615
+ {
616
+ label: 'Worker readiness probe (builtins.len)',
617
+ invalidJsonMessage: 'returned invalid JSON response',
618
+ malformedEnvelopeMessage: requestId =>
619
+ `returned malformed response envelope for request ${requestId}`,
620
+ pythonErrorMessage: (errorType, errorMessage) => `failed: ${errorType}: ${errorMessage}`,
621
+ malformedErrorPayloadMessage: 'failed with malformed error payload',
622
+ }
759
623
  );
760
624
 
761
- for (const worker of overusedWorkers) {
762
- await this.terminateWorker(worker);
763
- if (this.processPool.length < this.options.minProcesses) {
764
- await this.spawnProcess();
765
- }
625
+ if (result !== 0) {
626
+ throw new BridgeExecutionError(
627
+ `Worker readiness probe (builtins.len) returned unexpected result: ${JSON.stringify(result)}`
628
+ );
766
629
  }
767
- }
768
630
 
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
- }
631
+ await extraWarmup?.(worker);
632
+ };
633
+ }
779
634
 
780
- const index = this.processPool.indexOf(worker);
781
- if (index >= 0) {
782
- this.processPool.splice(index, 1);
783
- this.stats.processDeaths++;
784
- }
635
+ interface WorkerCallSpec {
636
+ module: string;
637
+ functionName: string;
638
+ args: unknown[];
639
+ }
785
640
 
786
- worker.core.handleProcessExit();
787
- worker.core.clear();
641
+ interface WorkerCallErrorMessages {
642
+ label: string;
643
+ invalidJsonMessage: string;
644
+ malformedEnvelopeMessage: (requestId: number) => string;
645
+ pythonErrorMessage: (errorType: string, errorMessage: string) => string;
646
+ malformedErrorPayloadMessage: string;
647
+ }
788
648
 
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
- }
649
+ async function executeWorkerCall(
650
+ worker: PooledWorker,
651
+ request: WorkerCallSpec,
652
+ timeoutMs: number,
653
+ messages: WorkerCallErrorMessages
654
+ ): Promise<unknown> {
655
+ const requestId = generateWarmupId();
656
+ let message: string;
657
+ try {
658
+ message = JSON.stringify({
659
+ id: requestId,
660
+ protocol: PROTOCOL_ID,
661
+ method: 'call',
662
+ params: {
663
+ module: request.module,
664
+ functionName: request.functionName,
665
+ args: request.args,
666
+ kwargs: {},
667
+ },
668
+ });
669
+ } catch (error) {
670
+ throw new BridgeExecutionError(
671
+ `${messages.label} failed to encode request: ${error instanceof Error ? error.message : String(error)}`,
672
+ { cause: error instanceof Error ? error : undefined }
673
+ );
804
674
  }
805
675
 
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
676
+ let response: string;
677
+ try {
678
+ response = await worker.transport.send(message, timeoutMs);
679
+ } catch (error) {
680
+ throw new BridgeExecutionError(
681
+ `${messages.label} failed to send: ${error instanceof Error ? error.message : String(error)}`,
682
+ { cause: error instanceof Error ? error : undefined }
683
+ );
817
684
  }
818
685
 
819
- /**
820
- * Dispose all resources
821
- */
822
- async dispose(): Promise<void> {
823
- if (this.disposed) {
824
- return;
825
- }
686
+ let parsed: unknown;
687
+ try {
688
+ parsed = JSON.parse(response);
689
+ } catch (error) {
690
+ throw new BridgeExecutionError(`${messages.label} ${messages.invalidJsonMessage}`, {
691
+ cause: error instanceof Error ? error : undefined,
692
+ });
693
+ }
826
694
 
827
- this.disposed = true;
695
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
696
+ throw new BridgeExecutionError(
697
+ `${messages.label} ${messages.malformedEnvelopeMessage(requestId)}`
698
+ );
699
+ }
828
700
 
829
- if (this.cleanupTimer) {
830
- clearInterval(this.cleanupTimer);
831
- this.cleanupTimer = undefined;
701
+ const envelope = parsed as { result?: unknown; error?: unknown };
702
+ if ('error' in envelope && envelope.error !== undefined && envelope.error !== null) {
703
+ const errorPayload = envelope.error;
704
+ if (errorPayload && typeof errorPayload === 'object' && !Array.isArray(errorPayload)) {
705
+ const err = errorPayload as { type?: unknown; message?: unknown };
706
+ const errType = typeof err.type === 'string' ? err.type : 'Error';
707
+ const errMessage = typeof err.message === 'string' ? err.message : 'Unknown error';
708
+ throw new BridgeExecutionError(
709
+ `${messages.label} ${messages.pythonErrorMessage(errType, errMessage)}`
710
+ );
832
711
  }
833
712
 
834
- // Terminate all workers
835
- const terminationPromises = this.processPool.map(worker =>
836
- this.terminateWorker(worker, { force: true })
837
- );
838
- await Promise.all(terminationPromises);
713
+ throw new BridgeExecutionError(`${messages.label} ${messages.malformedErrorPayloadMessage}`);
714
+ }
839
715
 
840
- this.processPool.length = 0;
841
- this.emitter.removeAllListeners();
716
+ if (!('result' in envelope)) {
717
+ throw new BridgeExecutionError(
718
+ `${messages.label} ${messages.malformedEnvelopeMessage(requestId)}`
719
+ );
842
720
  }
843
721
 
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;
722
+ return envelope.result;
723
+ }
724
+
725
+ // =============================================================================
726
+ // ENVIRONMENT BUILDING
727
+ // =============================================================================
728
+
729
+ /**
730
+ * Build environment variables for ProcessIO.
731
+ */
732
+ function buildProcessEnv(options: ResolvedOptions): Record<string, string> {
733
+ const allowedPrefixes = ['TYWRAP_'];
734
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
735
+ const env = createNullPrototypeEnv();
736
+
737
+ // Copy allowed env vars from process.env
738
+ if (options.inheritProcessEnv) {
739
+ for (const [key, value] of Object.entries(process.env)) {
740
+ if (value !== undefined) {
741
+ setEnvValue(env, key, value);
852
742
  }
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;
861
- }
743
+ }
744
+ } else {
745
+ for (const [key, value] of Object.entries(process.env)) {
746
+ if (
747
+ value !== undefined &&
748
+ (allowedKeys.has(key.toLowerCase()) || allowedPrefixes.some(p => key.startsWith(p)))
749
+ ) {
750
+ setEnvValue(env, key, value);
862
751
  }
863
752
  }
753
+ }
864
754
 
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}`;
755
+ // Apply user overrides
756
+ for (const [key, value] of Object.entries(options.env)) {
757
+ assertSafeEnvOverrideKey(key);
758
+ if (value !== undefined) {
759
+ setEnvValue(env, key, value);
875
760
  }
761
+ }
876
762
 
877
- ensurePythonEncoding(env);
878
- ensureJsonFallback(env, this.options.enableJsonFallback);
879
-
880
- env = normalizeEnv(env, {});
881
- return env;
763
+ // Configure virtual environment
764
+ if (options.virtualEnv) {
765
+ const venv = resolveVirtualEnv(options.virtualEnv, options.cwd);
766
+ env.VIRTUAL_ENV = venv.venvPath;
767
+ const pathKey = getPathKey(env);
768
+ const currentPath = getEnvValue(env, pathKey);
769
+ setPathValue(env, currentPath ? `${venv.binDir}${delimiter}${currentPath}` : venv.binDir);
882
770
  }
883
771
 
884
- private async refreshBridgeInfo(): Promise<void> {
885
- const info = await this.executeRequest<BridgeInfo>({ method: 'meta', params: {} });
886
- validateBridgeInfo(info);
887
- this.bridgeInfo = info;
772
+ // Add cwd to PYTHONPATH so Python can find modules in the working directory
773
+ if (options.cwd) {
774
+ const currentPythonPath = env.PYTHONPATH ?? '';
775
+ env.PYTHONPATH = currentPythonPath
776
+ ? `${options.cwd}${delimiter}${currentPythonPath}`
777
+ : options.cwd;
888
778
  }
889
- }
890
779
 
891
- /**
892
- * @deprecated Use NodeBridge with minProcesses/maxProcesses options instead.
893
- * This alias is provided for backward compatibility.
894
- */
895
- export { NodeBridge as OptimizedNodeBridge };
780
+ // Ensure Python uses UTF-8
781
+ env.PYTHONUTF8 = '1';
782
+ env.PYTHONIOENCODING = 'UTF-8';
783
+ env.PYTHONUNBUFFERED = '1';
784
+ env.PYTHONDONTWRITEBYTECODE = '1';
896
785
 
897
- /**
898
- * @deprecated Use NodeBridgeOptions instead.
899
- */
900
- export type { NodeBridgeOptions as ProcessPoolOptions };
786
+ return env;
787
+ }