tywrap 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +20 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/base.d.ts +17 -7
  7. package/dist/runtime/base.d.ts.map +1 -1
  8. package/dist/runtime/base.js +18 -1
  9. package/dist/runtime/base.js.map +1 -1
  10. package/dist/runtime/bounded-context.d.ts +252 -0
  11. package/dist/runtime/bounded-context.d.ts.map +1 -0
  12. package/dist/runtime/bounded-context.js +454 -0
  13. package/dist/runtime/bounded-context.js.map +1 -0
  14. package/dist/runtime/bridge-protocol.d.ts +167 -0
  15. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  16. package/dist/runtime/bridge-protocol.js +247 -0
  17. package/dist/runtime/bridge-protocol.js.map +1 -0
  18. package/dist/runtime/disposable.d.ts +40 -0
  19. package/dist/runtime/disposable.d.ts.map +1 -0
  20. package/dist/runtime/disposable.js +49 -0
  21. package/dist/runtime/disposable.js.map +1 -0
  22. package/dist/runtime/http-io.d.ts +91 -0
  23. package/dist/runtime/http-io.d.ts.map +1 -0
  24. package/dist/runtime/http-io.js +195 -0
  25. package/dist/runtime/http-io.js.map +1 -0
  26. package/dist/runtime/http.d.ts +47 -13
  27. package/dist/runtime/http.d.ts.map +1 -1
  28. package/dist/runtime/http.js +55 -74
  29. package/dist/runtime/http.js.map +1 -1
  30. package/dist/runtime/node.d.ts +97 -130
  31. package/dist/runtime/node.d.ts.map +1 -1
  32. package/dist/runtime/node.js +256 -523
  33. package/dist/runtime/node.js.map +1 -1
  34. package/dist/runtime/pooled-transport.d.ts +131 -0
  35. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  36. package/dist/runtime/pooled-transport.js +175 -0
  37. package/dist/runtime/pooled-transport.js.map +1 -0
  38. package/dist/runtime/process-io.d.ts +204 -0
  39. package/dist/runtime/process-io.d.ts.map +1 -0
  40. package/dist/runtime/process-io.js +695 -0
  41. package/dist/runtime/process-io.js.map +1 -0
  42. package/dist/runtime/pyodide-io.d.ts +155 -0
  43. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  44. package/dist/runtime/pyodide-io.js +397 -0
  45. package/dist/runtime/pyodide-io.js.map +1 -0
  46. package/dist/runtime/pyodide.d.ts +51 -19
  47. package/dist/runtime/pyodide.d.ts.map +1 -1
  48. package/dist/runtime/pyodide.js +57 -186
  49. package/dist/runtime/pyodide.js.map +1 -1
  50. package/dist/runtime/safe-codec.d.ts +81 -0
  51. package/dist/runtime/safe-codec.d.ts.map +1 -0
  52. package/dist/runtime/safe-codec.js +345 -0
  53. package/dist/runtime/safe-codec.js.map +1 -0
  54. package/dist/runtime/transport.d.ts +186 -0
  55. package/dist/runtime/transport.d.ts.map +1 -0
  56. package/dist/runtime/transport.js +86 -0
  57. package/dist/runtime/transport.js.map +1 -0
  58. package/dist/runtime/validators.d.ts +131 -0
  59. package/dist/runtime/validators.d.ts.map +1 -0
  60. package/dist/runtime/validators.js +219 -0
  61. package/dist/runtime/validators.js.map +1 -0
  62. package/dist/runtime/worker-pool.d.ts +196 -0
  63. package/dist/runtime/worker-pool.d.ts.map +1 -0
  64. package/dist/runtime/worker-pool.js +371 -0
  65. package/dist/runtime/worker-pool.js.map +1 -0
  66. package/dist/utils/codec.d.ts.map +1 -1
  67. package/dist/utils/codec.js +120 -1
  68. package/dist/utils/codec.js.map +1 -1
  69. package/package.json +2 -2
  70. package/runtime/python_bridge.py +30 -3
  71. package/runtime/safe_codec.py +344 -0
  72. package/src/index.ts +48 -5
  73. package/src/runtime/base.ts +18 -26
  74. package/src/runtime/bounded-context.ts +608 -0
  75. package/src/runtime/bridge-protocol.ts +319 -0
  76. package/src/runtime/disposable.ts +65 -0
  77. package/src/runtime/http-io.ts +244 -0
  78. package/src/runtime/http.ts +71 -117
  79. package/src/runtime/node.ts +358 -691
  80. package/src/runtime/pooled-transport.ts +252 -0
  81. package/src/runtime/process-io.ts +902 -0
  82. package/src/runtime/pyodide-io.ts +485 -0
  83. package/src/runtime/pyodide.ts +75 -215
  84. package/src/runtime/safe-codec.ts +443 -0
  85. package/src/runtime/transport.ts +273 -0
  86. package/src/runtime/validators.ts +241 -0
  87. package/src/runtime/worker-pool.ts +498 -0
  88. package/src/utils/codec.ts +126 -1
