tywrap 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +32 -5
  2. package/dist/cli.js +70 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +47 -11
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +6 -2
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/core/mapper.d.ts.map +1 -1
  11. package/dist/core/mapper.js +16 -4
  12. package/dist/core/mapper.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/runtime/bridge-core.d.ts +64 -0
  18. package/dist/runtime/bridge-core.d.ts.map +1 -0
  19. package/dist/runtime/bridge-core.js +344 -0
  20. package/dist/runtime/bridge-core.js.map +1 -0
  21. package/dist/runtime/node.d.ts +161 -15
  22. package/dist/runtime/node.d.ts.map +1 -1
  23. package/dist/runtime/node.js +542 -218
  24. package/dist/runtime/node.js.map +1 -1
  25. package/dist/runtime/optimized-node.d.ts +19 -128
  26. package/dist/runtime/optimized-node.d.ts.map +1 -1
  27. package/dist/runtime/optimized-node.js +19 -627
  28. package/dist/runtime/optimized-node.js.map +1 -1
  29. package/dist/runtime/timed-out-request-tracker.d.ts +20 -0
  30. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -0
  31. package/dist/runtime/timed-out-request-tracker.js +48 -0
  32. package/dist/runtime/timed-out-request-tracker.js.map +1 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/tywrap.d.ts +17 -4
  36. package/dist/tywrap.d.ts.map +1 -1
  37. package/dist/tywrap.js +68 -35
  38. package/dist/tywrap.js.map +1 -1
  39. package/dist/utils/codec.d.ts +22 -0
  40. package/dist/utils/codec.d.ts.map +1 -1
  41. package/dist/utils/codec.js +245 -55
  42. package/dist/utils/codec.js.map +1 -1
  43. package/dist/utils/logger.d.ts.map +1 -1
  44. package/dist/utils/logger.js +1 -0
  45. package/dist/utils/logger.js.map +1 -1
  46. package/dist/utils/python.d.ts.map +1 -1
  47. package/dist/utils/python.js.map +1 -1
  48. package/package.json +11 -2
  49. package/runtime/python_bridge.py +450 -69
  50. package/src/cli.ts +75 -3
  51. package/src/config/index.ts +58 -15
  52. package/src/core/generator.ts +6 -2
  53. package/src/core/mapper.ts +16 -4
  54. package/src/index.ts +2 -1
  55. package/src/runtime/bridge-core.ts +454 -0
  56. package/src/runtime/node.ts +725 -253
  57. package/src/runtime/optimized-node.ts +19 -844
  58. package/src/runtime/timed-out-request-tracker.ts +58 -0
  59. package/src/types/index.ts +3 -0
  60. package/src/tywrap.ts +91 -36
  61. package/src/utils/codec.ts +299 -82
  62. package/src/utils/logger.ts +1 -0
  63. package/src/utils/python.ts +0 -1
@@ -1,15 +1,26 @@
1
1
  /**
2
- * Node.js runtime bridge (minimal MVP)
2
+ * Node.js Runtime Bridge with Optional Connection Pooling
3
+ *
4
+ * NodeBridge is the unified Node.js runtime bridge that supports both single-process
5
+ * (correctness-first) and multi-process (pooled) configurations. By default, it runs
6
+ * in single-process mode for maximum compatibility with the original NodeBridge behavior.
7
+ *
8
+ * For high-throughput workloads, configure pooling via minProcesses/maxProcesses options.
3
9
  */
4
10
  import { existsSync } from 'node:fs';
5
11
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
6
12
  import { fileURLToPath } from 'node:url';
7
- import { decodeValueAsync } from '../utils/codec.js';
13
+ import { createRequire } from 'node:module';
14
+ import { EventEmitter } from 'events';
15
+ import { globalCache } from '../utils/cache.js';
16
+ import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
8
17
  import { getDefaultPythonPath } from '../utils/python.js';
9
18
  import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
10
19
  import { RuntimeBridge } from './base.js';
