tywrap 0.2.0 → 0.3.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 +65 -31
- package/dist/cli.js +49 -2
- package/dist/cli.js.map +1 -1
- package/dist/config/index.d.ts +11 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +43 -7
- package/dist/config/index.js.map +1 -1
- package/dist/core/analyzer.d.ts +1 -0
- package/dist/core/analyzer.d.ts.map +1 -1
- package/dist/core/analyzer.js +47 -12
- package/dist/core/analyzer.js.map +1 -1
- package/dist/core/annotation-parser.d.ts +8 -0
- package/dist/core/annotation-parser.d.ts.map +1 -0
- package/dist/core/annotation-parser.js +409 -0
- package/dist/core/annotation-parser.js.map +1 -0
- package/dist/core/discovery.d.ts +1 -0
- package/dist/core/discovery.d.ts.map +1 -1
- package/dist/core/discovery.js +9 -9
- package/dist/core/discovery.js.map +1 -1
- package/dist/core/generator.d.ts +9 -1
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +738 -104
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts +9 -7
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +129 -44
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime/base.d.ts +17 -7
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +18 -1
- package/dist/runtime/base.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +252 -0
- package/dist/runtime/bounded-context.d.ts.map +1 -0
- package/dist/runtime/bounded-context.js +454 -0
- package/dist/runtime/bounded-context.js.map +1 -0
- package/dist/runtime/bridge-core.d.ts +1 -0
- package/dist/runtime/bridge-core.d.ts.map +1 -1
- package/dist/runtime/bridge-core.js +52 -17
- package/dist/runtime/bridge-core.js.map +1 -1
- package/dist/runtime/bridge-protocol.d.ts +184 -0
- package/dist/runtime/bridge-protocol.d.ts.map +1 -0
- package/dist/runtime/bridge-protocol.js +344 -0
- package/dist/runtime/bridge-protocol.js.map +1 -0
- package/dist/runtime/disposable.d.ts +40 -0
- package/dist/runtime/disposable.d.ts.map +1 -0
- package/dist/runtime/disposable.js +49 -0
- package/dist/runtime/disposable.js.map +1 -0
- package/dist/runtime/errors.d.ts +10 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +9 -0
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/http-io.d.ts +91 -0
- package/dist/runtime/http-io.d.ts.map +1 -0
- package/dist/runtime/http-io.js +195 -0
- package/dist/runtime/http-io.js.map +1 -0
- package/dist/runtime/http.d.ts +48 -13
- package/dist/runtime/http.d.ts.map +1 -1
- package/dist/runtime/http.js +60 -73
- package/dist/runtime/http.js.map +1 -1
- package/dist/runtime/node.d.ts +97 -130
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +392 -521
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/optimized-node.d.ts +1 -1
- package/dist/runtime/optimized-node.d.ts.map +1 -1
- package/dist/runtime/optimized-node.js +1 -1
- package/dist/runtime/optimized-node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +135 -0
- package/dist/runtime/pooled-transport.d.ts.map +1 -0
- package/dist/runtime/pooled-transport.js +177 -0
- package/dist/runtime/pooled-transport.js.map +1 -0
- package/dist/runtime/process-io.d.ts +209 -0
- package/dist/runtime/process-io.d.ts.map +1 -0
- package/dist/runtime/process-io.js +729 -0
- package/dist/runtime/process-io.js.map +1 -0
- package/dist/runtime/pyodide-io.d.ts +154 -0
- package/dist/runtime/pyodide-io.d.ts.map +1 -0
- package/dist/runtime/pyodide-io.js +459 -0
- package/dist/runtime/pyodide-io.js.map +1 -0
- package/dist/runtime/pyodide.d.ts +51 -19
- package/dist/runtime/pyodide.d.ts.map +1 -1
- package/dist/runtime/pyodide.js +57 -186
- package/dist/runtime/pyodide.js.map +1 -1
- package/dist/runtime/safe-codec.d.ts +103 -0
- package/dist/runtime/safe-codec.d.ts.map +1 -0
- package/dist/runtime/safe-codec.js +519 -0
- package/dist/runtime/safe-codec.js.map +1 -0
- package/dist/runtime/transport.d.ts +191 -0
- package/dist/runtime/transport.d.ts.map +1 -0
- package/dist/runtime/transport.js +86 -0
- package/dist/runtime/transport.js.map +1 -0
- package/dist/runtime/validators.d.ts +120 -0
- package/dist/runtime/validators.d.ts.map +1 -0
- package/dist/runtime/validators.js +229 -0
- package/dist/runtime/validators.js.map +1 -0
- package/dist/runtime/worker-pool.d.ts +208 -0
- package/dist/runtime/worker-pool.d.ts.map +1 -0
- package/dist/runtime/worker-pool.js +419 -0
- package/dist/runtime/worker-pool.js.map +1 -0
- package/dist/types/index.d.ts +51 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +227 -328
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/cache.d.ts.map +1 -1
- package/dist/utils/cache.js +53 -5
- package/dist/utils/cache.js.map +1 -1
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +114 -1
- package/dist/utils/codec.js.map +1 -1
- package/dist/utils/ir-cache.d.ts +28 -0
- package/dist/utils/ir-cache.d.ts.map +1 -0
- package/dist/utils/ir-cache.js +29 -0
- package/dist/utils/ir-cache.js.map +1 -0
- package/dist/utils/parallel-processor.d.ts +1 -1
- package/dist/utils/parallel-processor.d.ts.map +1 -1
- package/dist/utils/parallel-processor.js +111 -40
- package/dist/utils/parallel-processor.js.map +1 -1
- package/dist/utils/runtime.d.ts +5 -1
- package/dist/utils/runtime.d.ts.map +1 -1
- package/dist/utils/runtime.js +112 -18
- package/dist/utils/runtime.js.map +1 -1
- package/package.json +40 -18
- package/runtime/python_bridge.py +108 -15
- package/runtime/safe_codec.py +344 -0
- package/src/cli.ts +61 -2
- package/src/config/index.ts +63 -17
- package/src/core/analyzer.ts +52 -15
- package/src/core/annotation-parser.ts +500 -0
- package/src/core/discovery.ts +26 -12
- package/src/core/generator.ts +956 -116
- package/src/core/mapper.ts +158 -51
- package/src/index.ts +78 -6
- package/src/runtime/base.ts +18 -26
- package/src/runtime/bounded-context.ts +608 -0
- package/src/runtime/bridge-core.ts +58 -18
- package/src/runtime/bridge-protocol.ts +483 -0
- package/src/runtime/disposable.ts +65 -0
- package/src/runtime/errors.ts +14 -0
- package/src/runtime/http-io.ts +245 -0
- package/src/runtime/http.ts +76 -115
- package/src/runtime/node.ts +563 -676
- package/src/runtime/optimized-node.ts +4 -1
- package/src/runtime/pooled-transport.ts +263 -0
- package/src/runtime/process-io.ts +930 -0
- package/src/runtime/pyodide-io.ts +551 -0
- package/src/runtime/pyodide.ts +75 -215
- package/src/runtime/safe-codec.ts +651 -0
- package/src/runtime/transport.ts +278 -0
- package/src/runtime/validators.ts +252 -0
- package/src/runtime/worker-pool.ts +559 -0
- package/src/types/global.d.ts +11 -1
- package/src/types/index.ts +63 -0
- package/src/tywrap.ts +279 -358
- package/src/utils/cache.ts +59 -5
- package/src/utils/codec.ts +122 -1
- package/src/utils/ir-cache.ts +51 -0
- package/src/utils/parallel-processor.ts +119 -43
- package/src/utils/runtime.ts +129 -18
package/src/runtime/node.ts
CHANGED
|
@@ -1,145 +1,143 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js
|
|
2
|
+
* Node.js runtime bridge for BridgeProtocol.
|
|
3
3
|
*
|
|
4
|
-
* NodeBridge
|
|
5
|
-
*
|
|
6
|
-
* in single-process mode for maximum compatibility with the original NodeBridge behavior.
|
|
4
|
+
* NodeBridge extends BridgeProtocol and uses ProcessIO transports with
|
|
5
|
+
* optional pooling for concurrent Python execution.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* @see https://github.com/bbopen/tywrap/issues/149
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { existsSync } from 'node:fs';
|
|
12
11
|
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
13
12
|
import { fileURLToPath } from 'node:url';
|
|
14
13
|
import { createRequire } from 'node:module';
|
|
15
|
-
import type { ChildProcess } from 'child_process';
|
|
16
|
-
import { EventEmitter } from 'events';
|
|
17
14
|
|
|
18
|
-
import {
|
|
19
|
-
import { autoRegisterArrowDecoder, decodeValueAsync } from '../utils/codec.js';
|
|
15
|
+
import { autoRegisterArrowDecoder } from '../utils/codec.js';
|
|
20
16
|
import { getDefaultPythonPath } from '../utils/python.js';
|
|
21
17
|
import { getVenvBinDir, getVenvPythonExe } from '../utils/runtime.js';
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} from './bridge-core.js';
|
|
36
|
-
import { getComponentLogger } from '../utils/logger.js';
|
|
37
|
-
|
|
38
|
-
const log = getComponentLogger('NodeBridge');
|
|
18
|
+
import { globalCache } from '../utils/cache.js';
|
|
19
|
+
|
|
20
|
+
import { BridgeProtocol, type BridgeProtocolOptions } from './bridge-protocol.js';
|
|
21
|
+
import { BridgeExecutionError, BridgeProtocolError } from './errors.js';
|
|
22
|
+
import { ProcessIO } from './process-io.js';
|
|
23
|
+
import { PooledTransport } from './pooled-transport.js';
|
|
24
|
+
import type { CodecOptions } from './safe-codec.js';
|
|
25
|
+
import { PROTOCOL_ID } from './transport.js';
|
|
26
|
+
import type { PooledWorker } from './worker-pool.js';
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// OPTIONS
|
|
30
|
+
// =============================================================================
|
|
39
31
|
|
|
40
32
|
/**
|
|
41
33
|
* 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
34
|
*/
|
|
46
35
|
export interface NodeBridgeOptions {
|
|
47
36
|
/** Minimum number of Python processes to keep alive. Default: 1 */
|
|
48
37
|
minProcesses?: number;
|
|
38
|
+
|
|
49
39
|
/** Maximum number of Python processes to spawn. Default: 1 (single-process mode) */
|
|
50
40
|
maxProcesses?: number;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
|
|
42
|
+
/** Maximum concurrent requests per process. Default: 10 */
|
|
43
|
+
maxConcurrentPerProcess?: number;
|
|
44
|
+
|
|
55
45
|
/** Path to Python executable. Auto-detected if not specified. */
|
|
56
46
|
pythonPath?: string;
|
|
47
|
+
|
|
57
48
|
/** Path to python_bridge.py script. Auto-detected if not specified. */
|
|
58
49
|
scriptPath?: string;
|
|
50
|
+
|
|
59
51
|
/** Path to Python virtual environment. */
|
|
60
52
|
virtualEnv?: string;
|
|
53
|
+
|
|
61
54
|
/** Working directory for Python process. Default: process.cwd() */
|
|
62
55
|
cwd?: string;
|
|
56
|
+
|
|
63
57
|
/** Timeout in ms for Python calls. Default: 30000 */
|
|
64
58
|
timeoutMs?: number;
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
|
|
60
|
+
/** Timeout in ms for waiting in pool queue. Default: 30000 */
|
|
61
|
+
queueTimeoutMs?: number;
|
|
62
|
+
|
|
67
63
|
/** Inherit all environment variables from parent process. Default: false */
|
|
68
64
|
inheritProcessEnv?: boolean;
|
|
69
|
-
|
|
70
|
-
* When true, sets TYWRAP_CODEC_FALLBACK=json for the Python process to prefer JSON encoding
|
|
71
|
-
* for rich types (ndarray/dataframe/series). Default: false for fast-fail on Arrow path issues.
|
|
72
|
-
*/
|
|
73
|
-
enableJsonFallback?: boolean;
|
|
65
|
+
|
|
74
66
|
/** Enable result caching for pure functions. Default: false */
|
|
75
67
|
enableCache?: boolean;
|
|
68
|
+
|
|
76
69
|
/** Optional extra environment variables to pass to the Python subprocess. */
|
|
77
70
|
env?: Record<string, string | undefined>;
|
|
71
|
+
|
|
72
|
+
/** Codec options for validation/serialization */
|
|
73
|
+
codec?: CodecOptions;
|
|
74
|
+
|
|
78
75
|
/** Commands to run on each process at startup for warming up. */
|
|
79
|
-
warmupCommands?: Array<
|
|
76
|
+
warmupCommands?: Array<
|
|
77
|
+
{ module: string; functionName: string; args?: unknown[] } | { method: string; params: unknown } // Legacy shape preserved so runtime can surface a migration error
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
// ===========================================================================
|
|
81
|
+
// DEPRECATED OPTIONS (kept for backwards compatibility, ignored internally)
|
|
82
|
+
// ===========================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @deprecated No longer used. Pool idle time is managed by WorkerPool.
|
|
86
|
+
*/
|
|
87
|
+
maxIdleTime?: number;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @deprecated No longer used. Process restart is managed by ProcessIO.
|
|
91
|
+
*/
|
|
92
|
+
maxRequestsPerProcess?: number;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated Use codec.bytesHandling option instead.
|
|
96
|
+
*/
|
|
97
|
+
enableJsonFallback?: boolean;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Use ProcessIO options instead.
|
|
101
|
+
*/
|
|
102
|
+
maxLineLength?: number;
|
|
80
103
|
}
|
|
81
104
|
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// INTERNAL TYPES
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
82
109
|
interface ResolvedOptions {
|
|
83
110
|
minProcesses: number;
|
|
84
111
|
maxProcesses: number;
|
|
85
|
-
|
|
86
|
-
maxRequestsPerProcess: number;
|
|
112
|
+
maxConcurrentPerProcess: number;
|
|
87
113
|
pythonPath: string;
|
|
88
114
|
scriptPath: string;
|
|
89
115
|
virtualEnv?: string;
|
|
90
116
|
cwd: string;
|
|
91
117
|
timeoutMs: number;
|
|
92
|
-
|
|
118
|
+
queueTimeoutMs: number;
|
|
93
119
|
inheritProcessEnv: boolean;
|
|
94
|
-
enableJsonFallback: boolean;
|
|
95
120
|
enableCache: boolean;
|
|
96
121
|
env: Record<string, string | undefined>;
|
|
97
|
-
|
|
122
|
+
codec?: CodecOptions;
|
|
123
|
+
warmupCommands: WarmupCommand[];
|
|
98
124
|
}
|
|
99
125
|
|
|
100
|
-
interface
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
};
|
|
126
|
+
interface WarmupCommand {
|
|
127
|
+
module: string;
|
|
128
|
+
functionName: string;
|
|
129
|
+
args?: unknown[];
|
|
114
130
|
}
|
|
115
131
|
|
|
116
|
-
|
|
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
|
-
}
|
|
132
|
+
const WORKER_READY_TIMEOUT_MS = 5000;
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
}>;
|
|
141
|
-
}
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// UTILITIES
|
|
136
|
+
// =============================================================================
|
|
142
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the default bridge script path.
|
|
140
|
+
*/
|
|
143
141
|
function resolveDefaultScriptPath(): string {
|
|
144
142
|
try {
|
|
145
143
|
return fileURLToPath(new URL('../../runtime/python_bridge.py', import.meta.url));
|
|
@@ -148,173 +146,298 @@ function resolveDefaultScriptPath(): string {
|
|
|
148
146
|
}
|
|
149
147
|
}
|
|
150
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Resolve virtual environment paths.
|
|
151
|
+
*/
|
|
151
152
|
function resolveVirtualEnv(
|
|
152
153
|
virtualEnv: string,
|
|
153
154
|
cwd: string
|
|
154
|
-
): {
|
|
155
|
-
venvPath: string;
|
|
156
|
-
binDir: string;
|
|
157
|
-
pythonPath: string;
|
|
158
|
-
} {
|
|
155
|
+
): { venvPath: string; binDir: string; pythonPath: string } {
|
|
159
156
|
const venvPath = resolve(cwd, virtualEnv);
|
|
160
157
|
const binDir = join(venvPath, getVenvBinDir());
|
|
161
158
|
const pythonPath = join(binDir, getVenvPythonExe());
|
|
162
159
|
return { venvPath, binDir, pythonPath };
|
|
163
160
|
}
|
|
164
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Get the environment variable key for PATH (case-insensitive on Windows).
|
|
164
|
+
*/
|
|
165
|
+
function getPathKey(env: Record<string, string | undefined>): string {
|
|
166
|
+
if (Object.prototype.hasOwnProperty.call(env, 'PATH')) {
|
|
167
|
+
return 'PATH';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const key of Object.keys(env)) {
|
|
171
|
+
if (key.toLowerCase() === 'path') {
|
|
172
|
+
return key;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return 'PATH';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setPathValue(env: Record<string, string>, value: string): void {
|
|
179
|
+
setEnvValue(env, 'PATH', value);
|
|
180
|
+
|
|
181
|
+
if (process.platform !== 'win32') {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const key of Object.keys(env)) {
|
|
186
|
+
if (key !== 'PATH' && key.toLowerCase() === 'path') {
|
|
187
|
+
setEnvValue(env, key, value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const DANGEROUS_ENV_OVERRIDE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
193
|
+
|
|
194
|
+
function createNullPrototypeEnv(): Record<string, string> {
|
|
195
|
+
return Object.create(null) as Record<string, string>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setEnvValue(env: Record<string, string>, key: string, value: string): void {
|
|
199
|
+
Object.defineProperty(env, key, {
|
|
200
|
+
value,
|
|
201
|
+
writable: true,
|
|
202
|
+
enumerable: true,
|
|
203
|
+
configurable: true,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getEnvValue(env: Record<string, string>, key: string): string | undefined {
|
|
208
|
+
const value = Reflect.get(env, key);
|
|
209
|
+
return typeof value === 'string' ? value : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertSafeEnvOverrideKey(key: string): void {
|
|
213
|
+
if (DANGEROUS_ENV_OVERRIDE_KEYS.has(key)) {
|
|
214
|
+
throw new BridgeProtocolError(`Invalid environment override key "${key}" in options.env`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeWarmupCommands(commands: NodeBridgeOptions['warmupCommands']): WarmupCommand[] {
|
|
219
|
+
if (commands === undefined) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(commands)) {
|
|
223
|
+
throw new BridgeProtocolError('warmupCommands must be an array when provided');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const warmups = commands;
|
|
227
|
+
return warmups.map((command, index) => {
|
|
228
|
+
if (!command || typeof command !== 'object' || Array.isArray(command)) {
|
|
229
|
+
throw new BridgeProtocolError(
|
|
230
|
+
`Invalid warmup command at index ${index + 1}: expected { module, functionName, args? }`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const candidate = command as Record<string, unknown>;
|
|
235
|
+
if ('method' in candidate || 'params' in candidate) {
|
|
236
|
+
throw new BridgeProtocolError(
|
|
237
|
+
`Invalid warmup command at index ${index + 1}: legacy { method, params } format is no longer supported. Use { module, functionName, args? }.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof candidate.module !== 'string' || candidate.module.trim().length === 0) {
|
|
242
|
+
throw new BridgeProtocolError(
|
|
243
|
+
`Invalid warmup command at index ${index + 1}: "module" must be a non-empty string`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (typeof candidate.functionName !== 'string' || candidate.functionName.trim().length === 0) {
|
|
247
|
+
throw new BridgeProtocolError(
|
|
248
|
+
`Invalid warmup command at index ${index + 1}: "functionName" must be a non-empty string`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (candidate.args !== undefined && !Array.isArray(candidate.args)) {
|
|
252
|
+
throw new BridgeProtocolError(
|
|
253
|
+
`Invalid warmup command at index ${index + 1}: "args" must be an array when provided`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
module: candidate.module,
|
|
259
|
+
functionName: candidate.functionName,
|
|
260
|
+
args: candidate.args as unknown[] | undefined,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// =============================================================================
|
|
266
|
+
// NODE BRIDGE
|
|
267
|
+
// =============================================================================
|
|
268
|
+
|
|
165
269
|
/**
|
|
166
270
|
* Node.js runtime bridge for executing Python code.
|
|
167
271
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
272
|
+
* NodeBridge provides subprocess-based Python execution with optional pooling
|
|
273
|
+
* for high-throughput workloads. By default, it runs in single-process mode.
|
|
274
|
+
*
|
|
275
|
+
* Features:
|
|
276
|
+
* - Single or multi-process execution via process pooling
|
|
277
|
+
* - Virtual environment support
|
|
278
|
+
* - Full SafeCodec validation (NaN/Infinity rejection, key validation)
|
|
279
|
+
* - Automatic Arrow decoding for DataFrames/ndarrays
|
|
280
|
+
* - Optional result caching for pure functions
|
|
281
|
+
* - Process warmup commands
|
|
170
282
|
*
|
|
171
283
|
* @example
|
|
172
284
|
* ```typescript
|
|
173
285
|
* // Single-process mode (default)
|
|
174
286
|
* const bridge = new NodeBridge();
|
|
287
|
+
* await bridge.init();
|
|
288
|
+
*
|
|
289
|
+
* const result = await bridge.call('math', 'sqrt', [16]);
|
|
290
|
+
* console.log(result); // 4.0
|
|
175
291
|
*
|
|
292
|
+
* await bridge.dispose();
|
|
293
|
+
* ```
|
|
294
|
+
*
|
|
295
|
+
* @example
|
|
296
|
+
* ```typescript
|
|
176
297
|
* // Multi-process pooling for high throughput
|
|
177
298
|
* const pooledBridge = new NodeBridge({
|
|
178
|
-
*
|
|
179
|
-
*
|
|
299
|
+
* maxProcesses: 4,
|
|
300
|
+
* maxConcurrentPerProcess: 2,
|
|
180
301
|
* enableCache: true,
|
|
181
302
|
* });
|
|
303
|
+
* await pooledBridge.init();
|
|
182
304
|
* ```
|
|
183
305
|
*/
|
|
184
|
-
export class NodeBridge extends
|
|
185
|
-
private
|
|
186
|
-
private
|
|
187
|
-
private cleanupTimer?: NodeJS.Timeout;
|
|
188
|
-
private options: ResolvedOptions;
|
|
189
|
-
private emitter = new EventEmitter();
|
|
190
|
-
private disposed = false;
|
|
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
|
-
};
|
|
306
|
+
export class NodeBridge extends BridgeProtocol {
|
|
307
|
+
private readonly resolvedOptions: ResolvedOptions;
|
|
308
|
+
private readonly pooledTransport: PooledTransport;
|
|
207
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Create a new NodeBridge instance.
|
|
312
|
+
*
|
|
313
|
+
* @param options - Configuration options for the bridge
|
|
314
|
+
*/
|
|
208
315
|
constructor(options: NodeBridgeOptions = {}) {
|
|
209
|
-
super();
|
|
210
316
|
const cwd = options.cwd ?? process.cwd();
|
|
211
317
|
const virtualEnv = options.virtualEnv ? resolve(cwd, options.virtualEnv) : undefined;
|
|
212
318
|
const venv = virtualEnv ? resolveVirtualEnv(virtualEnv, cwd) : undefined;
|
|
213
319
|
const scriptPath = options.scriptPath ?? resolveDefaultScriptPath();
|
|
214
320
|
const resolvedScriptPath = isAbsolute(scriptPath) ? scriptPath : resolve(cwd, scriptPath);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
321
|
+
|
|
322
|
+
const maxProcesses = options.maxProcesses ?? 1;
|
|
323
|
+
const minProcesses = Math.min(options.minProcesses ?? 1, maxProcesses);
|
|
324
|
+
|
|
325
|
+
const warmupCommands = normalizeWarmupCommands(options.warmupCommands);
|
|
326
|
+
|
|
327
|
+
const resolvedOptions: ResolvedOptions = {
|
|
328
|
+
minProcesses,
|
|
329
|
+
maxProcesses,
|
|
330
|
+
maxConcurrentPerProcess: options.maxConcurrentPerProcess ?? 10,
|
|
221
331
|
pythonPath: options.pythonPath ?? venv?.pythonPath ?? getDefaultPythonPath(),
|
|
222
332
|
scriptPath: resolvedScriptPath,
|
|
223
333
|
virtualEnv,
|
|
224
334
|
cwd,
|
|
225
335
|
timeoutMs: options.timeoutMs ?? 30000,
|
|
226
|
-
|
|
336
|
+
queueTimeoutMs: options.queueTimeoutMs ?? 30000,
|
|
227
337
|
inheritProcessEnv: options.inheritProcessEnv ?? false,
|
|
228
|
-
enableJsonFallback: options.enableJsonFallback ?? false,
|
|
229
338
|
enableCache: options.enableCache ?? false,
|
|
230
339
|
env: options.env ?? {},
|
|
231
|
-
|
|
340
|
+
codec: options.codec,
|
|
341
|
+
warmupCommands,
|
|
232
342
|
};
|
|
233
343
|
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
344
|
+
// Build environment for ProcessIO
|
|
345
|
+
const processEnv = buildProcessEnv(resolvedOptions);
|
|
346
|
+
|
|
347
|
+
// Create warmup callback for per-worker initialization
|
|
348
|
+
const userWarmup =
|
|
349
|
+
resolvedOptions.warmupCommands.length > 0
|
|
350
|
+
? createWarmupCallback(resolvedOptions.warmupCommands, resolvedOptions.timeoutMs)
|
|
351
|
+
: undefined;
|
|
352
|
+
const replacementWorkerReady = createWorkerReadyCallback(resolvedOptions.timeoutMs, userWarmup);
|
|
353
|
+
|
|
354
|
+
// Create pooled transport with ProcessIO workers
|
|
355
|
+
const transport = new PooledTransport({
|
|
356
|
+
createTransport: (): ProcessIO =>
|
|
357
|
+
new ProcessIO({
|
|
358
|
+
pythonPath: resolvedOptions.pythonPath,
|
|
359
|
+
bridgeScript: resolvedOptions.scriptPath,
|
|
360
|
+
env: processEnv,
|
|
361
|
+
cwd: resolvedOptions.cwd,
|
|
362
|
+
}),
|
|
363
|
+
maxWorkers: resolvedOptions.maxProcesses,
|
|
364
|
+
minWorkers: resolvedOptions.minProcesses,
|
|
365
|
+
queueTimeoutMs: resolvedOptions.queueTimeoutMs,
|
|
366
|
+
maxConcurrentPerWorker: resolvedOptions.maxConcurrentPerProcess,
|
|
367
|
+
onWorkerReady: userWarmup,
|
|
368
|
+
onReplacementWorkerReady: replacementWorkerReady,
|
|
369
|
+
});
|
|
237
370
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.
|
|
249
|
-
return this.initPromise;
|
|
371
|
+
// Initialize BridgeProtocol with pooled transport
|
|
372
|
+
const protocolOptions: BridgeProtocolOptions = {
|
|
373
|
+
transport,
|
|
374
|
+
codec: resolvedOptions.codec,
|
|
375
|
+
defaultTimeoutMs: resolvedOptions.timeoutMs,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
super(protocolOptions);
|
|
379
|
+
|
|
380
|
+
this.resolvedOptions = resolvedOptions;
|
|
381
|
+
this.pooledTransport = transport;
|
|
250
382
|
}
|
|
251
383
|
|
|
252
|
-
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
// LIFECYCLE
|
|
386
|
+
// ===========================================================================
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Initialize the bridge.
|
|
390
|
+
*
|
|
391
|
+
* Validates the bridge script exists, registers Arrow decoder,
|
|
392
|
+
* and initializes the transport pool (which runs warmup commands per-worker).
|
|
393
|
+
*/
|
|
394
|
+
protected override async doInit(): Promise<void> {
|
|
395
|
+
// Validate script exists
|
|
253
396
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- script path is user-configured
|
|
254
|
-
if (!existsSync(this.
|
|
255
|
-
throw new BridgeProtocolError(
|
|
397
|
+
if (!existsSync(this.resolvedOptions.scriptPath)) {
|
|
398
|
+
throw new BridgeProtocolError(
|
|
399
|
+
`Python bridge script not found at ${this.resolvedOptions.scriptPath}`
|
|
400
|
+
);
|
|
256
401
|
}
|
|
257
402
|
|
|
403
|
+
// Register Arrow decoder for DataFrames/ndarrays
|
|
258
404
|
const require = createRequire(import.meta.url);
|
|
259
405
|
await autoRegisterArrowDecoder({
|
|
260
406
|
loader: () => require('apache-arrow'),
|
|
261
407
|
});
|
|
262
408
|
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
-
}
|
|
409
|
+
// Initialize parent (which initializes transport and runs warmup per-worker)
|
|
410
|
+
await super.doInit();
|
|
275
411
|
}
|
|
276
412
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
await this.refreshBridgeInfo();
|
|
281
|
-
}
|
|
282
|
-
if (!this.bridgeInfo) {
|
|
283
|
-
throw new BridgeProtocolError('Bridge info unavailable');
|
|
284
|
-
}
|
|
285
|
-
return this.bridgeInfo;
|
|
286
|
-
}
|
|
413
|
+
// ===========================================================================
|
|
414
|
+
// CACHING OVERRIDE
|
|
415
|
+
// ===========================================================================
|
|
287
416
|
|
|
288
|
-
|
|
417
|
+
/**
|
|
418
|
+
* Override call() to add optional caching.
|
|
419
|
+
*/
|
|
420
|
+
override async call<T = unknown>(
|
|
289
421
|
module: string,
|
|
290
422
|
functionName: string,
|
|
291
423
|
args: unknown[],
|
|
292
424
|
kwargs?: Record<string, unknown>
|
|
293
425
|
): Promise<T> {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (cached !== null) {
|
|
303
|
-
this.stats.cacheHits++;
|
|
304
|
-
this.updateStats(performance.now() - startTime);
|
|
305
|
-
return cached;
|
|
426
|
+
// Check cache if enabled
|
|
427
|
+
if (this.resolvedOptions.enableCache) {
|
|
428
|
+
const cacheKey = this.safeCacheKey('runtime_call', module, functionName, args, kwargs);
|
|
429
|
+
if (cacheKey) {
|
|
430
|
+
const cached = await globalCache.get<T>(cacheKey);
|
|
431
|
+
if (cached !== null) {
|
|
432
|
+
return cached;
|
|
433
|
+
}
|
|
306
434
|
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
const result = await this.executeRequest<T>({
|
|
311
|
-
method: 'call',
|
|
312
|
-
params: { module, functionName, args, kwargs },
|
|
313
|
-
});
|
|
314
435
|
|
|
436
|
+
// Execute and cache if pure function
|
|
437
|
+
const startTime = performance.now();
|
|
438
|
+
const result = await super.call<T>(module, functionName, args, kwargs);
|
|
315
439
|
const duration = performance.now() - startTime;
|
|
316
440
|
|
|
317
|
-
// Cache result for pure functions (simple heuristic)
|
|
318
441
|
if (cacheKey && this.isPureFunctionCandidate(functionName, args)) {
|
|
319
442
|
await globalCache.set(cacheKey, result, {
|
|
320
443
|
computeTime: duration,
|
|
@@ -322,338 +445,74 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
322
445
|
});
|
|
323
446
|
}
|
|
324
447
|
|
|
325
|
-
this.updateStats(duration);
|
|
326
|
-
return result;
|
|
327
|
-
} catch (error) {
|
|
328
|
-
this.updateStats(performance.now() - startTime, true);
|
|
329
|
-
throw error;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async instantiate<T = unknown>(
|
|
334
|
-
module: string,
|
|
335
|
-
className: string,
|
|
336
|
-
args: unknown[],
|
|
337
|
-
kwargs?: Record<string, unknown>
|
|
338
|
-
): Promise<T> {
|
|
339
|
-
await this.init();
|
|
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
448
|
return result;
|
|
350
|
-
} catch (error) {
|
|
351
|
-
this.updateStats(performance.now() - startTime, true);
|
|
352
|
-
throw error;
|
|
353
449
|
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async callMethod<T = unknown>(
|
|
357
|
-
handle: string,
|
|
358
|
-
methodName: string,
|
|
359
|
-
args: unknown[],
|
|
360
|
-
kwargs?: Record<string, unknown>
|
|
361
|
-
): Promise<T> {
|
|
362
|
-
await this.init();
|
|
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
|
-
}
|
|
377
|
-
}
|
|
378
450
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
await this.executeRequest<void>({
|
|
382
|
-
method: 'dispose_instance',
|
|
383
|
-
params: { handle },
|
|
384
|
-
});
|
|
451
|
+
// No caching - direct call
|
|
452
|
+
return super.call<T>(module, functionName, args, kwargs);
|
|
385
453
|
}
|
|
386
454
|
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
}
|
|
401
|
-
}
|
|
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);
|
|
408
|
-
}
|
|
455
|
+
// ===========================================================================
|
|
456
|
+
// POOL STATISTICS
|
|
457
|
+
// ===========================================================================
|
|
409
458
|
|
|
410
459
|
/**
|
|
411
|
-
*
|
|
460
|
+
* Get current pool statistics.
|
|
412
461
|
*/
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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;
|
|
432
|
-
}
|
|
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
|
-
}
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
checkWorker();
|
|
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
|
-
|
|
478
|
-
try {
|
|
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;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
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
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
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
|
-
},
|
|
462
|
+
getPoolStats(): { workerCount: number; queueLength: number; totalInFlight: number } {
|
|
463
|
+
return {
|
|
464
|
+
workerCount: this.pooledTransport.workerCount,
|
|
465
|
+
queueLength: this.pooledTransport.queueLength,
|
|
466
|
+
totalInFlight: this.pooledTransport.totalInFlight,
|
|
550
467
|
};
|
|
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
468
|
}
|
|
578
469
|
|
|
579
470
|
/**
|
|
580
|
-
*
|
|
471
|
+
* Get bridge statistics.
|
|
472
|
+
*
|
|
473
|
+
* @deprecated Use getPoolStats() instead. This method is provided for
|
|
474
|
+
* backwards compatibility and returns a subset of the previous stats.
|
|
581
475
|
*/
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
476
|
+
getStats(): {
|
|
477
|
+
totalRequests: number;
|
|
478
|
+
totalTime: number;
|
|
479
|
+
cacheHits: number;
|
|
480
|
+
poolHits: number;
|
|
481
|
+
poolMisses: number;
|
|
482
|
+
processSpawns: number;
|
|
483
|
+
processDeaths: number;
|
|
484
|
+
memoryPeak: number;
|
|
485
|
+
averageTime: number;
|
|
486
|
+
cacheHitRate: number;
|
|
487
|
+
poolSize: number;
|
|
488
|
+
busyWorkers: number;
|
|
489
|
+
} {
|
|
490
|
+
const poolStats = this.getPoolStats();
|
|
491
|
+
return {
|
|
492
|
+
// Legacy stats (no longer tracked, return 0)
|
|
493
|
+
totalRequests: 0,
|
|
494
|
+
totalTime: 0,
|
|
495
|
+
cacheHits: 0,
|
|
496
|
+
poolHits: 0,
|
|
497
|
+
poolMisses: 0,
|
|
498
|
+
processSpawns: 0,
|
|
499
|
+
processDeaths: 0,
|
|
500
|
+
memoryPeak: 0,
|
|
501
|
+
averageTime: 0,
|
|
502
|
+
cacheHitRate: 0,
|
|
503
|
+
// Current pool stats
|
|
504
|
+
poolSize: poolStats.workerCount,
|
|
505
|
+
busyWorkers: poolStats.totalInFlight,
|
|
506
|
+
};
|
|
610
507
|
}
|
|
611
508
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
private handleWorkerExit(worker: WorkerProcess, _code: number | null): void {
|
|
616
|
-
if (!this.processPool.includes(worker)) {
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
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
|
-
}
|
|
635
|
-
}
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
// PRIVATE HELPERS
|
|
511
|
+
// ===========================================================================
|
|
636
512
|
|
|
637
513
|
/**
|
|
638
|
-
*
|
|
514
|
+
* Generate a cache key, returning null if generation fails.
|
|
639
515
|
*/
|
|
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) });
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
await Promise.all(warmupPromises);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
516
|
private safeCacheKey(prefix: string, ...inputs: unknown[]): string | null {
|
|
658
517
|
try {
|
|
659
518
|
return globalCache.generateKey(prefix, ...inputs);
|
|
@@ -663,10 +522,9 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
663
522
|
}
|
|
664
523
|
|
|
665
524
|
/**
|
|
666
|
-
* Heuristic to determine if function result should be cached
|
|
525
|
+
* Heuristic to determine if function result should be cached.
|
|
667
526
|
*/
|
|
668
527
|
private isPureFunctionCandidate(functionName: string, args: unknown[]): boolean {
|
|
669
|
-
// Simple heuristics - could be made more sophisticated
|
|
670
528
|
const pureFunctionPatterns = [
|
|
671
529
|
/^(get|fetch|read|load|find|search|query|select)_/i,
|
|
672
530
|
/^(compute|calculate|process|transform|convert)_/i,
|
|
@@ -679,222 +537,251 @@ export class NodeBridge extends RuntimeBridge {
|
|
|
679
537
|
/random|uuid|timestamp|now|current/i,
|
|
680
538
|
];
|
|
681
539
|
|
|
682
|
-
// Don't cache if function name suggests mutation
|
|
683
540
|
if (impureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
684
541
|
return false;
|
|
685
542
|
}
|
|
686
543
|
|
|
687
|
-
// Cache if function name suggests pure computation
|
|
688
544
|
if (pureFunctionPatterns.some(pattern => pattern.test(functionName))) {
|
|
689
545
|
return true;
|
|
690
546
|
}
|
|
691
547
|
|
|
692
|
-
// Don't cache if args contain mutable objects (very basic check)
|
|
693
548
|
const hasComplexArgs = args.some(
|
|
694
549
|
arg => arg !== null && typeof arg === 'object' && !(arg instanceof Date)
|
|
695
550
|
);
|
|
696
551
|
|
|
697
|
-
return !hasComplexArgs && args.length <= 3;
|
|
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
|
-
}
|
|
552
|
+
return !hasComplexArgs && args.length <= 3;
|
|
711
553
|
}
|
|
554
|
+
}
|
|
712
555
|
|
|
713
|
-
|
|
714
|
-
|
|
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;
|
|
556
|
+
// =============================================================================
|
|
557
|
+
// WARMUP CALLBACK
|
|
558
|
+
// =============================================================================
|
|
721
559
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
);
|
|
560
|
+
/**
|
|
561
|
+
* Simple request ID generator for warmup commands.
|
|
562
|
+
*/
|
|
563
|
+
let warmupRequestId = 0;
|
|
564
|
+
function generateWarmupId(): number {
|
|
565
|
+
return ++warmupRequestId;
|
|
566
|
+
}
|
|
751
567
|
|
|
752
|
-
|
|
753
|
-
|
|
568
|
+
/**
|
|
569
|
+
* Create a callback that runs warmup commands on each worker.
|
|
570
|
+
*
|
|
571
|
+
* The callback sends warmup commands directly to the worker's transport,
|
|
572
|
+
* bypassing the pool to ensure each worker gets warmed up individually.
|
|
573
|
+
*/
|
|
574
|
+
function createWarmupCallback(
|
|
575
|
+
warmupCommands: WarmupCommand[],
|
|
576
|
+
timeoutMs: number
|
|
577
|
+
): (worker: PooledWorker) => Promise<void> {
|
|
578
|
+
return async (worker: PooledWorker) => {
|
|
579
|
+
for (const [index, cmd] of warmupCommands.entries()) {
|
|
580
|
+
const commandLabel = `${cmd.module}.${cmd.functionName}`;
|
|
581
|
+
await executeWorkerCall(
|
|
582
|
+
worker,
|
|
583
|
+
{
|
|
584
|
+
module: cmd.module,
|
|
585
|
+
functionName: cmd.functionName,
|
|
586
|
+
args: cmd.args ?? [],
|
|
587
|
+
},
|
|
588
|
+
timeoutMs,
|
|
589
|
+
{
|
|
590
|
+
label: `Warmup command #${index + 1} (${commandLabel})`,
|
|
591
|
+
invalidJsonMessage: 'returned invalid JSON response',
|
|
592
|
+
malformedEnvelopeMessage: requestId =>
|
|
593
|
+
`returned malformed response envelope for request ${requestId}`,
|
|
594
|
+
pythonErrorMessage: (errorType, errorMessage) => `failed: ${errorType}: ${errorMessage}`,
|
|
595
|
+
malformedErrorPayloadMessage: 'failed with malformed error payload',
|
|
596
|
+
}
|
|
597
|
+
);
|
|
754
598
|
}
|
|
599
|
+
};
|
|
600
|
+
}
|
|
755
601
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
602
|
+
function createWorkerReadyCallback(
|
|
603
|
+
timeoutMs: number,
|
|
604
|
+
extraWarmup?: (worker: PooledWorker) => Promise<void>
|
|
605
|
+
): (worker: PooledWorker) => Promise<void> {
|
|
606
|
+
return async (worker: PooledWorker) => {
|
|
607
|
+
const result = await executeWorkerCall(
|
|
608
|
+
worker,
|
|
609
|
+
{
|
|
610
|
+
module: 'builtins',
|
|
611
|
+
functionName: 'len',
|
|
612
|
+
args: [[]],
|
|
613
|
+
},
|
|
614
|
+
Math.max(timeoutMs, WORKER_READY_TIMEOUT_MS),
|
|
615
|
+
{
|
|
616
|
+
label: 'Worker readiness probe (builtins.len)',
|
|
617
|
+
invalidJsonMessage: 'returned invalid JSON response',
|
|
618
|
+
malformedEnvelopeMessage: requestId =>
|
|
619
|
+
`returned malformed response envelope for request ${requestId}`,
|
|
620
|
+
pythonErrorMessage: (errorType, errorMessage) => `failed: ${errorType}: ${errorMessage}`,
|
|
621
|
+
malformedErrorPayloadMessage: 'failed with malformed error payload',
|
|
622
|
+
}
|
|
759
623
|
);
|
|
760
624
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
625
|
+
if (result !== 0) {
|
|
626
|
+
throw new BridgeExecutionError(
|
|
627
|
+
`Worker readiness probe (builtins.len) returned unexpected result: ${JSON.stringify(result)}`
|
|
628
|
+
);
|
|
766
629
|
}
|
|
767
|
-
}
|
|
768
630
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
private async terminateWorker(
|
|
773
|
-
worker: WorkerProcess,
|
|
774
|
-
options: { force?: boolean } = {}
|
|
775
|
-
): Promise<void> {
|
|
776
|
-
if (worker.busy && !options.force) {
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
631
|
+
await extraWarmup?.(worker);
|
|
632
|
+
};
|
|
633
|
+
}
|
|
779
634
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
635
|
+
interface WorkerCallSpec {
|
|
636
|
+
module: string;
|
|
637
|
+
functionName: string;
|
|
638
|
+
args: unknown[];
|
|
639
|
+
}
|
|
785
640
|
|
|
786
|
-
|
|
787
|
-
|
|
641
|
+
interface WorkerCallErrorMessages {
|
|
642
|
+
label: string;
|
|
643
|
+
invalidJsonMessage: string;
|
|
644
|
+
malformedEnvelopeMessage: (requestId: number) => string;
|
|
645
|
+
pythonErrorMessage: (errorType: string, errorMessage: string) => string;
|
|
646
|
+
malformedErrorPayloadMessage: string;
|
|
647
|
+
}
|
|
788
648
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
649
|
+
async function executeWorkerCall(
|
|
650
|
+
worker: PooledWorker,
|
|
651
|
+
request: WorkerCallSpec,
|
|
652
|
+
timeoutMs: number,
|
|
653
|
+
messages: WorkerCallErrorMessages
|
|
654
|
+
): Promise<unknown> {
|
|
655
|
+
const requestId = generateWarmupId();
|
|
656
|
+
let message: string;
|
|
657
|
+
try {
|
|
658
|
+
message = JSON.stringify({
|
|
659
|
+
id: requestId,
|
|
660
|
+
protocol: PROTOCOL_ID,
|
|
661
|
+
method: 'call',
|
|
662
|
+
params: {
|
|
663
|
+
module: request.module,
|
|
664
|
+
functionName: request.functionName,
|
|
665
|
+
args: request.args,
|
|
666
|
+
kwargs: {},
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
} catch (error) {
|
|
670
|
+
throw new BridgeExecutionError(
|
|
671
|
+
`${messages.label} failed to encode request: ${error instanceof Error ? error.message : String(error)}`,
|
|
672
|
+
{ cause: error instanceof Error ? error : undefined }
|
|
673
|
+
);
|
|
804
674
|
}
|
|
805
675
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
log.error('Cleanup error', { error: String(error) });
|
|
815
|
-
}
|
|
816
|
-
}, 60000); // Cleanup every minute
|
|
676
|
+
let response: string;
|
|
677
|
+
try {
|
|
678
|
+
response = await worker.transport.send(message, timeoutMs);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw new BridgeExecutionError(
|
|
681
|
+
`${messages.label} failed to send: ${error instanceof Error ? error.message : String(error)}`,
|
|
682
|
+
{ cause: error instanceof Error ? error : undefined }
|
|
683
|
+
);
|
|
817
684
|
}
|
|
818
685
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
686
|
+
let parsed: unknown;
|
|
687
|
+
try {
|
|
688
|
+
parsed = JSON.parse(response);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
throw new BridgeExecutionError(`${messages.label} ${messages.invalidJsonMessage}`, {
|
|
691
|
+
cause: error instanceof Error ? error : undefined,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
826
694
|
|
|
827
|
-
|
|
695
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
696
|
+
throw new BridgeExecutionError(
|
|
697
|
+
`${messages.label} ${messages.malformedEnvelopeMessage(requestId)}`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
828
700
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
701
|
+
const envelope = parsed as { result?: unknown; error?: unknown };
|
|
702
|
+
if ('error' in envelope && envelope.error !== undefined && envelope.error !== null) {
|
|
703
|
+
const errorPayload = envelope.error;
|
|
704
|
+
if (errorPayload && typeof errorPayload === 'object' && !Array.isArray(errorPayload)) {
|
|
705
|
+
const err = errorPayload as { type?: unknown; message?: unknown };
|
|
706
|
+
const errType = typeof err.type === 'string' ? err.type : 'Error';
|
|
707
|
+
const errMessage = typeof err.message === 'string' ? err.message : 'Unknown error';
|
|
708
|
+
throw new BridgeExecutionError(
|
|
709
|
+
`${messages.label} ${messages.pythonErrorMessage(errType, errMessage)}`
|
|
710
|
+
);
|
|
832
711
|
}
|
|
833
712
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
this.terminateWorker(worker, { force: true })
|
|
837
|
-
);
|
|
838
|
-
await Promise.all(terminationPromises);
|
|
713
|
+
throw new BridgeExecutionError(`${messages.label} ${messages.malformedErrorPayloadMessage}`);
|
|
714
|
+
}
|
|
839
715
|
|
|
840
|
-
|
|
841
|
-
|
|
716
|
+
if (!('result' in envelope)) {
|
|
717
|
+
throw new BridgeExecutionError(
|
|
718
|
+
`${messages.label} ${messages.malformedEnvelopeMessage(requestId)}`
|
|
719
|
+
);
|
|
842
720
|
}
|
|
843
721
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
722
|
+
return envelope.result;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// =============================================================================
|
|
726
|
+
// ENVIRONMENT BUILDING
|
|
727
|
+
// =============================================================================
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Build environment variables for ProcessIO.
|
|
731
|
+
*/
|
|
732
|
+
function buildProcessEnv(options: ResolvedOptions): Record<string, string> {
|
|
733
|
+
const allowedPrefixes = ['TYWRAP_'];
|
|
734
|
+
const allowedKeys = new Set(['path', 'pythonpath', 'virtual_env', 'pythonhome']);
|
|
735
|
+
const env = createNullPrototypeEnv();
|
|
736
|
+
|
|
737
|
+
// Copy allowed env vars from process.env
|
|
738
|
+
if (options.inheritProcessEnv) {
|
|
739
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
740
|
+
if (value !== undefined) {
|
|
741
|
+
setEnvValue(env, key, value);
|
|
852
742
|
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
746
|
+
if (
|
|
747
|
+
value !== undefined &&
|
|
748
|
+
(allowedKeys.has(key.toLowerCase()) || allowedPrefixes.some(p => key.startsWith(p)))
|
|
749
|
+
) {
|
|
750
|
+
setEnvValue(env, key, value);
|
|
862
751
|
}
|
|
863
752
|
}
|
|
753
|
+
}
|
|
864
754
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
env
|
|
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}`;
|
|
755
|
+
// Apply user overrides
|
|
756
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
757
|
+
assertSafeEnvOverrideKey(key);
|
|
758
|
+
if (value !== undefined) {
|
|
759
|
+
setEnvValue(env, key, value);
|
|
875
760
|
}
|
|
761
|
+
}
|
|
876
762
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
env =
|
|
881
|
-
|
|
763
|
+
// Configure virtual environment
|
|
764
|
+
if (options.virtualEnv) {
|
|
765
|
+
const venv = resolveVirtualEnv(options.virtualEnv, options.cwd);
|
|
766
|
+
env.VIRTUAL_ENV = venv.venvPath;
|
|
767
|
+
const pathKey = getPathKey(env);
|
|
768
|
+
const currentPath = getEnvValue(env, pathKey);
|
|
769
|
+
setPathValue(env, currentPath ? `${venv.binDir}${delimiter}${currentPath}` : venv.binDir);
|
|
882
770
|
}
|
|
883
771
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
772
|
+
// Add cwd to PYTHONPATH so Python can find modules in the working directory
|
|
773
|
+
if (options.cwd) {
|
|
774
|
+
const currentPythonPath = env.PYTHONPATH ?? '';
|
|
775
|
+
env.PYTHONPATH = currentPythonPath
|
|
776
|
+
? `${options.cwd}${delimiter}${currentPythonPath}`
|
|
777
|
+
: options.cwd;
|
|
888
778
|
}
|
|
889
|
-
}
|
|
890
779
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
780
|
+
// Ensure Python uses UTF-8
|
|
781
|
+
env.PYTHONUTF8 = '1';
|
|
782
|
+
env.PYTHONIOENCODING = 'UTF-8';
|
|
783
|
+
env.PYTHONUNBUFFERED = '1';
|
|
784
|
+
env.PYTHONDONTWRITEBYTECODE = '1';
|
|
896
785
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
*/
|
|
900
|
-
export type { NodeBridgeOptions as ProcessPoolOptions };
|
|
786
|
+
return env;
|
|
787
|
+
}
|