@@ -1,145 +1,138 @@
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 { 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 type { PooledWorker } from './worker-pool.js';
26
+
27
+ // =============================================================================
28
+ // OPTIONS
29
+ // =============================================================================
39
30
 
40
31
  /**
41
32
  * 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
33
  */
46
34
  export interface NodeBridgeOptions {
47
35
  /** Minimum number of Python processes to keep alive. Default: 1 */
48
36
  minProcesses?: number;
37
+
49
38
  /** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
50
39
  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;
40
+
41
+ /** Maximum concurrent requests per process. Default: 10 */
42
+ maxConcurrentPerProcess?: number;
43
+
55
44
  /** Path to Python executable. Auto-detected if not specified. */
56
45
  pythonPath?: string;
46
+
57
47
  /** Path to python_bridge.py script. Auto-detected if not specified. */
58
48
  scriptPath?: string;
49
+
59
50
  /** Path to Python virtual environment. */
60
51
  virtualEnv?: string;
52
+
61
53
  /** Working directory for Python process. Default: process.cwd() */
62
54
  cwd?: string;
55
+
63
56
  /** Timeout in ms for Python calls. Default: 30000 */
64
57
  timeoutMs?: number;
65
- /** Maximum line length for JSONL protocol. */
66
- maxLineLength?: number;
58
+
59
+ /** Timeout in ms for waiting in pool queue. Default: 30000 */
60
+ queueTimeoutMs?: number;
61
+
67
62
  /** Inherit all environment variables from parent process. Default: false */
68
63
  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;
64
+
74
65
  /** Enable result caching for pure functions. Default: false */
75
66
  enableCache?: boolean;
67
+
76
68
  /** Optional extra environment variables to pass to the Python subprocess. */
77
69
  env?: Record<string, string | undefined>;
70
+
71
+ /** Codec options for validation/serialization */
72
+ codec?: CodecOptions;
73
+
78
74
  /** Commands to run on each process at startup for warming up. */
79
- warmupCommands?: Array<{ method: string; params: unknown }>;
75
+ warmupCommands?: Array<
76
+ | { module: string; functionName: string; args?: unknown[] }
77
+ | { method: string; params: unknown } // Legacy format for backwards compatibility
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 }>;
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
- };
122
+ codec?: CodecOptions;
123
+ warmupCommands: Array<
124
+ | { module: string; functionName: string; args?: unknown[] }
125
+ | { method: string; params: unknown }
126
+ >;
114
127
  }
115
128
 
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
- }>;
141
- }
129
+ // =============================================================================
130
+ // UTILITIES
131
+ // =============================================================================
142
132
 
