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/dist/runtime/node.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
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
|
import { existsSync } from 'node:fs';
|
|
5
11
|
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
6
12
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
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,
|
|
12
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
processPool = [];
|
|
59
|
+
roundRobinIndex = 0;
|
|
60
|
+
cleanupTimer;
|
|
31
61
|
options;
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
219
|
+
await this.executeRequest({
|
|
220
|
+
method: 'dispose_instance',
|
|
221
|
+
params: { handle },
|
|
222
|
+
});
|
|
96
223
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
this.
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
reject(new
|
|
123
|
-
},
|
|
124
|
-
const
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
150
|
-
if (
|
|
314
|
+
quarantineWorker(worker, error) {
|
|
315
|
+
if (worker.quarantined) {
|
|
151
316
|
return;
|
|
152
317
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
cwd: this.options.cwd,
|
|
204
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
205
|
-
env,
|
|
206
|
-
});
|
|
580
|
+
await this.cleanup();
|
|
207
581
|
}
|
|
208
|
-
catch (
|
|
209
|
-
|
|
582
|
+
catch (error) {
|
|
583
|
+
log.error('Cleanup error', { error: String(error) });
|
|
210
584
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
305
|
-
this.child = undefined;
|
|
306
|
-
this.initPromise = undefined;
|
|
307
|
-
this.bridgeInfo = undefined;
|
|
308
|
-
});
|
|
309
|
-
await this.refreshBridgeInfo();
|
|
622
|
+
}
|
|
310
623
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
this.
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
320
|
-
|
|
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
|