tywrap 0.1.1 → 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 +23 -3
- 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 +5 -6
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +94 -190
- 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 -194
- 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 +116 -220
- package/src/runtime/optimized-node.ts +160 -243
- 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
|
@@ -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,30 +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', {
|
|
491
|
-
workerId: worker.id,
|
|
492
|
-
error: String(error),
|
|
493
|
-
});
|
|
494
|
-
});
|
|
495
|
-
}
|
|
501
|
+
worker.core.handleStdoutData(chunk);
|
|
496
502
|
});
|
|
497
503
|
|
|
498
|
-
// Handle stderr for debugging
|
|
499
504
|
childProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
505
|
+
worker.core.handleStderrData(chunk);
|
|
500
506
|
const errorText = chunk.toString().trim();
|
|
501
507
|
if (errorText) {
|
|
502
508
|
log.warn('Worker stderr', { workerId: worker.id, output: errorText });
|
|
@@ -506,81 +512,27 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
506
512
|
// Handle process exit
|
|
507
513
|
childProcess.on('exit', code => {
|
|
508
514
|
log.warn('Worker exited', { workerId: worker.id, code });
|
|
515
|
+
worker.core.handleProcessExit();
|
|
509
516
|
this.handleWorkerExit(worker, code);
|
|
510
517
|
});
|
|
511
518
|
|
|
512
519
|
// Handle process errors
|
|
513
520
|
childProcess.on('error', error => {
|
|
514
521
|
log.error('Worker error', { workerId: worker.id, error: String(error) });
|
|
522
|
+
worker.core.handleProcessError(error);
|
|
515
523
|
this.handleWorkerExit(worker, -1);
|
|
516
524
|
});
|
|
517
525
|
}
|
|
518
526
|
|
|
519
|
-
/**
|
|
520
|
-
* Handle response from worker process
|
|
521
|
-
*/
|
|
522
|
-
private async handleWorkerResponse(worker: WorkerProcess, line: string): Promise<void> {
|
|
523
|
-
try {
|
|
524
|
-
const msg = JSON.parse(line) as RpcResponse;
|
|
525
|
-
if (msg.protocol !== TYWRAP_PROTOCOL) {
|
|
526
|
-
this.handleProtocolError(
|
|
527
|
-
worker,
|
|
528
|
-
line,
|
|
529
|
-
new BridgeProtocolError(
|
|
530
|
-
`Invalid protocol. Expected ${TYWRAP_PROTOCOL} but received ${String(msg.protocol)}`
|
|
531
|
-
)
|
|
532
|
-
);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (typeof msg.id !== 'number') {
|
|
536
|
-
this.handleProtocolError(worker, line, new BridgeProtocolError('Invalid response id'));
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
const pending = worker.pendingRequests.get(msg.id);
|
|
540
|
-
|
|
541
|
-
if (!pending) {
|
|
542
|
-
this.handleProtocolError(
|
|
543
|
-
worker,
|
|
544
|
-
line,
|
|
545
|
-
new BridgeProtocolError(`Unexpected response id ${msg.id}`)
|
|
546
|
-
);
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
worker.pendingRequests.delete(msg.id);
|
|
551
|
-
|
|
552
|
-
if (pending.timer) {
|
|
553
|
-
clearTimeout(pending.timer);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (msg.error) {
|
|
557
|
-
pending.reject(this.errorFrom(msg.error));
|
|
558
|
-
} else {
|
|
559
|
-
try {
|
|
560
|
-
const decoded = await decodeValueAsync(msg.result);
|
|
561
|
-
pending.resolve(decoded);
|
|
562
|
-
} catch (decodeError) {
|
|
563
|
-
pending.reject(new BridgeProtocolError(`Failed to decode response: ${decodeError}`));
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
} catch (parseError) {
|
|
567
|
-
this.handleProtocolError(worker, line, parseError);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
527
|
/**
|
|
572
528
|
* Handle worker process exit
|
|
573
529
|
*/
|
|
574
|
-
private handleWorkerExit(worker: WorkerProcess,
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (pending.timer) {
|
|
578
|
-
clearTimeout(pending.timer);
|
|
579
|
-
}
|
|
580
|
-
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;
|
|
581
533
|
}
|
|
582
534
|
|
|
583
|
-
worker.
|
|
535
|
+
worker.core.clear();
|
|
584
536
|
|
|
585
537
|
// Remove from pool
|
|
586
538
|
const index = this.processPool.indexOf(worker);
|
|
@@ -618,6 +570,14 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
618
570
|
// Warmed up ${this.processPool.length} worker processes
|
|
619
571
|
}
|
|
620
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
|
+
|
|
621
581
|
/**
|
|
622
582
|
* Heuristic to determine if function result should be cached
|
|
623
583
|
*/
|
|
@@ -688,7 +648,7 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
688
648
|
averageTime: w.stats.averageTime,
|
|
689
649
|
errorCount: w.stats.errorCount,
|
|
690
650
|
busy: w.busy,
|
|
691
|
-
pendingRequests: w.
|
|
651
|
+
pendingRequests: w.core.getPendingCount(),
|
|
692
652
|
})),
|
|
693
653
|
};
|
|
694
654
|
}
|
|
@@ -725,28 +685,31 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
725
685
|
/**
|
|
726
686
|
* Gracefully terminate a worker
|
|
727
687
|
*/
|
|
728
|
-
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
|
+
|
|
729
696
|
const index = this.processPool.indexOf(worker);
|
|
730
697
|
if (index >= 0) {
|
|
731
698
|
this.processPool.splice(index, 1);
|
|
699
|
+
this.stats.processDeaths++;
|
|
732
700
|
}
|
|
733
701
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if (pending.timer) {
|
|
737
|
-
clearTimeout(pending.timer);
|
|
738
|
-
}
|
|
739
|
-
pending.reject(new Error('Worker process terminated'));
|
|
740
|
-
}
|
|
702
|
+
worker.core.handleProcessExit();
|
|
703
|
+
worker.core.clear();
|
|
741
704
|
|
|
742
705
|
// Graceful termination
|
|
743
706
|
try {
|
|
744
|
-
if (worker.process.
|
|
707
|
+
if (worker.process.exitCode === null) {
|
|
745
708
|
worker.process.kill('SIGTERM');
|
|
746
709
|
|
|
747
710
|
// Force kill if not terminated in 5 seconds
|
|
748
711
|
setTimeout(() => {
|
|
749
|
-
if (
|
|
712
|
+
if (worker.process.exitCode === null) {
|
|
750
713
|
worker.process.kill('SIGKILL');
|
|
751
714
|
}
|
|
752
715
|
}, 5000);
|
|
@@ -787,7 +750,9 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
787
750
|
}
|
|
788
751
|
|
|
789
752
|
// Terminate all workers
|
|
790
|
-
const terminationPromises = this.processPool.map(worker =>
|
|
753
|
+
const terminationPromises = this.processPool.map(worker =>
|
|
754
|
+
this.terminateWorker(worker, { force: true })
|
|
755
|
+
);
|
|
791
756
|
await Promise.all(terminationPromises);
|
|
792
757
|
|
|
793
758
|
this.processPool.length = 0;
|
|
@@ -795,52 +760,4 @@ export class OptimizedNodeBridge extends RuntimeBridge {
|
|
|
795
760
|
|
|
796
761
|
// Disposed optimized Node.js bridge
|
|
797
762
|
}
|
|
798
|
-
|
|
799
|
-
private errorFrom(err: { type: string; message: string; traceback?: string }): Error {
|
|
800
|
-
const e = new BridgeExecutionError(`${err.type}: ${err.message}`);
|
|
801
|
-
e.traceback = err.traceback;
|
|
802
|
-
return e;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
private handleProtocolError(worker: WorkerProcess, line: string, error: unknown): void {
|
|
806
|
-
const snippet = line.length > 500 ? `${line.slice(0, 500)}…` : line;
|
|
807
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
808
|
-
const hint =
|
|
809
|
-
'Ensure your Python code does not print to stdout and that the bridge outputs only JSON lines.';
|
|
810
|
-
const msg = `Protocol error from Python bridge. ${detail}\n${hint}\nOffending line: ${snippet}`;
|
|
811
|
-
const bridgeError = new BridgeProtocolError(msg);
|
|
812
|
-
|
|
813
|
-
for (const [, pending] of worker.pendingRequests) {
|
|
814
|
-
if (pending.timer) {
|
|
815
|
-
clearTimeout(pending.timer);
|
|
816
|
-
}
|
|
817
|
-
pending.reject(bridgeError);
|
|
818
|
-
}
|
|
819
|
-
worker.pendingRequests.clear();
|
|
820
|
-
|
|
821
|
-
try {
|
|
822
|
-
if (worker.process.connected) {
|
|
823
|
-
worker.process.kill('SIGTERM');
|
|
824
|
-
}
|
|
825
|
-
} catch (killError) {
|
|
826
|
-
log.warn('Error terminating worker after protocol error', {
|
|
827
|
-
workerId: worker.id,
|
|
828
|
-
error: String(killError),
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const index = this.processPool.indexOf(worker);
|
|
833
|
-
if (index >= 0) {
|
|
834
|
-
this.processPool.splice(index, 1);
|
|
835
|
-
this.stats.processDeaths++;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
if (!this.disposed && this.processPool.length < this.options.minProcesses) {
|
|
839
|
-
this.spawnProcess().catch(spawnError => {
|
|
840
|
-
log.error('Failed to spawn replacement worker after protocol error', {
|
|
841
|
-
error: String(spawnError),
|
|
842
|
-
});
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
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