133
+ /**
134
+ * Resolve the default bridge script path.
135
+ */
143
136
  function resolveDefaultScriptPath(): string {
144
137
  try {
145
138
  return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
@@ -148,173 +141,202 @@ function resolveDefaultScriptPath(): string {
148
141
  }
149
142
  }
150
143
 
144
+ /**
145
+ * Resolve virtual environment paths.
146
+ */
151
147
  function resolveVirtualEnv(
152
148
  virtualEnv: string,
153
149
  cwd: string
154
- ): {
155
- venvPath: string;
156
- binDir: string;
157
- pythonPath: string;
158
- } {
150
+ ): { venvPath: string; binDir: string; pythonPath: string } {
159
151
  const venvPath = resolve(cwd, virtualEnv);
160
152
  const binDir = join(venvPath, getVenvBinDir());
161
153
  const pythonPath = join(binDir, getVenvPythonExe());
162
154
  return { venvPath, binDir, pythonPath };
163
155
  }
164
156
 
157
+ /**
158
+ * Get the environment variable key for PATH (case-insensitive on Windows).
159
+ */
160
+ function getPathKey(env: Record<string, string | undefined>): string {
161
+ for (const key of Object.keys(env)) {
162
+ if (key.toLowerCase() === 'path') {
163
+ return key;
164
+ }
165
+ }
166
+ return 'PATH';
167
+ }
168
+
169
+ // =============================================================================
170
+ // NODE BRIDGE
171
+ // =============================================================================
172
+
165
173
  /**
166
174
  * Node.js runtime bridge for executing Python code.
167
175
  *
168
- * By default, runs in single-process mode for correctness-first behavior.
169
- * Configure minProcesses/maxProcesses for process pooling in high-throughput scenarios.
176
+ * NodeBridge provides subprocess-based Python execution with optional pooling
177
+ * for high-throughput workloads. By default, it runs in single-process mode.
178
+ *
179
+ * Features:
180
+ * - Single or multi-process execution via process pooling
181
+ * - Virtual environment support
182
+ * - Full SafeCodec validation (NaN/Infinity rejection, key validation)
183
+ * - Automatic Arrow decoding for DataFrames/ndarrays
184
+ * - Optional result caching for pure functions
185
+ * - Process warmup commands
170
186
  *
171
187
  * @example
172
188
  * ```typescript
173
189
  * // Single-process mode (default)
174
190
  * const bridge = new NodeBridge();
191
+ * await bridge.init();
192
+ *
193
+ * const result = await bridge.call('math', 'sqrt', [16]);
194
+ * console.log(result); // 4.0
175
195
  *
196
+ * await bridge.dispose();
197
+ * ```
198
+ *
199
+ * @example
200
+ * ```typescript
176
201
  * // Multi-process pooling for high throughput
177
202
  * const pooledBridge = new NodeBridge({
178
- * minProcesses: 2,
179
- * maxProcesses: 8,
203
+ * maxProcesses: 4,
204
+ * maxConcurrentPerProcess: 2,
180
205
  * enableCache: true,
181
206
  * });
207
+ * await pooledBridge.init();
182
208
  * ```
183
209
  */
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
- };
210
+ export class NodeBridge extends BridgeProtocol {
211
+ private readonly resolvedOptions: ResolvedOptions;
212
+ private readonly pooledTransport: PooledTransport;
207
213
 
214
+ /**
215
+ * Create a new NodeBridge instance.
216
+ *
217
+ * @param options - Configuration options for the bridge
218
+ */
208
219
  constructor(options: NodeBridgeOptions = {}) {
209
- super();
210
220
  const cwd = options.cwd ?? process.cwd();
211
221
  const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
212
222
  const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
213
223
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
214
224
  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,
225
+
226
+ const maxProcesses = options.maxProcesses ?? 1;
227
+ const minProcesses = Math.min(options.minProcesses ?? 1, maxProcesses);
228
+
229
+ const resolvedOptions: ResolvedOptions = {
230
+ minProcesses,
231
+ maxProcesses,
232
+ maxConcurrentPerProcess: options.maxConcurrentPerProcess ?? 10,
221
233
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
222
234
  scriptPath: resolvedScriptPath,
223
235
  virtualEnv,
224
236
  cwd,
225
237
  timeoutMs: options.timeoutMs ?? 30000,
226
- maxLineLength: options.maxLineLength,
238
+ queueTimeoutMs: options.queueTimeoutMs ?? 30000,
227
239
  inheritProcessEnv: options.inheritProcessEnv ?? false,
228
- enableJsonFallback: options.enableJsonFallback ?? false,
229
240
  enableCache: options.enableCache ?? false,
230
241
  env: options.env ?? {},
242
+ codec: options.codec,
231
243
  warmupCommands: options.warmupCommands ?? [],
232
244
  };
233
245
 
234
- // Start cleanup scheduler for pooled mode
235
- this.startCleanupScheduler();
236
- }
246
+ // Build environment for ProcessIO
247
+ const processEnv = buildProcessEnv(resolvedOptions);
248
+
249
+ // Create warmup callback for per-worker initialization
250
+ const onWorkerReady = resolvedOptions.warmupCommands.length > 0
251
+ ? createWarmupCallback(resolvedOptions.warmupCommands, resolvedOptions.timeoutMs)
252
+ : undefined;
253
+
254
+ // Create pooled transport with ProcessIO workers
255
+ const transport = new PooledTransport({
256
+ createTransport: () =>
257
+ new ProcessIO({
258
+ pythonPath: resolvedOptions.pythonPath,
259
+ bridgeScript: resolvedOptions.scriptPath,
260
+ env: processEnv,
261
+ cwd: resolvedOptions.cwd,
262
+ }),
263
+ maxWorkers: resolvedOptions.maxProcesses,
264
+ minWorkers: resolvedOptions.minProcesses,
265
+ queueTimeoutMs: resolvedOptions.queueTimeoutMs,
266
+ maxConcurrentPerWorker: resolvedOptions.maxConcurrentPerProcess,
267
+ onWorkerReady,
268
+ });
237
269
 
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;
270
+ // Initialize BridgeProtocol with pooled transport
271
+ const protocolOptions: BridgeProtocolOptions = {
272
+ transport,
273
+ codec: resolvedOptions.codec,
274
+ defaultTimeoutMs: resolvedOptions.timeoutMs,
275
+ };
276
+
277
+ super(protocolOptions);
278
+
279
+ this.resolvedOptions = resolvedOptions;
280
+ this.pooledTransport = transport;
250
281
  }
