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