11
- import { BridgeDisposedError, BridgeExecutionError, BridgeProtocolError, BridgeTimeoutError, } from './errors.js';
12
- import { TYWRAP_PROTOCOL, TYWRAP_PROTOCOL_VERSION } from './protocol.js';
20
+ import { BridgeDisposedError, BridgeProtocolError } from './errors.js';
21
+ import { BridgeCore, ensureJsonFallback, ensurePythonEncoding, getMaxLineLengthFromEnv, getPathKey, normalizeEnv, validateBridgeInfo, } from './bridge-core.js';
22
+ import { getComponentLogger } from '../utils/logger.js';
23
+ const log = getComponentLogger('NodeBridge');
13
24
  function resolveDefaultScriptPath() {
14
25
  try {
15
26
  return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
@@ -24,16 +35,47 @@ function resolveVirtualEnv(virtualEnv, cwd) {
24
35
  const pythonPath = join(binDir, getVenvPythonExe());
25
36
  return { venvPath, binDir, pythonPath };
26
37
  }
38
+ /**
39
+ * Node.js runtime bridge for executing Python code.
40
+ *
41
+ * By default, runs in single-process mode for correctness-first behavior.
42
+ * Configure minProcesses/maxProcesses for process pooling in high-throughput scenarios.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * // Single-process mode (default)
47
+ * const bridge = new NodeBridge();
48
+ *
49
+ * // Multi-process pooling for high throughput
50
+ * const pooledBridge = new NodeBridge({
51
+ * minProcesses: 2,
52
+ * maxProcesses: 8,
53
+ * enableCache: true,
54
+ * });
55
+ * ```
56
+ */
27
57
  export class NodeBridge extends RuntimeBridge {
28
- child;
29
- nextId = 1;
30
- pending = new Map();
58
+ processPool = [];
59
+ roundRobinIndex = 0;
60
+ cleanupTimer;
31
61
  options;
32
- stderrBuffer = '';
62
+ emitter = new EventEmitter();
33
63
  disposed = false;
34
- protocolError = false;
35
- initPromise;
36
64
  bridgeInfo;
65
+ initPromise;
66
+ // Performance monitoring
67
+ stats = {
68
+ totalRequests: 0,
69
+ totalTime: 0,
70
+ cacheHits: 0,
71
+ poolHits: 0,
72
+ poolMisses: 0,
73
+ processSpawns: 0,
74
+ processDeaths: 0,
75
+ memoryPeak: 0,
76
+ averageTime: 0,
77
+ cacheHitRate: 0,
78
+ };
37
79
  constructor(options = {}) {
38
80
  super();
39
81
  const cwd = options.cwd ?? process.cwd();
@@ -42,31 +84,58 @@ export class NodeBridge extends RuntimeBridge {
42
84
  const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
43
85
  const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
44
86
  this.options = {
87
+ // Default to single-process mode for backward compatibility
88
+ minProcesses: options.minProcesses ?? 1,
89
+ maxProcesses: options.maxProcesses ?? 1,
90
+ maxIdleTime: options.maxIdleTime ?? 300000, // 5 minutes
91
+ maxRequestsPerProcess: options.maxRequestsPerProcess ?? 1000,
45
92
  pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
46
93
  scriptPath: resolvedScriptPath,
47
94
  virtualEnv,
48
95
  cwd,
49
96
  timeoutMs: options.timeoutMs ?? 30000,
97
+ maxLineLength: options.maxLineLength,
98
+ inheritProcessEnv: options.inheritProcessEnv ?? false,
50
99
  enableJsonFallback: options.enableJsonFallback ?? false,
100
+ enableCache: options.enableCache ?? false,
51
101
  env: options.env ?? {},
102
+ warmupCommands: options.warmupCommands ?? [],
52
103
  };
104
+ // Start cleanup scheduler for pooled mode
105
+ this.startCleanupScheduler();
53
106
  }
54
107
  async init() {
55
108
  if (this.disposed) {
56
109
  throw new BridgeDisposedError('Bridge has been disposed');
57
110
  }
58
- if (this.child) {
59
- return;
111
+ if (this.processPool.length >= this.options.minProcesses) {
112
+ return; // Already initialized
60
113
  }
61
114
  if (this.initPromise) {
62
115
  return this.initPromise;
63
116
  }
117
+ this.initPromise = this.doInit();
118
+ return this.initPromise;
119
+ }
120
+ async doInit() {
64
121
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
65
122
  if (!existsSync(this.options.scriptPath)) {
66
123
  throw new BridgeProtocolError(`Python bridge script not found at ${this.options.scriptPath}`);
67
124
  }
68
- this.initPromise = this.startProcess();
69
- return this.initPromise;
125
+ const require = createRequire(import.meta.url);
126
+ await autoRegisterArrowDecoder({
127
+ loader: () => require('apache-arrow'),
128
+ });
129
+ // Ensure minimum processes are available
130
+ while (this.processPool.length < this.options.minProcesses) {
131
+ await this.spawnProcess();
132
+ }
133
+ // Validate protocol version
134
+ await this.refreshBridgeInfo();
135
+ // Warm up processes if configured
136
+ if (this.options.warmupCommands.length > 0) {
137
+ await this.warmupProcesses();
138
+ }
70
139
  }
71
140
  async getBridgeInfo(options = {}) {
72
141
  await this.init();
@@ -80,247 +149,502 @@ export class NodeBridge extends RuntimeBridge {
80
149
  }
81
150
  async call(module, functionName, args, kwargs) {
82
151
  await this.init();
83
- return this.send({ method: 'call', params: { module, functionName, args, kwargs } });
152
+ const startTime = performance.now();
153
+ const cacheKey = this.options.enableCache
154
+ ? this.safeCacheKey('runtime_call', module, functionName, args, kwargs)
155
+ : null;
156
+ if (cacheKey) {
157
+ const cached = await globalCache.get(cacheKey);
158
+ if (cached !== null) {
159
+ this.stats.cacheHits++;
160
+ this.updateStats(performance.now() - startTime);
161
+ return cached;
162
+ }
163
+ }
164
+ try {
165
+ const result = await this.executeRequest({
166
+ method: 'call',
167
+ params: { module, functionName, args, kwargs },
168
+ });
169
+ const duration = performance.now() - startTime;
170
+ // Cache result for pure functions (simple heuristic)
171
+ if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
172
+ await globalCache.set(cacheKey, result, {
173
+ computeTime: duration,
174
+ dependencies: [module],
175
+ });
176
+ }
177
+ this.updateStats(duration);
178
+ return result;
179
+ }
180
+ catch (error) {
181
+ this.updateStats(performance.now() - startTime, true);
182
+ throw error;
183
+ }
84
184
  }
85
185
  async instantiate(module, className, args, kwargs) {
86
186
  await this.init();
87
- return this.send({ method: 'instantiate', params: { module, className, args, kwargs } });
187
+ const startTime = performance.now();
188
+ try {
189
+ const result = await this.executeRequest({
190
+ method: 'instantiate',
191
+ params: { module, className, args, kwargs },
192
+ });
193
+ this.updateStats(performance.now() - startTime);
194
+ return result;
195
+ }
196
+ catch (error) {
197
+ this.updateStats(performance.now() - startTime, true);
198
+ throw error;
199
+ }
88
200
  }
89
201
  async callMethod(handle, methodName, args, kwargs) {
90
202
  await this.init();
91
- return this.send({ method: 'call_method', params: { handle, methodName, args, kwargs } });
203
+ const startTime = performance.now();
204
+ try {
205
+ const result = await this.executeRequest({
206
+ method: 'call_method',
207
+ params: { handle, methodName, args, kwargs },
208
+ });
209
+ this.updateStats(performance.now() - startTime);
210
+ return result;
211
+ }
212
+ catch (error) {
213
+ this.updateStats(performance.now() - startTime, true);
214
+ throw error;
215
+ }
92
216
  }
93
217
  async disposeInstance(handle) {
94
218
  await this.init();
95
- await this.send({ method: 'dispose_instance', params: { handle } });
219
+ await this.executeRequest({
220
+ method: 'dispose_instance',
221
+ params: { handle },
222
+ });
96
223
  }
97
- async dispose() {
98
- this.disposed = true;
99
- this.initPromise = undefined;
100
- this.bridgeInfo = undefined;
101
- if (!this.child) {
102
- return;
224
+ /**
225
+ * Execute request with intelligent process selection
226
+ */
227
+ async executeRequest(payload) {
228
+ let worker = this.selectOptimalWorker();
229
+ // Spawn new process if none available and under limit
230
+ if (!worker && this.processPool.length < this.options.maxProcesses) {
231
+ try {
232
+ worker = await this.spawnProcess();
233
+ this.stats.poolMisses++;
234
+ }
235
+ catch (error) {
236
+ throw new Error(`Failed to spawn worker process: ${error}`);
237
+ }
103
238
  }
104
- this.child.kill('SIGTERM');
105
- this.child = undefined;
239
+ // Wait for worker if all are busy
240
+ worker ??= await this.waitForAvailableWorker();
241
+ this.stats.poolHits++;
242
+ return this.sendToWorker(worker, payload);
106
243
  }
107
- async send(payload) {
108
- if (this.disposed) {
109
- throw new BridgeDisposedError('Bridge has been disposed');
244
+ /**
245
+ * Select optimal worker based on load and performance
246
+ */
247
+ selectOptimalWorker() {
248
+ // For single-process mode, allow concurrent requests to the same worker.
249
+ // BridgeCore handles request ID multiplexing, so multiple in-flight requests are safe.
250
+ // This preserves original NodeBridge behavior where concurrent calls were allowed.
251
+ if (this.options.maxProcesses === 1 && this.processPool.length === 1) {
252
+ const worker = this.processPool[0];
253
+ if (worker && !worker.quarantined && worker.process.exitCode === null) {
254
+ return worker;
255
+ }
256
+ return null;
257
+ }
258
+ // For multi-worker pools, use busy status for load balancing
259
+ const availableWorkers = this.processPool.filter(w => !w.busy && !w.quarantined && w.process.exitCode === null);
260
+ if (availableWorkers.length === 0) {
261
+ return null;
110
262
  }
111
- const id = this.nextId++;
112
- const message = { id, protocol: TYWRAP_PROTOCOL, ...payload };
113
- const text = `${JSON.stringify(message)}\n`;
114
- let timer;
115
- const promise = new Promise((resolvePromise, reject) => {
116
- timer = setTimeout(() => {
117
- this.pending.delete(id);
118
- const stderrTail = this.stderrBuffer.trim();
119
- const msg = stderrTail
120
- ? `Python call timed out. Recent stderr from Python:\n${stderrTail}`
121
- : 'Python call timed out';
122
- reject(new BridgeTimeoutError(msg));
123
- }, this.options.timeoutMs);
124
- const resolveWrapped = (v) => {
125
- resolvePromise(v);
263
+ // Simple round-robin for now, could be enhanced with load-based selection
264
+ const worker = availableWorkers[this.roundRobinIndex % availableWorkers.length];
265
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % availableWorkers.length;
266
+ return worker ?? null;
267
+ }
268
+ /**
269
+ * Wait for any worker to become available
270
+ */
271
+ async waitForAvailableWorker(timeoutMs = 5000) {
272
+ return new Promise((resolvePromise, reject) => {
273
+ const timeout = setTimeout(() => {
274
+ reject(new Error('Timeout waiting for available worker'));
275
+ }, timeoutMs);
276
+ const checkWorker = () => {
277
+ const worker = this.selectOptimalWorker();
278
+ if (worker) {
279
+ clearTimeout(timeout);
280
+ resolvePromise(worker);
281
+ }
282
+ else {
283
+ // Check again in 10ms
284
+ setTimeout(checkWorker, 10);
285
+ }
126
286
  };
127
- this.pending.set(id, { resolve: resolveWrapped, reject, timer });
287
+ checkWorker();
128
288
  });
289
+ }
290
+ /**
291
+ * Send request to specific worker
292
+ */
293
+ async sendToWorker(worker, payload) {
294
+ worker.busy = true;
295
+ worker.requestCount++;
296
+ worker.lastUsed = Date.now();
297
+ const startTime = performance.now();
129
298
  try {
130
- if (!this.child?.stdin) {
131
- throw new BridgeProtocolError('Python process not available');
132
- }
133
- this.child.stdin.write(text);
299
+ const result = await worker.core.send(payload);
300
+ const duration = performance.now() - startTime;
301
+ worker.stats.totalTime += duration;
302
+ worker.stats.totalRequests++;
303
+ worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
304
+ return result;
134
305
  }
135
- catch (err) {
136
- this.pending.delete(id);
137
- if (timer) {
138
- clearTimeout(timer);
139
- }
140
- throw new BridgeProtocolError(`IPC failure: ${err.message}`);
306
+ catch (error) {
307
+ worker.stats.errorCount++;
308
+ throw error;
309
+ }
310
+ finally {
311
+ worker.busy = false;
141
312
  }
142
- return promise;
143
- }
144
- errorFrom(err) {
145
- const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
146
- e.traceback = err.traceback;
147
- return e;
148
313
  }
149
- handleProtocolError(details, line) {
150
- if (this.protocolError) {
314
+ quarantineWorker(worker, error) {
315
+ if (worker.quarantined) {
151
316
  return;
152
317
  }
153
- this.protocolError = true;
154
- const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}…` : line) : undefined;
155
- const hint = 'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
156
- const msg = snippet
157
- ? `Protocol error from Python bridge. ${details}\n${hint}\nOffending line: ${snippet}`
158
- : `Protocol error from Python bridge. ${details}\n${hint}`;
159
- const error = new BridgeProtocolError(msg);
160
- for (const [, p] of this.pending) {
161
- p.reject(error);
162
- }
163
- this.pending.clear();
164
- this.child?.kill('SIGTERM');
165
- this.child = undefined;
166
- this.initPromise = undefined;
167
- this.bridgeInfo = undefined;
318
+ worker.quarantined = true;
319
+ log.warn('Quarantining worker', { workerId: worker.id, error: String(error) });
320
+ this.terminateWorker(worker, { force: true })
321
+ .then(() => {
322
+ if (!this.disposed && this.processPool.length < this.options.minProcesses) {
323
+ this.spawnProcess().catch(spawnError => {
324
+ log.error('Failed to spawn replacement worker after quarantine', {
325
+ error: String(spawnError),
326
+ });
327
+ });
328
+ }
329
+ })
330
+ .catch(terminateError => {
331
+ log.warn('Failed to terminate quarantined worker', {
332
+ workerId: worker.id,
333
+ error: String(terminateError),
334
+ });
335
+ });
168
336
  }
169
- async startProcess() {
170
- try {
171
- const { spawn } = await import('child_process');
172
- const allowedPrefixes = ['TYWRAP_'];
173
- const allowedKeys = new Set(['PATH', 'PYTHONPATH', 'VIRTUAL_ENV', 'PYTHONHOME']);
174
- const baseEnv = new Map();
175
- for (const [k, v] of Object.entries(process.env)) {
176
- if (allowedKeys.has(k) || allowedPrefixes.some(p => k.startsWith(p))) {
177
- baseEnv.set(k, v);
337
+ /**
338
+ * Spawn new worker process with optimizations
339
+ */
340
+ async spawnProcess() {
341
+ const { spawn } = await import('child_process');
342
+ const workerId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
343
+ const env = this.buildEnv();
344
+ env.PYTHONUNBUFFERED = '1'; // Ensure immediate output
345
+ env.PYTHONDONTWRITEBYTECODE = '1'; // Skip .pyc files for faster startup
346
+ const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
347
+ const childProcess = spawn(this.options.pythonPath, [this.options.scriptPath], {
348
+ cwd: this.options.cwd,
349
+ stdio: ['pipe', 'pipe', 'pipe'],
350
+ env,
351
+ });
352
+ const worker = {
353
+ process: childProcess,
354
+ id: workerId,
355
+ requestCount: 0,
356
+ lastUsed: Date.now(),
357
+ busy: false,
358
+ quarantined: false,
359
+ core: null,
360
+ stats: {
361
+ totalRequests: 0,
362
+ totalTime: 0,
363
+ averageTime: 0,
364
+ errorCount: 0,
365
+ },
366
+ };
367
+ worker.core = new BridgeCore({
368
+ write: (data) => {
369
+ if (!worker.process.stdin?.writable) {
370
+ throw new BridgeProtocolError('Worker process stdin not writable');
178
371
  }
372
+ worker.process.stdin.write(data);
373
+ },
374
+ }, {
375
+ timeoutMs: this.options.timeoutMs,
376
+ maxLineLength,
377
+ decodeValue: decodeValueAsync,
378
+ onFatalError: (error) => this.quarantineWorker(worker, error),
379
+ onTimeout: (error) => this.quarantineWorker(worker, error),
380
+ });
381
+ // Setup process event handlers
382
+ this.setupProcessHandlers(worker);
383
+ this.processPool.push(worker);
384
+ this.stats.processSpawns++;
385
+ return worker;
386
+ }
387
+ /**
388
+ * Setup event handlers for worker process
389
+ */
390
+ setupProcessHandlers(worker) {
391
+ const childProcess = worker.process;
392
+ childProcess.stdout?.on('data', (chunk) => {
393
+ worker.core.handleStdoutData(chunk);
394
+ });
395
+ childProcess.stderr?.on('data', (chunk) => {
396
+ worker.core.handleStderrData(chunk);
397
+ const errorText = chunk.toString().trim();
398
+ if (errorText) {
399
+ log.warn('Worker stderr', { workerId: worker.id, output: errorText });
179
400
  }
180
- const env = {
181
- ...Object.fromEntries(baseEnv),
182
- ...this.options.env,
183
- };
184
- if (this.options.virtualEnv) {
185
- const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
186
- env.VIRTUAL_ENV = venv.venvPath;
187
- const currentPath = env.PATH ?? process.env.PATH ?? '';
188
- env.PATH = `${venv.binDir}${delimiter}${currentPath}`;
189
- }
190
- if (!env.PYTHONUTF8) {
191
- env.PYTHONUTF8 = '1';
401
+ });
402
+ // Handle process exit
403
+ childProcess.on('exit', code => {
404
+ log.warn('Worker exited', { workerId: worker.id, code });
405
+ worker.core.handleProcessExit();
406
+ this.handleWorkerExit(worker, code);
407
+ });
408
+ // Handle process errors
409
+ childProcess.on('error', error => {
410
+ log.error('Worker error', { workerId: worker.id, error: String(error) });
411
+ worker.core.handleProcessError(error);
412
+ this.handleWorkerExit(worker, -1);
413
+ });
414
+ }
415
+ /**
416
+ * Handle worker process exit
417
+ */
418
+ handleWorkerExit(worker, _code) {
419
+ if (!this.processPool.includes(worker)) {
420
+ return;
421
+ }
422
+ worker.core.clear();
423
+ // Remove from pool
424
+ const index = this.processPool.indexOf(worker);
425
+ if (index >= 0) {
426
+ this.processPool.splice(index, 1);
427
+ this.stats.processDeaths++;
428
+ }
429
+ // Spawn replacement if needed and not disposing
430
+ if (!this.disposed && this.processPool.length < this.options.minProcesses) {
431
+ this.spawnProcess().catch(error => {
432
+ log.error('Failed to spawn replacement worker', { error: String(error) });
433
+ });
434
+ }
435
+ }
436
+ /**
437
+ * Warm up processes with configured commands
438
+ */
439
+ async warmupProcesses() {
440
+ const warmupPromises = this.processPool.map(async (worker) => {
441
+ for (const cmd of this.options.warmupCommands) {
442
+ try {
443
+ await this.sendToWorker(worker, {
444
+ method: cmd.method,
445
+ params: cmd.params,
446
+ });
447
+ }
448
+ catch (error) {
449
+ log.warn('Warmup command failed', { workerId: worker.id, error: String(error) });
450
+ }
192
451
  }
193
- if (!env.PYTHONIOENCODING) {
194
- env.PYTHONIOENCODING = 'UTF-8';
452
+ });
453
+ await Promise.all(warmupPromises);
454
+ }
455
+ safeCacheKey(prefix, ...inputs) {
456
+ try {
457
+ return globalCache.generateKey(prefix, ...inputs);
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ }
463
+ /**
464
+ * Heuristic to determine if function result should be cached
465
+ */
466
+ isPureFunctionCandidate(functionName, args) {
467
+ // Simple heuristics - could be made more sophisticated
468
+ const pureFunctionPatterns = [
469
+ /^(get|fetch|read|load|find|search|query|select)_/i,
470
+ /^(compute|calculate|process|transform|convert)_/i,
471
+ /^(encode|decode|serialize|deserialize)_/i,
472
+ ];
473
+ const impureFunctionPatterns = [
474
+ /^(set|save|write|update|insert|delete|create|modify)_/i,
475
+ /^(send|post|put|patch)_/i,
476
+ /random|uuid|timestamp|now|current/i,
477
+ ];
478
+ // Don't cache if function name suggests mutation
479
+ if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
480
+ return false;
481
+ }
482
+ // Cache if function name suggests pure computation
483
+ if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
484
+ return true;
485
+ }
486
+ // Don't cache if args contain mutable objects (very basic check)
487
+ const hasComplexArgs = args.some(arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date));
488
+ return !hasComplexArgs && args.length <= 3; // Cache simple calls with few args
489
+ }
490
+ /**
491
+ * Update performance statistics
492
+ */
493
+ updateStats(duration, _error = false) {
494
+ this.stats.totalRequests++;
495
+ this.stats.totalTime += duration;
496
+ const currentMemory = process.memoryUsage().heapUsed;
497
+ if (currentMemory > this.stats.memoryPeak) {
498
+ this.stats.memoryPeak = currentMemory;
499
+ }
500
+ }
501
+ /**
502
+ * Get performance statistics
503
+ */
504
+ getStats() {
505
+ const avgTime = this.stats.totalRequests > 0 ? this.stats.totalTime / this.stats.totalRequests : 0;
506
+ const hitRate = this.stats.totalRequests > 0 ? this.stats.cacheHits / this.stats.totalRequests : 0;
507
+ return {
508
+ ...this.stats,
509
+ averageTime: avgTime,
510
+ cacheHitRate: hitRate,
511
+ poolSize: this.processPool.length,
512
+ busyWorkers: this.processPool.filter(w => w.busy).length,
513
+ memoryUsage: process.memoryUsage(),
514
+ workerStats: this.processPool.map(w => ({
515
+ id: w.id,
516
+ requestCount: w.requestCount,
517
+ averageTime: w.stats.averageTime,
518
+ errorCount: w.stats.errorCount,
519
+ busy: w.busy,
520
+ pendingRequests: w.core.getPendingCount(),
521
+ })),
522
+ };
523
+ }
524
+ /**
525
+ * Cleanup idle processes
526
+ */
527
+ async cleanup() {
528
+ const now = Date.now();
529
+ const idleWorkers = this.processPool.filter(w => !w.busy &&
530
+ now - w.lastUsed > this.options.maxIdleTime &&
531
+ this.processPool.length > this.options.minProcesses);
532
+ for (const worker of idleWorkers) {
533
+ await this.terminateWorker(worker);
534
+ }
535
+ // Restart workers that have handled too many requests
536
+ const overusedWorkers = this.processPool.filter(w => !w.busy && w.requestCount >= this.options.maxRequestsPerProcess);
537
+ for (const worker of overusedWorkers) {
538
+ await this.terminateWorker(worker);
539
+ if (this.processPool.length < this.options.minProcesses) {
540
+ await this.spawnProcess();
195
541
  }
196
- // Respect explicit request for JSON fallback only; otherwise fast-fail by default
197
- if (this.options.enableJsonFallback && !env.TYWRAP_CODEC_FALLBACK) {
198
- env.TYWRAP_CODEC_FALLBACK = 'json';
542
+ }
543
+ }
544
+ /**
545
+ * Gracefully terminate a worker
546
+ */
547
+ async terminateWorker(worker, options = {}) {
548
+ if (worker.busy && !options.force) {
549
+ return;
550
+ }
551
+ const index = this.processPool.indexOf(worker);
552
+ if (index >= 0) {
553
+ this.processPool.splice(index, 1);
554
+ this.stats.processDeaths++;
555
+ }
556
+ worker.core.handleProcessExit();
557
+ worker.core.clear();
558
+ // Graceful termination
559
+ try {
560
+ if (worker.process.exitCode === null) {
561
+ worker.process.kill('SIGTERM');
562
+ // Force kill if not terminated in 5 seconds
563
+ setTimeout(() => {
564
+ if (worker.process.exitCode === null) {
565
+ worker.process.kill('SIGKILL');
566
+ }
567
+ }, 5000);
199
568
  }
200
- let child;
569
+ }
570
+ catch (error) {
571
+ log.warn('Error terminating worker', { workerId: worker.id, error: String(error) });
572
+ }
573
+ }
574
+ /**
575
+ * Start cleanup scheduler
576
+ */
577
+ startCleanupScheduler() {
578
+ this.cleanupTimer = setInterval(async () => {
201
579
  try {
202
- child = spawn(this.options.pythonPath, [this.options.scriptPath], {
203
- cwd: this.options.cwd,
204
- stdio: ['pipe', 'pipe', 'pipe'],
205
- env,
206
- });
580
+ await this.cleanup();
207
581
  }
208
- catch (err) {
209
- throw new BridgeProtocolError(`Failed to start Python process: ${err.message}`);
582
+ catch (error) {
583
+ log.error('Cleanup error', { error: String(error) });
210
584
  }
211
- const startupError = await new Promise(done => {
212
- child.once('error', e => done(e));
213
- child.once('spawn', () => done(null));
214
- });
215
- if (startupError) {
216
- throw new BridgeProtocolError(`Failed to start Python process: ${startupError.message}`);
585
+ }, 60000); // Cleanup every minute
586
+ }
587
+ /**
588
+ * Dispose all resources
589
+ */
590
+ async dispose() {
591
+ if (this.disposed) {
592
+ return;
593
+ }
594
+ this.disposed = true;
595
+ if (this.cleanupTimer) {
596
+ clearInterval(this.cleanupTimer);
597
+ this.cleanupTimer = undefined;
598
+ }
599
+ // Terminate all workers
600
+ const terminationPromises = this.processPool.map(worker => this.terminateWorker(worker, { force: true }));
601
+ await Promise.all(terminationPromises);
602
+ this.processPool.length = 0;
603
+ this.emitter.removeAllListeners();
604
+ }
605
+ buildEnv() {
606
+ const allowedPrefixes = ['TYWRAP_'];
607
+ const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
608
+ const baseEnv = {};
609
+ if (this.options.inheritProcessEnv) {
610
+ for (const [key, value] of Object.entries(process.env)) {
611
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
612
+ baseEnv[key] = value;
217
613
  }
218
- this.child = child;
219
- this.protocolError = false;
220
- this.child?.on('error', err => {
221
- const msg = `Python process error: ${err instanceof Error ? err.message : String(err)}`;
222
- for (const [, p] of this.pending) {
223
- p.reject(new BridgeProtocolError(msg));
224
- }
225
- this.pending.clear();
226
- this.child = undefined;
227
- this.initPromise = undefined;
228
- this.bridgeInfo = undefined;
229
- });
230
- let buffer = '';
231
- this.child.stdout?.on('data', (chunk) => {
232
- buffer += chunk.toString();
233
- let idx;
234
- while ((idx = buffer.indexOf('\n')) !== -1) {
235
- const line = buffer.slice(0, idx);
236
- buffer = buffer.slice(idx + 1);
237
- if (!line.trim()) {
238
- continue;
239
- }
240
- (async () => {
241
- try {
242
- const msg = JSON.parse(line);
243
- if (msg.protocol !== TYWRAP_PROTOCOL) {
244
- this.handleProtocolError(`Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(msg.protocol)}`, line);
245
- return;
246
- }
247
- if (typeof msg.id !== 'number') {
248
- this.handleProtocolError('Invalid response id', line);
249
- return;
250
- }
251
- const pending = this.pending.get(msg.id);
252
- if (!pending) {
253
- this.handleProtocolError(`Unexpected response id ${msg.id}`, line);
254
- return;
255
- }
256
- this.pending.delete(msg.id);
257
- if (pending.timer) {
258
- clearTimeout(pending.timer);
259
- }
260
- if (msg.error) {
261
- pending.reject(this.errorFrom(msg.error));
262
- }
263
- else {
264
- try {
265
- const decoded = await decodeValueAsync(msg.result);
266
- pending.resolve(decoded);
267
- }
268
- catch (err) {
269
- pending.reject(new BridgeProtocolError(`Failed to decode Python response: ${err instanceof Error ? err.message : String(err)}`));
270
- }
271
- }
272
- }
273
- catch (err) {
274
- const parseMessage = err instanceof Error ? err.message : String(err);
275
- this.handleProtocolError(`Invalid JSON: ${parseMessage}`, line);
276
- }
277
- })().catch(() => {
278
- /* ignore */
279
- });
280
- }
281
- });
282
- this.child?.stderr?.on('data', (chunk) => {
283
- // Buffer stderr for better error diagnostics on failures/exits
284
- try {
285
- this.stderrBuffer += chunk.toString();
286
- // Truncate to last 8KB to avoid unbounded growth
287
- const MAX = 8 * 1024;
288
- if (this.stderrBuffer.length > MAX) {
289
- this.stderrBuffer = this.stderrBuffer.slice(this.stderrBuffer.length - MAX);
290
- }
291
- }
292
- catch {
293
- // ignore
294
- }
295
- });
296
- this.child?.on('exit', () => {
297
- for (const [, p] of this.pending) {
298
- const stderrTail = this.stderrBuffer.trim();
299
- const msg = stderrTail
300
- ? `Python process exited. Stderr:\n${stderrTail}`
301
- : 'Python process exited';
302
- p.reject(new BridgeProtocolError(msg));
614
+ }
615
+ else {
616
+ for (const [key, value] of Object.entries(process.env)) {
617
+ if (allowedKeys.has(key.toLowerCase()) ||
618
+ allowedPrefixes.some(prefix => key.startsWith(prefix))) {
619
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
620
+ baseEnv[key] = value;
303
621
  }
304
- this.pending.clear();
305
- this.child = undefined;
306
- this.initPromise = undefined;
307
- this.bridgeInfo = undefined;
308
- });
309
- await this.refreshBridgeInfo();
622
+ }
310
623
  }
311
- catch (err) {
312
- this.child?.kill('SIGTERM');
313
- this.child = undefined;
314
- this.initPromise = undefined;
315
- throw err;
624
+ let env = normalizeEnv(baseEnv, this.options.env);
625
+ if (this.options.virtualEnv) {
626
+ const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
627
+ env.VIRTUAL_ENV = venv.venvPath;
628
+ const pathKey = getPathKey(env);
629
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
630
+ const currentPath = env[pathKey] ?? '';
631
+ // eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
632
+ env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
316
633
  }
634
+ ensurePythonEncoding(env);
635
+ ensureJsonFallback(env, this.options.enableJsonFallback);
636
+ env = normalizeEnv(env, {});
637
+ return env;
317
638
  }
318
639
  async refreshBridgeInfo() {
319
- const info = await this.send({ method: 'meta', params: {} });
320
- if (info.protocol !== TYWRAP_PROTOCOL || info.protocolVersion !== TYWRAP_PROTOCOL_VERSION) {
321
- throw new BridgeProtocolError('Invalid bridge info payload');
322
- }
640
+ const info = await this.executeRequest({ method: 'meta', params: {} });
641
+ validateBridgeInfo(info);
323
642
  this.bridgeInfo = info;
324
643
  }
325
644
  }
645
+ /**
646
+ * @deprecated Use NodeBridge with minProcesses/maxProcesses options instead.
647
+ * This alias is provided for backward compatibility.
648
+ */
649
+ export { NodeBridge as OptimizedNodeBridge };
326
650
  //# sourceMappingURL=node.js.map