251
282
 
252
- private async doInit(): Promise<void> {
283
+ // ===========================================================================
284
+ // LIFECYCLE
285
+ // ===========================================================================
286
+
287
+ /**
288
+ * Initialize the bridge.
289
+ *
290
+ * Validates the bridge script exists, registers Arrow decoder,
291
+ * and initializes the transport pool (which runs warmup commands per-worker).
292
+ */
293
+ protected override async doInit(): Promise<void> {
294
+ // Validate script exists
253
295
  // 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}`);
296
+ if (!existsSync(this.resolvedOptions.scriptPath)) {
297
+ throw new BridgeProtocolError(
298
+ `Python bridge script not found at ${this.resolvedOptions.scriptPath}`
299
+ );
256
300
  }
257
301
 
302
+ // Register Arrow decoder for DataFrames/ndarrays
258
303
  const require = createRequire(import.meta.url);
259
304
  await autoRegisterArrowDecoder({
260
305
  loader: () => require('apache-arrow'),
261
306
  });
262
307
 
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
- }
308
+ // Initialize parent (which initializes transport and runs warmup per-worker)
309
+ await super.doInit();
275
310
  }
276
311
 
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
- }
312
+ // ===========================================================================
313
+ // CACHING OVERRIDE
314
+ // ===========================================================================
287
315
 
