tywrap 0.1.0 → 0.1.2
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 +54 -221
- 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 +56 -12
- 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 +20 -5
- 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 +5 -6
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +95 -193
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/optimized-node.d.ts +4 -7
- package/dist/runtime/optimized-node.d.ts.map +1 -1
- package/dist/runtime/optimized-node.js +117 -186
- 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 +7 -4
- 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 +81 -5
- package/src/config/index.ts +67 -16
- package/src/core/generator.ts +6 -2
- package/src/core/mapper.ts +21 -8
- package/src/index.ts +2 -1
- package/src/runtime/bridge-core.ts +454 -0
- package/src/runtime/node.ts +117 -224
- package/src/runtime/optimized-node.ts +160 -235
- 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 +306 -83
- package/src/utils/logger.ts +11 -7
- package/src/utils/python.ts +0 -1
|
@@ -5,35 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
8
9
|
import type { ChildProcess } from 'child_process';
|
|
9
10
|
import { EventEmitter } from 'events';
|
|
10
11
|
|
|
11
12
|
import { globalCache } from '../utils/cache.js';
|
|
12
|
-
import { decodeValueAsync } from '../utils/codec.js';
|
|
13
|
+
import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
|
|
13
14
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
14
15
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
15
16
|
|
|
16
17
|
import { RuntimeBridge } from './base.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
18
|
+
import { BridgeProtocolError } from './errors.js';
|
|
19
|
+
import {
|
|
20
|
+
BridgeCore,
|
|
21
|
+
type RpcRequest,
|
|
22
|
+
ensureJsonFallback,
|
|
23
|
+
ensurePythonEncoding,
|
|
24
|
+
getMaxLineLengthFromEnv,
|
|
25
|
+
getPathKey,
|
|
26
|
+
normalizeEnv,
|
|
27
|
+
} from './bridge-core.js';
|
|
19
28
|
import { getComponentLogger } from '../utils/logger.js';
|
|
20
29
|
|
|
21
30
|
const log = getComponentLogger('OptimizedBridge');
|
|
22
31
|
|
|
23
|
-
interface RpcRequest {
|
|
24
|
-
id: number;
|
|
25
|
-
protocol: string;
|
|
26
|
-
method: 'call' | 'instantiate' | 'call_method' | 'dispose_instance';
|
|
27
|
-
params: unknown;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface RpcResponse<T = unknown> {
|
|
31
|
-
id: number;
|
|
32
|
-
protocol: string;
|
|
33
|
-
result?: T;
|
|
34
|
-
error?: { type: string; message: string; traceback?: string };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
32
|
interface ProcessPoolOptions {
|
|
38
33
|
minProcesses?: number;
|
|
39
34
|
maxProcesses?: number;
|
|
@@ -44,27 +39,38 @@ interface ProcessPoolOptions {
|
|
|
44
39
|
virtualEnv?: string | undefined;
|
|
45
40
|
cwd?: string;
|
|
46
41
|
timeoutMs?: number;
|
|
42
|
+
maxLineLength?: number;
|
|
47
43
|
enableJsonFallback?: boolean;
|
|
44
|
+
enableCache?: boolean;
|
|
48
45
|
env?: Record<string, string | undefined>;
|
|
49
46
|
warmupCommands?: Array<{ method: string; params: unknown }>; // Commands to warm up processes
|
|
50
47
|
}
|
|
51
48
|
|
|
49
|
+
interface ResolvedProcessPoolOptions {
|
|
50
|
+
minProcesses: number;
|
|
51
|
+
maxProcesses: number;
|
|
52
|
+
maxIdleTime: number;
|
|
53
|
+
maxRequestsPerProcess: number;
|
|
54
|
+
pythonPath: string;
|
|
55
|
+
scriptPath: string;
|
|
56
|
+
virtualEnv?: string;
|
|
57
|
+
cwd: string;
|
|
58
|
+
timeoutMs: number;
|
|
59
|
+
maxLineLength?: number;
|
|
60
|
+
enableJsonFallback: boolean;
|
|
61
|
+
enableCache: boolean;
|
|
62
|
+
env: Record<string, string | undefined>;
|
|
63
|
+
warmupCommands: Array<{ method: string; params: unknown }>;
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
interface WorkerProcess {
|
|
53
67
|
process: ChildProcess;
|
|
54
68
|
id: string;
|
|
55
69
|
requestCount: number;
|
|
56
70
|
lastUsed: number;
|
|
57
71
|
busy: boolean;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
number,
|
|
61
|
-
{
|
|
62
|
-
resolve: (value: unknown) => void;
|
|
63
|
-
reject: (error: unknown) => void;
|
|
64
|
-
timer?: NodeJS.Timeout;
|
|
65
|
-
startTime: number;
|
|
66
|
-
}
|
|
67
|
-
>;
|
|
72
|
+
quarantined: boolean;
|
|
73
|
+
core: BridgeCore;
|
|
68
74
|
stats: {
|
|
69
75
|
totalRequests: number;
|
|
70
76
|
totalTime: number;
|
|
@@ -125,9 +131,8 @@ function resolveVirtualEnv(
|
|
|
125
131
|
export class OptimizedNodeBridge extends RuntimeBridge {
|
|
126
132
|
private processPool: WorkerProcess[] = [];
|
|
127
133
|
private roundRobinIndex = 0;
|
|
128
|
-
private nextId = 1;
|
|
129
134
|
private cleanupTimer?: NodeJS.Timeout;
|
|
130
|
-
private options:
|
|
135
|
+
private options: ResolvedProcessPoolOptions;
|
|
131
136
|
private emitter = new EventEmitter();
|
|
132
137
|
private disposed = false;
|
|
133
138
|
|
|
@@ -148,7 +153,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
148
153
|
constructor(options: ProcessPoolOptions = {}) {
|
|
149
154
|
super();
|
|
150
155
|
const cwd = options.cwd ?? process.cwd();
|
|
151
|
-
const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) :
|
|
156
|
+
const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
|
|
152
157
|
const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
|
|
153
158
|
const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
|
|
154
159
|
const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
|
|
@@ -162,7 +167,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
162
167
|
virtualEnv,
|
|
163
168
|
cwd,
|
|
164
169
|
timeoutMs: options.timeoutMs ?? 30000,
|
|
170
|
+
maxLineLength: options.maxLineLength,
|
|
165
171
|
enableJsonFallback: options.enableJsonFallback ?? false,
|
|
172
|
+
enableCache: options.enableCache ?? false,
|
|
166
173
|
env: options.env ?? {},
|
|
167
174
|
warmupCommands: options.warmupCommands ?? [],
|
|
168
175
|
};
|
|
@@ -176,6 +183,11 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
176
183
|
throw new Error('Bridge has been disposed');
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
const require = createRequire(import.meta.url);
|
|
187
|
+
await autoRegisterArrowDecoder({
|
|
188
|
+
loader: () => require('apache-arrow'),
|
|
189
|
+
});
|
|
190
|
+
|
|
179
191
|
// Ensure minimum processes are available
|
|
180
192
|
while (this.processPool.length < this.options.minProcesses) {
|
|
181
193
|
await this.spawnProcess();
|
|
@@ -195,13 +207,17 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
195
207
|
): Promise<T> {
|
|
196
208
|
const startTime = performance.now();
|
|
197
209
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
const cacheKey = this.options.enableCache
|
|
211
|
+
? this.safeCacheKey('runtime_call', module, functionName, args, kwargs)
|
|
212
|
+
: null;
|
|
213
|
+
if (cacheKey) {
|
|
214
|
+
const cached = await globalCache.get<T>(cacheKey);
|
|
215
|
+
if (cached !== null) {
|
|
216
|
+
this.stats.cacheHits++;
|
|
217
|
+
this.updateStats(performance.now() - startTime);
|
|
218
|
+
// Runtime cache HIT for ${module}.${functionName}
|
|
219
|
+
return cached;
|
|
220
|
+
}
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
try {
|
|
@@ -213,7 +229,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
213
229
|
const duration = performance.now() - startTime;
|
|
214
230
|
|
|
215
231
|
// Cache result for pure functions (simple heuristic)
|
|
216
|
-
if (this.isPureFunctionCandidate(functionName, args)) {
|
|
232
|
+
if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
|
|
217
233
|
await globalCache.set(cacheKey, result, {
|
|
218
234
|
computeTime: duration,
|
|
219
235
|
dependencies: [module],
|
|
@@ -306,7 +322,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
306
322
|
* Select optimal worker based on load and performance
|
|
307
323
|
*/
|
|
308
324
|
private selectOptimalWorker(): WorkerProcess | null {
|
|
309
|
-
const availableWorkers = this.processPool.filter(
|
|
325
|
+
const availableWorkers = this.processPool.filter(
|
|
326
|
+
w => !w.busy && !w.quarantined && w.process.exitCode === null
|
|
327
|
+
);
|
|
310
328
|
|
|
311
329
|
if (availableWorkers.length === 0) {
|
|
312
330
|
return null;
|
|
@@ -350,58 +368,49 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
350
368
|
worker: WorkerProcess,
|
|
351
369
|
payload: Omit<RpcRequest, 'id' | 'protocol'>
|
|
352
370
|
): Promise<T> {
|
|
353
|
-
const id = this.nextId++;
|
|
354
|
-
const message: RpcRequest = { id, protocol: TYWRAP_PROTOCOL, ...payload } as RpcRequest;
|
|
355
|
-
const text = `${JSON.stringify(message)}\n`;
|
|
356
|
-
|
|
357
371
|
worker.busy = true;
|
|
358
372
|
worker.requestCount++;
|
|
359
373
|
worker.lastUsed = Date.now();
|
|
360
374
|
|
|
361
|
-
|
|
362
|
-
const startTime = performance.now();
|
|
363
|
-
|
|
364
|
-
const timer = setTimeout(() => {
|
|
365
|
-
worker.pendingRequests.delete(id);
|
|
366
|
-
worker.busy = false;
|
|
367
|
-
reject(new Error(`Request ${id} timed out after ${this.options.timeoutMs}ms`));
|
|
368
|
-
}, this.options.timeoutMs);
|
|
369
|
-
|
|
370
|
-
worker.pendingRequests.set(id, {
|
|
371
|
-
resolve: (value: unknown) => {
|
|
372
|
-
const duration = performance.now() - startTime;
|
|
373
|
-
worker.stats.totalTime += duration;
|
|
374
|
-
worker.stats.totalRequests++;
|
|
375
|
-
worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
|
|
376
|
-
worker.busy = false;
|
|
377
|
-
resolvePromise(value as T);
|
|
378
|
-
},
|
|
379
|
-
reject: (error: unknown) => {
|
|
380
|
-
worker.stats.errorCount++;
|
|
381
|
-
worker.busy = false;
|
|
382
|
-
reject(error);
|
|
383
|
-
},
|
|
384
|
-
timer,
|
|
385
|
-
startTime,
|
|
386
|
-
});
|
|
375
|
+
const startTime = performance.now();
|
|
387
376
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
377
|
+
try {
|
|
378
|
+
const result = await worker.core.send<T>(payload);
|
|
379
|
+
const duration = performance.now() - startTime;
|
|
380
|
+
worker.stats.totalTime += duration;
|
|
381
|
+
worker.stats.totalRequests++;
|
|
382
|
+
worker.stats.averageTime = worker.stats.totalTime / worker.stats.totalRequests;
|
|
383
|
+
return result;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
worker.stats.errorCount++;
|
|
386
|
+
throw error;
|
|
387
|
+
} finally {
|
|
388
|
+
worker.busy = false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
395
391
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
392
|
+
private quarantineWorker(worker: WorkerProcess, error: Error): void {
|
|
393
|
+
if (worker.quarantined) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
worker.quarantined = true;
|
|
397
|
+
log.warn('Quarantining worker', { workerId: worker.id, error: String(error) });
|
|
398
|
+
this.terminateWorker(worker, { force: true })
|
|
399
|
+
.then(() => {
|
|
400
|
+
if (!this.disposed && this.processPool.length < this.options.minProcesses) {
|
|
401
|
+
this.spawnProcess().catch(spawnError => {
|
|
402
|
+
log.error('Failed to spawn replacement worker after quarantine', {
|
|
403
|
+
error: String(spawnError),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
.catch(terminateError => {
|
|
409
|
+
log.warn('Failed to terminate quarantined worker', {
|
|
410
|
+
workerId: worker.id,
|
|
411
|
+
error: String(terminateError),
|
|
412
|
+
});
|
|
413
|
+
});
|
|
405
414
|
}
|
|
406
415
|
|
|
407
416
|
/**
|
|
@@ -412,27 +421,24 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
412
421
|
|
|
413
422
|
const workerId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
414
423
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
PYTHONDONTWRITEBYTECODE: '1', // Skip .pyc files for faster startup
|
|
420
|
-
};
|
|
421
|
-
if (!env.PYTHONUTF8) {
|
|
422
|
-
env.PYTHONUTF8 = '1';
|
|
423
|
-
}
|
|
424
|
-
if (!env.PYTHONIOENCODING) {
|
|
425
|
-
env.PYTHONIOENCODING = 'UTF-8';
|
|
426
|
-
}
|
|
424
|
+
let env = normalizeEnv(process.env as Record<string, string | undefined>, this.options.env);
|
|
425
|
+
env.PYTHONUNBUFFERED = '1'; // Ensure immediate output
|
|
426
|
+
env.PYTHONDONTWRITEBYTECODE = '1'; // Skip .pyc files for faster startup
|
|
427
|
+
ensurePythonEncoding(env);
|
|
427
428
|
if (this.options.virtualEnv) {
|
|
428
429
|
const venv = resolveVirtualEnv(this.options.virtualEnv, this.options.cwd);
|
|
429
430
|
env.VIRTUAL_ENV = venv.venvPath;
|
|
430
|
-
|
|
431
|
+
const pathKey = getPathKey(env);
|
|
432
|
+
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
433
|
+
const currentPath = env[pathKey] ?? '';
|
|
434
|
+
// eslint-disable-next-line security/detect-object-injection -- env keys are dynamic by design
|
|
435
|
+
env[pathKey] = `${venv.binDir}${delimiter}${currentPath}`;
|
|
431
436
|
}
|
|
432
437
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
438
|
+
ensureJsonFallback(env, this.options.enableJsonFallback);
|
|
439
|
+
|
|
440
|
+
env = normalizeEnv(env, {});
|
|
441
|
+
const maxLineLength = this.options.maxLineLength ?? getMaxLineLengthFromEnv(env);
|
|
436
442
|
|
|
437
443
|
const childProcess = spawn(this.options.pythonPath, [this.options.scriptPath], {
|
|
438
444
|
cwd: this.options.cwd,
|
|
@@ -446,8 +452,8 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
446
452
|
requestCount: 0,
|
|
447
453
|
lastUsed: Date.now(),
|
|
448
454
|
busy: false,
|
|
449
|
-
|
|
450
|
-
|
|
455
|
+
quarantined: false,
|
|
456
|
+
core: null as unknown as BridgeCore,
|
|
451
457
|
stats: {
|
|
452
458
|
totalRequests: 0,
|
|
453
459
|
totalTime: 0,
|
|
@@ -456,6 +462,24 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
456
462
|
},
|
|
457
463
|
};
|
|
458
464
|
|
|
465
|
+
worker.core = new BridgeCore(
|
|
466
|
+
{
|
|
467
|
+
write: (data: string): void => {
|
|
468
|
+
if (!worker.process.stdin?.writable) {
|
|
469
|
+
throw new BridgeProtocolError('Worker process stdin not writable');
|
|
470
|
+
}
|
|
471
|
+
worker.process.stdin.write(data);
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
timeoutMs: this.options.timeoutMs,
|
|
476
|
+
maxLineLength,
|
|
477
|
+
decodeValue: decodeValueAsync,
|
|
478
|
+
onFatalError: (error: Error): void => this.quarantineWorker(worker, error),
|
|
479
|
+
onTimeout: (error: Error): void => this.quarantineWorker(worker, error),
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
|
|
459
483
|
// Setup process event handlers
|
|
460
484
|
this.setupProcessHandlers(worker);
|
|
461
485
|
|
|
@@ -473,27 +497,12 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
473
497
|
private setupProcessHandlers(worker: WorkerProcess): void {
|
|
474
498
|
const childProcess = worker.process;
|
|
475
499
|
|
|
476
|
-
// Handle stdout data with efficient buffering
|
|
477
500
|
childProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
478
|
-
worker.
|
|
479
|
-
|
|
480
|
-
let idx: number;
|
|
481
|
-
while ((idx = worker.buffer.indexOf('\n')) !== -1) {
|
|
482
|
-
const line = worker.buffer.slice(0, idx).trim();
|
|
483
|
-
worker.buffer = worker.buffer.slice(idx + 1);
|
|
484
|
-
|
|
485
|
-
if (!line) {
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
this.handleWorkerResponse(worker, line).catch(error => {
|
|
490
|
-
log.error('Error handling worker response', { workerId: worker.id, error: String(error) });
|
|
491
|
-
});
|
|
492
|
-
}
|
|
501
|
+
worker.core.handleStdoutData(chunk);
|
|
493
502
|
});
|
|
494
503
|
|
|
495
|
-
// Handle stderr for debugging
|
|
496
504
|
childProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
505
|
+
worker.core.handleStderrData(chunk);
|
|
497
506
|
const errorText = chunk.toString().trim();
|
|
498
507
|
if (errorText) {
|
|
499
508
|
log.warn('Worker stderr', { workerId: worker.id, output: errorText });
|
|
@@ -503,81 +512,27 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
503
512
|
// Handle process exit
|
|
504
513
|
childProcess.on('exit', code => {
|
|
505
514
|
log.warn('Worker exited', { workerId: worker.id, code });
|
|
515
|
+
worker.core.handleProcessExit();
|
|
506
516
|
this.handleWorkerExit(worker, code);
|
|
507
517
|
});
|
|
508
518
|
|
|
509
519
|
// Handle process errors
|
|
510
520
|
childProcess.on('error', error => {
|
|
511
521
|
log.error('Worker error', { workerId: worker.id, error: String(error) });
|
|
522
|
+
worker.core.handleProcessError(error);
|
|
512
523
|
this.handleWorkerExit(worker, -1);
|
|
513
524
|
});
|
|
514
525
|
}
|
|
515
526
|
|
|
516
|
-
/**
|
|
517
|
-
* Handle response from worker process
|
|
518
|
-
*/
|
|
519
|
-
private async handleWorkerResponse(worker: WorkerProcess, line: string): Promise<void> {
|
|
520
|
-
try {
|
|
521
|
-
const msg = JSON.parse(line) as RpcResponse;
|
|
522
|
-
if (msg.protocol !== TYWRAP_PROTOCOL) {
|
|
523
|
-
this.handleProtocolError(
|
|
524
|
-
worker,
|
|
525
|
-
line,
|
|
526
|
-
new BridgeProtocolError(
|
|
527
|
-
`Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(msg.protocol)}`
|
|
528
|
-
)
|
|
529
|
-
);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
if (typeof msg.id !== 'number') {
|
|
533
|
-
this.handleProtocolError(worker, line, new BridgeProtocolError('Invalid response id'));
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
const pending = worker.pendingRequests.get(msg.id);
|
|
537
|
-
|
|
538
|
-
if (!pending) {
|
|
539
|
-
this.handleProtocolError(
|
|
540
|
-
worker,
|
|
541
|
-
line,
|
|
542
|
-
new BridgeProtocolError(`Unexpected response id ${msg.id}`)
|
|
543
|
-
);
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
worker.pendingRequests.delete(msg.id);
|
|
548
|
-
|
|
549
|
-
if (pending.timer) {
|
|
550
|
-
clearTimeout(pending.timer);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
if (msg.error) {
|
|
554
|
-
pending.reject(this.errorFrom(msg.error));
|
|
555
|
-
} else {
|
|
556
|
-
try {
|
|
557
|
-
const decoded = await decodeValueAsync(msg.result);
|
|
558
|
-
pending.resolve(decoded);
|
|
559
|
-
} catch (decodeError) {
|
|
560
|
-
pending.reject(new BridgeProtocolError(`Failed to decode response: ${decodeError}`));
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
} catch (parseError) {
|
|
564
|
-
this.handleProtocolError(worker, line, parseError);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
527
|
/**
|
|
569
528
|
* Handle worker process exit
|
|
570
529
|
*/
|
|
571
|
-
private handleWorkerExit(worker: WorkerProcess,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (pending.timer) {
|
|
575
|
-
clearTimeout(pending.timer);
|
|
576
|
-
}
|
|
577
|
-
pending.reject(new Error(`Worker process exited with code ${code}`));
|
|
530
|
+
private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
|
|
531
|
+
if (!this.processPool.includes(worker)) {
|
|
532
|
+
return;
|
|
578
533
|
}
|
|
579
534
|
|
|
580
|
-
worker.
|
|
535
|
+
worker.core.clear();
|
|
581
536
|
|
|
582
537
|
// Remove from pool
|
|
583
538
|
const index = this.processPool.indexOf(worker);
|
|
@@ -615,6 +570,14 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
615
570
|
// Warmed up ${this.processPool.length} worker processes
|
|
616
571
|
}
|
|
617
572
|
|
|
573
|
+
private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
|
|
574
|
+
try {
|
|
575
|
+
return globalCache.generateKey(prefix, ...inputs);
|
|
576
|
+
} catch {
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
618
581
|
/**
|
|
619
582
|
* Heuristic to determine if function result should be cached
|
|
620
583
|
*/
|
|
@@ -685,7 +648,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
685
648
|
averageTime: w.stats.averageTime,
|
|
686
649
|
errorCount: w.stats.errorCount,
|
|
687
650
|
busy: w.busy,
|
|
688
|
-
pendingRequests: w.
|
|
651
|
+
pendingRequests: w.core.getPendingCount(),
|
|
689
652
|
})),
|
|
690
653
|
};
|
|
691
654
|
}
|
|
@@ -722,28 +685,31 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
722
685
|
/**
|
|
723
686
|
* Gracefully terminate a worker
|
|
724
687
|
*/
|
|
725
|
-
private async terminateWorker(
|
|
688
|
+
private async terminateWorker(
|
|
689
|
+
worker: WorkerProcess,
|
|
690
|
+
options: { force?: boolean } = {}
|
|
691
|
+
): Promise<void> {
|
|
692
|
+
if (worker.busy && !options.force) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
726
696
|
const index = this.processPool.indexOf(worker);
|
|
727
697
|
if (index >= 0) {
|
|
728
698
|
this.processPool.splice(index, 1);
|
|
699
|
+
this.stats.processDeaths++;
|
|
729
700
|
}
|
|
730
701
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (pending.timer) {
|
|
734
|
-
clearTimeout(pending.timer);
|
|
735
|
-
}
|
|
736
|
-
pending.reject(new Error('Worker process terminated'));
|
|
737
|
-
}
|
|
702
|
+
worker.core.handleProcessExit();
|
|
703
|
+
worker.core.clear();
|
|
738
704
|
|
|
739
705
|
// Graceful termination
|
|
740
706
|
try {
|
|
741
|
-
if (worker.process.
|
|
707
|
+
if (worker.process.exitCode === null) {
|
|
742
708
|
worker.process.kill('SIGTERM');
|
|
743
709
|
|
|
744
710
|
// Force kill if not terminated in 5 seconds
|
|
745
711
|
setTimeout(() => {
|
|
746
|
-
if (
|
|
712
|
+
if (worker.process.exitCode === null) {
|
|
747
713
|
worker.process.kill('SIGKILL');
|
|
748
714
|
}
|
|
749
715
|
}, 5000);
|
|
@@ -784,7 +750,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
784
750
|
}
|
|
785
751
|
|
|
786
752
|
// Terminate all workers
|
|
787
|
-
const terminationPromises = this.processPool.map(worker =>
|
|
753
|
+
const terminationPromises = this.processPool.map(worker =>
|
|
754
|
+
this.terminateWorker(worker, { force: true })
|
|
755
|
+
);
|
|
788
756
|
await Promise.all(terminationPromises);
|
|
789
757
|
|
|
790
758
|
this.processPool.length = 0;
|
|
@@ -792,47 +760,4 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
792
760
|
|
|
793
761
|
// Disposed optimized Node.js bridge
|
|
794
762
|
}
|
|
795
|
-
|
|
796
|
-
private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
|
|
797
|
-
const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
|
|
798
|
-
e.traceback = err.traceback;
|
|
799
|
-
return e;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
private handleProtocolError(worker: WorkerProcess, line: string, error: unknown): void {
|
|
803
|
-
const snippet = line.length > 500 ? `${line.slice(0, 500)}…` : line;
|
|
804
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
805
|
-
const hint =
|
|
806
|
-
'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
|
|
807
|
-
const msg = `Protocol error from Python bridge. ${detail}\n${hint}\nOffending line: ${snippet}`;
|
|
808
|
-
const bridgeError = new BridgeProtocolError(msg);
|
|
809
|
-
|
|
810
|
-
for (const [, pending] of worker.pendingRequests) {
|
|
811
|
-
if (pending.timer) {
|
|
812
|
-
clearTimeout(pending.timer);
|
|
813
|
-
}
|
|
814
|
-
pending.reject(bridgeError);
|
|
815
|
-
}
|
|
816
|
-
worker.pendingRequests.clear();
|
|
817
|
-
|
|
818
|
-
try {
|
|
819
|
-
if (worker.process.connected) {
|
|
820
|
-
worker.process.kill('SIGTERM');
|
|
821
|
-
}
|
|
822
|
-
} catch (killError) {
|
|
823
|
-
log.warn('Error terminating worker after protocol error', { workerId: worker.id, error: String(killError) });
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const index = this.processPool.indexOf(worker);
|
|
827
|
-
if (index >= 0) {
|
|
828
|
-
this.processPool.splice(index, 1);
|
|
829
|
-
this.stats.processDeaths++;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
if (!this.disposed && this.processPool.length < this.options.minProcesses) {
|
|
833
|
-
this.spawnProcess().catch(spawnError => {
|
|
834
|
-
log.error('Failed to spawn replacement worker after protocol error', { error: String(spawnError) });
|
|
835
|
-
});
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
763
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface TimedOutRequestTrackerOptions {
|
|
2
|
+
ttlMs: number;
|
|
3
|
+
maxSize?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Track request IDs that timed out on the JS side so late Python responses can be
|
|
8
|
+
* safely ignored instead of treated as protocol errors.
|
|
9
|
+
*/
|
|
10
|
+
export class TimedOutRequestTracker {
|
|
11
|
+
private readonly ttlMs: number;
|
|
12
|
+
private readonly maxSize: number;
|
|
13
|
+
private readonly items = new Map<number, number>();
|
|
14
|
+
|
|
15
|
+
constructor(options: TimedOutRequestTrackerOptions) {
|
|
16
|
+
this.ttlMs = options.ttlMs;
|
|
17
|
+
this.maxSize = options.maxSize ?? 1000;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
clear(): void {
|
|
21
|
+
this.items.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
mark(id: number): void {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
this.pruneOld(now);
|
|
27
|
+
this.items.set(id, now);
|
|
28
|
+
this.pruneMax();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
consume(id: number): boolean {
|
|
32
|
+
if (!this.items.has(id)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
this.items.delete(id);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private pruneOld(now: number): void {
|
|
40
|
+
const cutoff = now - this.ttlMs;
|
|
41
|
+
for (const [key, ts] of this.items) {
|
|
42
|
+
if (ts >= cutoff) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
this.items.delete(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private pruneMax(): void {
|
|
50
|
+
while (this.items.size > this.maxSize) {
|
|
51
|
+
const oldest = this.items.keys().next();
|
|
52
|
+
if (oldest.done) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
this.items.delete(oldest.value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/types/index.ts
CHANGED