288
- async call<T = unknown>(
316
+ /**
317
+ * Override call() to add optional caching.
318
+ */
319
+ override async call<T = unknown>(
289
320
  module: string,
290
321
  functionName: string,
291
322
  args: unknown[],
292
323
  kwargs?: Record<string, unknown>
293
324
  ): 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;
325
+ // Check cache if enabled
326
+ if (this.resolvedOptions.enableCache) {
327
+ const cacheKey = this.safeCacheKey('runtime_call', module, functionName, args, kwargs);
328
+ if (cacheKey) {
329
+ const cached = await globalCache.get<T>(cacheKey);
330
+ if (cached !== null) {
331
+ return cached;
332
+ }
306
333
  }
307
- }
308
-
309
- try {
310
- const result = await this.executeRequest<T>({
311
- method: 'call',
312
- params: { module, functionName, args, kwargs },
313
- });
314
334
 
335
+ // Execute and cache if pure function
336
+ const startTime = performance.now();
337
+ const result = await super.call<T>(module, functionName, args, kwargs);
315
338
  const duration = performance.now() - startTime;
316
339
 
317
- // Cache result for pure functions (simple heuristic)
318
340
  if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
319
341
  await globalCache.set(cacheKey, result, {
320
342
  computeTime: duration,
@@ -322,338 +344,74 @@ export class NodeBridge extends RuntimeBridge {
322
344
  });
323
345
  }
324
346
 
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
347
  return result;
350
- } catch (error) {
351
- this.updateStats(performance.now() - startTime, true);
352
- throw error;
353
348
  }
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
349
 
371
- this.updateStats(performance.now() - startTime);
372
- return result;
373
- } catch (error) {
374
- this.updateStats(performance.now() - startTime, true);
375
- throw error;
376
- }
350
+ // No caching - direct call
351
+ return super.call<T>(module, functionName, args, kwargs);
377
352
  }
378
353
 
379
- async disposeInstance(handle: string): Promise<void> {
380
- await this.init();
381
- await this.executeRequest<void>({
382
- method: 'dispose_instance',
383
- params: { handle },
384
- });
385
- }
354
+ // ===========================================================================
355
+ // POOL STATISTICS
356
+ // ===========================================================================
386
357
 
387
358
  /**
388
- * Execute request with intelligent process selection
359
+ * Get current pool statistics.
389
360
  */
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
- }
409
-
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;
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
- },
361
+ getPoolStats(): { workerCount: number; queueLength: number; totalInFlight: number } {
362
+ return {
363
+ workerCount: this.pooledTransport.workerCount,
364
+ queueLength: this.pooledTransport.queueLength,
365
+ totalInFlight: this.pooledTransport.totalInFlight,
550
366
  };
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
367
  }
578
368
 
579
369
  /**
580
- * Setup event handlers for worker process
370
+ * Get bridge statistics.
371
+ *
372
+ * @deprecated Use getPoolStats() instead. This method is provided for
373
+ * backwards compatibility and returns a subset of the previous stats.
581
374
  */
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
- });
375
+ getStats(): {
376
+ totalRequests: number;
377
+ totalTime: number;
378
+ cacheHits: number;
379
+ poolHits: number;
380
+ poolMisses: number;
381
+ processSpawns: number;
382
+ processDeaths: number;
383
+ memoryPeak: number;
384
+ averageTime: number;
385
+ cacheHitRate: number;
386
+ poolSize: number;
387
+ busyWorkers: number;
388
+ } {
389
+ const poolStats = this.getPoolStats();
390
+ return {
391
+ // Legacy stats (no longer tracked, return 0)
392
+ totalRequests: 0,
393
+ totalTime: 0,
394
+ cacheHits: 0,
395
+ poolHits: 0,
396
+ poolMisses: 0,
397
+ processSpawns: 0,
398
+ processDeaths: 0,
399
+ memoryPeak: 0,
400
+ averageTime: 0,
401
+ cacheHitRate: 0,
402
+ // Current pool stats
403
+ poolSize: poolStats.workerCount,
404
+ busyWorkers: poolStats.totalInFlight,
405
+ };
610
406
  }
611
407
 
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
- }
408
+ // ===========================================================================
409
+ // PRIVATE HELPERS
410
+ // ===========================================================================
636
411
 
637
412
  /**
638
- * Warm up processes with configured commands
413
+ * Generate a cache key, returning null if generation fails.
639
414
  */
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
415
  private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
658
416
  try {
659
417
  return globalCache.generateKey(prefix, ...inputs);
@@ -663,10 +421,9 @@ export class NodeBridge extends RuntimeBridge {
663
421
  }
664
422
 
665
423
  /**
666
- * Heuristic to determine if function result should be cached
424
+ * Heuristic to determine if function result should be cached.
667
425
  */
668
426
  private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
669
- // Simple heuristics - could be made more sophisticated
670
427
  const pureFunctionPatterns = [
671
428
  /^(get|fetch|read|load|find|search|query|select)_/i,
672
429
  /^(compute|calculate|process|transform|convert)_/i,
@@ -679,222 +436,132 @@ export class NodeBridge extends RuntimeBridge {
679
436
  /random|uuid|timestamp|now|current/i,
680
437
  ];
681
438
 
682
- // Don't cache if function name suggests mutation
683
439
  if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
684
440
  return false;
685
441
  }
686
442
 
687
- // Cache if function name suggests pure computation
688
443
  if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
689
444
  return true;
690
445
  }
691
446
 
692
- // Don't cache if args contain mutable objects (very basic check)
693
447
  const hasComplexArgs = args.some(
694
448
  arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
695
449
  );
696
450
 
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
- };
451
+ return !hasComplexArgs && args.length <= 3;
738
452
  }
453
+ }
739
454
 
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
- );
455
+ // =============================================================================
456
+ // WARMUP CALLBACK
457
+ // =============================================================================
751
458
 
752
- for (const worker of idleWorkers) {
753
- await this.terminateWorker(worker);
754
- }
459
+ /**
460
+ * Simple request ID generator for warmup commands.
461
+ */
462
+ let warmupRequestId = 0;
463
+ function generateWarmupId(): string {
464
+ return `warmup_${++warmupRequestId}_${Date.now()}`;
465
+ }
755
466
 
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
- );
467
+ /**
468
+ * Create a callback that runs warmup commands on each worker.
469
+ *
470
+ * The callback sends warmup commands directly to the worker's transport,
471
+ * bypassing the pool to ensure each worker gets warmed up individually.
472
+ */
473
+ function createWarmupCallback(
474
+ warmupCommands: Array<
475
+ | { module: string; functionName: string; args?: unknown[] }
476
+ | { method: string; params: unknown }
477
+ >,
478
+ timeoutMs: number
479
+ ): (worker: PooledWorker) => Promise<void> {
480
+ return async (worker: PooledWorker) => {
481
+ for (const cmd of warmupCommands) {
482
+ try {
483
+ // Handle both new and legacy warmup command formats
484
+ if ('module' in cmd && 'functionName' in cmd) {
485
+ // Build the protocol message
486
+ const message = JSON.stringify({
487
+ id: generateWarmupId(),
488
+ type: 'call',
489
+ module: cmd.module,
490
+ functionName: cmd.functionName,
491
+ args: cmd.args ?? [],
492
+ kwargs: {},
493
+ });
760
494
 
761
- for (const worker of overusedWorkers) {
762
- await this.terminateWorker(worker);
763
- if (this.processPool.length < this.options.minProcesses) {
764
- await this.spawnProcess();
495
+ // Send directly to this worker's transport
496
+ await worker.transport.send(message, timeoutMs);
497
+ }
498
+ // Legacy format { method, params } is ignored as it's not supported
499
+ } catch {
500
+ // Ignore warmup errors - they're not critical
765
501
  }
766
502
  }
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
- }
503
+ };
504
+ }
785
505
 
786
- worker.core.handleProcessExit();
787
- worker.core.clear();
506
+ // =============================================================================
507
+ // ENVIRONMENT BUILDING
508
+ // =============================================================================
788
509
 
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);
510
+ /**
511
+ * Build environment variables for ProcessIO.
512
+ */
513
+ function buildProcessEnv(options: ResolvedOptions): Record<string, string> {
514
+ const allowedPrefixes = ['TYWRAP_'];
515
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
516
+ const env: Record<string, string> = {};
517
+
518
+ // Copy allowed env vars from process.env
519
+ if (options.inheritProcessEnv) {
520
+ for (const [key, value] of Object.entries(process.env)) {
521
+ if (value !== undefined) {
522
+ env[key] = value;
800
523
  }
801
- } catch (error) {
802
- log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
803
524
  }
804
- }
805
-
806
- /**
807
- * Start cleanup scheduler
808
- */
809
- private startCleanupScheduler(): void {
810
- this.cleanupTimer = setInterval(async () => {
811
- try {
812
- await this.cleanup();
813
- } catch (error) {
814
- log.error('Cleanup error', { error: String(error) });
525
+ } else {
526
+ for (const [key, value] of Object.entries(process.env)) {
527
+ if (
528
+ value !== undefined &&
529
+ (allowedKeys.has(key.toLowerCase()) || allowedPrefixes.some(p => key.startsWith(p)))
530
+ ) {
531
+ env[key] = value;
815
532
  }
816
- }, 60000); // Cleanup every minute
817
- }
818
-
819
- /**
820
- * Dispose all resources
821
- */
822
- async dispose(): Promise<void> {
823
- if (this.disposed) {
824
- return;
825
- }
826
-
827
- this.disposed = true;
828
-
829
- if (this.cleanupTimer) {
830
- clearInterval(this.cleanupTimer);
831
- this.cleanupTimer = undefined;
832
533
  }
833
-
834
- // Terminate all workers
835
- const terminationPromises = this.processPool.map(worker =>
836
- this.terminateWorker(worker, { force: true })
837
- );
838
- await Promise.all(terminationPromises);
839
-
840
- this.processPool.length = 0;
841
- this.emitter.removeAllListeners();
842
534
  }
843
535
 
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;
861
- }
862
- }
863
- }
864
-
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}`;
536
+ // Apply user overrides
537
+ for (const [key, value] of Object.entries(options.env)) {
538
+ if (value !== undefined) {
539
+ env[key] = value;
875
540
  }
541
+ }
876
542
 
877
- ensurePythonEncoding(env);
878
- ensureJsonFallback(env, this.options.enableJsonFallback);
879
-
880
- env = normalizeEnv(env, {});
881
- return env;
543
+ // Configure virtual environment
544
+ if (options.virtualEnv) {
545
+ const venv = resolveVirtualEnv(options.virtualEnv, options.cwd);
546
+ env.VIRTUAL_ENV = venv.venvPath;
547
+ const pathKey = getPathKey(env);
548
+ const currentPath = env[pathKey] ?? '';
549
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
882
550
  }
883
551
 
884
- private async refreshBridgeInfo(): Promise<void> {
885
- const info = await this.executeRequest<BridgeInfo>({ method: 'meta', params: {} });
886
- validateBridgeInfo(info);
887
- this.bridgeInfo = info;
552
+ // Add cwd to PYTHONPATH so Python can find modules in the working directory
553
+ if (options.cwd) {
554
+ const currentPythonPath = env.PYTHONPATH ?? '';
555
+ env.PYTHONPATH = currentPythonPath
556
+ ? `${options.cwd}${delimiter}${currentPythonPath}`
557
+ : options.cwd;
888
558
  }
889
- }
890
559
 
891
- /**
892
- * @deprecated Use NodeBridge with minProcesses/maxProcesses options instead.
893
- * This alias is provided for backward compatibility.
894
- */
895
- export { NodeBridge as OptimizedNodeBridge };
560
+ // Ensure Python uses UTF-8
561
+ env.PYTHONUTF8 = '1';
562
+ env.PYTHONIOENCODING = 'UTF-8';
563
+ env.PYTHONUNBUFFERED = '1';
564
+ env.PYTHONDONTWRITEBYTECODE = '1';
896
565
 
897
- /**
898
- * @deprecated Use NodeBridgeOptions instead.
899
- */
900
- export type { NodeBridgeOptions as ProcessPoolOptions };
566
+ return env;
567
+ }