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.
Files changed (163) hide show
  1. package/README.md +65 -31
  2. package/dist/cli.js +49 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/index.d.ts +11 -7
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/index.js +43 -7
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/core/analyzer.d.ts +1 -0
  9. package/dist/core/analyzer.d.ts.map +1 -1
  10. package/dist/core/analyzer.js +47 -12
  11. package/dist/core/analyzer.js.map +1 -1
  12. package/dist/core/annotation-parser.d.ts +8 -0
  13. package/dist/core/annotation-parser.d.ts.map +1 -0
  14. package/dist/core/annotation-parser.js +409 -0
  15. package/dist/core/annotation-parser.js.map +1 -0
  16. package/dist/core/discovery.d.ts +1 -0
  17. package/dist/core/discovery.d.ts.map +1 -1
  18. package/dist/core/discovery.js +9 -9
  19. package/dist/core/discovery.js.map +1 -1
  20. package/dist/core/generator.d.ts +9 -1
  21. package/dist/core/generator.d.ts.map +1 -1
  22. package/dist/core/generator.js +738 -104
  23. package/dist/core/generator.js.map +1 -1
  24. package/dist/core/mapper.d.ts +9 -7
  25. package/dist/core/mapper.d.ts.map +1 -1
  26. package/dist/core/mapper.js +129 -44
  27. package/dist/core/mapper.js.map +1 -1
  28. package/dist/index.d.ts +23 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +23 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/runtime/base.d.ts +17 -7
  33. package/dist/runtime/base.d.ts.map +1 -1
  34. package/dist/runtime/base.js +18 -1
  35. package/dist/runtime/base.js.map +1 -1
  36. package/dist/runtime/bounded-context.d.ts +252 -0
  37. package/dist/runtime/bounded-context.d.ts.map +1 -0
  38. package/dist/runtime/bounded-context.js +454 -0
  39. package/dist/runtime/bounded-context.js.map +1 -0
  40. package/dist/runtime/bridge-core.d.ts +1 -0
  41. package/dist/runtime/bridge-core.d.ts.map +1 -1
  42. package/dist/runtime/bridge-core.js +52 -17
  43. package/dist/runtime/bridge-core.js.map +1 -1
  44. package/dist/runtime/bridge-protocol.d.ts +184 -0
  45. package/dist/runtime/bridge-protocol.d.ts.map +1 -0
  46. package/dist/runtime/bridge-protocol.js +344 -0
  47. package/dist/runtime/bridge-protocol.js.map +1 -0
  48. package/dist/runtime/disposable.d.ts +40 -0
  49. package/dist/runtime/disposable.d.ts.map +1 -0
  50. package/dist/runtime/disposable.js +49 -0
  51. package/dist/runtime/disposable.js.map +1 -0
  52. package/dist/runtime/errors.d.ts +10 -0
  53. package/dist/runtime/errors.d.ts.map +1 -1
  54. package/dist/runtime/errors.js +9 -0
  55. package/dist/runtime/errors.js.map +1 -1
  56. package/dist/runtime/http-io.d.ts +91 -0
  57. package/dist/runtime/http-io.d.ts.map +1 -0
  58. package/dist/runtime/http-io.js +195 -0
  59. package/dist/runtime/http-io.js.map +1 -0
  60. package/dist/runtime/http.d.ts +48 -13
  61. package/dist/runtime/http.d.ts.map +1 -1
  62. package/dist/runtime/http.js +60 -73
  63. package/dist/runtime/http.js.map +1 -1
  64. package/dist/runtime/node.d.ts +97 -130
  65. package/dist/runtime/node.d.ts.map +1 -1
  66. package/dist/runtime/node.js +392 -521
  67. package/dist/runtime/node.js.map +1 -1
  68. package/dist/runtime/optimized-node.d.ts +1 -1
  69. package/dist/runtime/optimized-node.d.ts.map +1 -1
  70. package/dist/runtime/optimized-node.js +1 -1
  71. package/dist/runtime/optimized-node.js.map +1 -1
  72. package/dist/runtime/pooled-transport.d.ts +135 -0
  73. package/dist/runtime/pooled-transport.d.ts.map +1 -0
  74. package/dist/runtime/pooled-transport.js +177 -0
  75. package/dist/runtime/pooled-transport.js.map +1 -0
  76. package/dist/runtime/process-io.d.ts +209 -0
  77. package/dist/runtime/process-io.d.ts.map +1 -0
  78. package/dist/runtime/process-io.js +729 -0
  79. package/dist/runtime/process-io.js.map +1 -0
  80. package/dist/runtime/pyodide-io.d.ts +154 -0
  81. package/dist/runtime/pyodide-io.d.ts.map +1 -0
  82. package/dist/runtime/pyodide-io.js +459 -0
  83. package/dist/runtime/pyodide-io.js.map +1 -0
  84. package/dist/runtime/pyodide.d.ts +51 -19
  85. package/dist/runtime/pyodide.d.ts.map +1 -1
  86. package/dist/runtime/pyodide.js +57 -186
  87. package/dist/runtime/pyodide.js.map +1 -1
  88. package/dist/runtime/safe-codec.d.ts +103 -0
  89. package/dist/runtime/safe-codec.d.ts.map +1 -0
  90. package/dist/runtime/safe-codec.js +519 -0
  91. package/dist/runtime/safe-codec.js.map +1 -0
  92. package/dist/runtime/transport.d.ts +191 -0
  93. package/dist/runtime/transport.d.ts.map +1 -0
  94. package/dist/runtime/transport.js +86 -0
  95. package/dist/runtime/transport.js.map +1 -0
  96. package/dist/runtime/validators.d.ts +120 -0
  97. package/dist/runtime/validators.d.ts.map +1 -0
  98. package/dist/runtime/validators.js +229 -0
  99. package/dist/runtime/validators.js.map +1 -0
  100. package/dist/runtime/worker-pool.d.ts +208 -0
  101. package/dist/runtime/worker-pool.d.ts.map +1 -0
  102. package/dist/runtime/worker-pool.js +419 -0
  103. package/dist/runtime/worker-pool.js.map +1 -0
  104. package/dist/types/index.d.ts +51 -1
  105. package/dist/types/index.d.ts.map +1 -1
  106. package/dist/tywrap.d.ts.map +1 -1
  107. package/dist/tywrap.js +227 -328
  108. package/dist/tywrap.js.map +1 -1
  109. package/dist/utils/cache.d.ts.map +1 -1
  110. package/dist/utils/cache.js +53 -5
  111. package/dist/utils/cache.js.map +1 -1
  112. package/dist/utils/codec.d.ts.map +1 -1
  113. package/dist/utils/codec.js +114 -1
  114. package/dist/utils/codec.js.map +1 -1
  115. package/dist/utils/ir-cache.d.ts +28 -0
  116. package/dist/utils/ir-cache.d.ts.map +1 -0
  117. package/dist/utils/ir-cache.js +29 -0
  118. package/dist/utils/ir-cache.js.map +1 -0
  119. package/dist/utils/parallel-processor.d.ts +1 -1
  120. package/dist/utils/parallel-processor.d.ts.map +1 -1
  121. package/dist/utils/parallel-processor.js +111 -40
  122. package/dist/utils/parallel-processor.js.map +1 -1
  123. package/dist/utils/runtime.d.ts +5 -1
  124. package/dist/utils/runtime.d.ts.map +1 -1
  125. package/dist/utils/runtime.js +112 -18
  126. package/dist/utils/runtime.js.map +1 -1
  127. package/package.json +40 -18
  128. package/runtime/python_bridge.py +108 -15
  129. package/runtime/safe_codec.py +344 -0
  130. package/src/cli.ts +61 -2
  131. package/src/config/index.ts +63 -17
  132. package/src/core/analyzer.ts +52 -15
  133. package/src/core/annotation-parser.ts +500 -0
  134. package/src/core/discovery.ts +26 -12
  135. package/src/core/generator.ts +956 -116
  136. package/src/core/mapper.ts +158 -51
  137. package/src/index.ts +78 -6
  138. package/src/runtime/base.ts +18 -26
  139. package/src/runtime/bounded-context.ts +608 -0
  140. package/src/runtime/bridge-core.ts +58 -18
  141. package/src/runtime/bridge-protocol.ts +483 -0
  142. package/src/runtime/disposable.ts +65 -0
  143. package/src/runtime/errors.ts +14 -0
  144. package/src/runtime/http-io.ts +245 -0
  145. package/src/runtime/http.ts +76 -115
  146. package/src/runtime/node.ts +563 -676
  147. package/src/runtime/optimized-node.ts +4 -1
  148. package/src/runtime/pooled-transport.ts +263 -0
  149. package/src/runtime/process-io.ts +930 -0
  150. package/src/runtime/pyodide-io.ts +551 -0
  151. package/src/runtime/pyodide.ts +75 -215
  152. package/src/runtime/safe-codec.ts +651 -0
  153. package/src/runtime/transport.ts +278 -0
  154. package/src/runtime/validators.ts +252 -0
  155. package/src/runtime/worker-pool.ts +559 -0
  156. package/src/types/global.d.ts +11 -1
  157. package/src/types/index.ts +63 -0
  158. package/src/tywrap.ts +279 -358
  159. package/src/utils/cache.ts +59 -5
  160. package/src/utils/codec.ts +122 -1
  161. package/src/utils/ir-cache.ts +51 -0
  162. package/src/utils/parallel-processor.ts +119 -43
  163. package/src/utils/runtime.ts +129 -18
@@ -0,0 +1,930 @@
1
+ /**
2
+ * ProcessIO Transport - Subprocess-based Python communication for Node.js.
3
+ *
4
+ * This transport implements the Transport interface for spawning and communicating
5
+ * with a Python subprocess via stdio streams. It provides:
6
+ * - JSONL (JSON Lines) protocol for request/response messaging
7
+ * - Backpressure handling for stdin writes
8
+ * - Line-based stdout parsing with buffering
9
+ * - Stderr capture for error diagnostics
10
+ * - Request timeout management
11
+ * - Optional process restart after N requests
12
+ *
13
+ * @see https://github.com/bbopen/tywrap/issues/149
14
+ */
15
+
16
+ import { spawn, type ChildProcess } from 'child_process';
17
+ import { BoundedContext } from './bounded-context.js';
18
+ import {
19
+ BridgeDisposedError,
20
+ BridgeProtocolError,
21
+ BridgeTimeoutError,
22
+ BridgeExecutionError,
23
+ } from './errors.js';
24
+ import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
25
+ import type { Transport } from './transport.js';
26
+
27
+ // =============================================================================
28
+ // CONSTANTS
29
+ // =============================================================================
30
+
31
+ /** Default maximum response line length: 100MB */
32
+ const DEFAULT_MAX_LINE_LENGTH = 100 * 1024 * 1024;
33
+
34
+ /** Maximum stderr bytes to retain for diagnostics: 8KB */
35
+ const MAX_STDERR_BYTES = 8 * 1024;
36
+
37
+ /** Default write queue timeout: 30 seconds */
38
+ const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
39
+
40
+ /** Track timed-out/cancelled request IDs long enough to ignore late responses. */
41
+ const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
42
+
43
+ /** Regex for ANSI escape sequences */
44
+ const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
45
+
46
+ /** Regex for control characters */
47
+ const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
48
+
49
+ // =============================================================================
50
+ // TYPES
51
+ // =============================================================================
52
+
53
+ /**
54
+ * Options for ProcessIO transport.
55
+ */
56
+ export interface ProcessIOOptions {
57
+ /** Python executable path. Default: 'python3' */
58
+ pythonPath?: string;
59
+
60
+ /** Path to the bridge script */
61
+ bridgeScript: string;
62
+
63
+ /** Environment variables to pass to the subprocess */
64
+ env?: Record<string, string>;
65
+
66
+ /** Working directory for the subprocess. Default: process.cwd() */
67
+ cwd?: string;
68
+
69
+ /** Maximum line length for responses. Default: 100MB */
70
+ maxLineLength?: number;
71
+
72
+ /** Restart process after N requests (0 = never). Default: 0 */
73
+ restartAfterRequests?: number;
74
+
75
+ /** Write queue timeout in milliseconds. Default: 30000ms */
76
+ writeQueueTimeoutMs?: number;
77
+ }
78
+
79
+ /**
80
+ * Pending request entry for tracking in-flight requests.
81
+ */
82
+ interface PendingRequest {
83
+ resolve: (value: string) => void;
84
+ reject: (error: Error) => void;
85
+ timer?: NodeJS.Timeout;
86
+ }
87
+
88
+ /**
89
+ * Queued write entry for backpressure handling.
90
+ */
91
+ interface QueuedWrite {
92
+ data: string;
93
+ resolve: () => void;
94
+ reject: (error: Error) => void;
95
+ /** Timestamp when the write was queued */
96
+ queuedAt: number;
97
+ /** Timeout handle for write queue timeout */
98
+ timeoutHandle?: NodeJS.Timeout;
99
+ }
100
+
101
+ // =============================================================================
102
+ // UTILITIES
103
+ // =============================================================================
104
+
105
+ /**
106
+ * Sanitize stderr output by removing ANSI codes and control characters.
107
+ */
108
+ function sanitizeStderr(value: string): string {
109
+ return value.replace(ANSI_ESCAPE_RE, '').replace(CONTROL_CHARS_RE, '');
110
+ }
111
+
112
+ /**
113
+ * Extract top-level message ID from a JSON string.
114
+ * Returns null if ID cannot be extracted.
115
+ */
116
+ function extractMessageId(json: string): number | null {
117
+ let parsed: unknown;
118
+ try {
119
+ parsed = JSON.parse(json);
120
+ } catch {
121
+ return null;
122
+ }
123
+
124
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
125
+ return null;
126
+ }
127
+
128
+ const id = (parsed as { id?: unknown }).id;
129
+ if (typeof id !== 'number' || !Number.isInteger(id)) {
130
+ return null;
131
+ }
132
+
133
+ return id;
134
+ }
135
+
136
+ // =============================================================================
137
+ // PROCESS IO TRANSPORT
138
+ // =============================================================================
139
+
140
+ /**
141
+ * Transport implementation for subprocess-based Python communication.
142
+ *
143
+ * ProcessIO spawns a Python child process and communicates via stdio:
144
+ * - Requests are written to stdin as JSON lines
145
+ * - Responses are read from stdout as JSON lines
146
+ * - Stderr is captured for diagnostics
147
+ *
148
+ * @example
149
+ * ```typescript
150
+ * const transport = new ProcessIO({
151
+ * bridgeScript: '/path/to/bridge.py',
152
+ * pythonPath: 'python3',
153
+ * });
154
+ *
155
+ * await transport.init();
156
+ *
157
+ * const response = await transport.send(
158
+ * JSON.stringify({ id: '1', type: 'call', module: 'math', args: [16] }),
159
+ * 5000
160
+ * );
161
+ *
162
+ * await transport.dispose();
163
+ * ```
164
+ */
165
+ export class ProcessIO extends BoundedContext implements Transport {
166
+ // Configuration
167
+ private readonly pythonPath: string;
168
+ private readonly bridgeScript: string;
169
+ private readonly envOverrides: Record<string, string>;
170
+ private readonly cwd: string | undefined;
171
+ private readonly maxLineLength: number;
172
+ private readonly restartAfterRequests: number;
173
+ private readonly writeQueueTimeoutMs: number;
174
+
175
+ // Process state
176
+ private process: ChildProcess | null = null;
177
+ private processExited = false;
178
+ private processError: Error | null = null;
179
+
180
+ // Stream buffers
181
+ private stdoutBuffer = '';
182
+ private stderrBuffer = '';
183
+
184
+ // Request tracking
185
+ private readonly pending = new Map<number, PendingRequest>();
186
+ private readonly timedOutRequests = new TimedOutRequestTracker({
187
+ ttlMs: TIMED_OUT_REQUEST_TTL_MS,
188
+ });
189
+ private requestCount = 0;
190
+ private needsRestart = false;
191
+
192
+ // Write queue for backpressure
193
+ private readonly writeQueue: QueuedWrite[] = [];
194
+ private draining = false;
195
+
196
+ /**
197
+ * Create a new ProcessIO transport.
198
+ *
199
+ * @param options - Transport configuration options
200
+ */
201
+ constructor(options: ProcessIOOptions) {
202
+ super();
203
+
204
+ this.pythonPath = options.pythonPath ?? 'python3';
205
+ this.bridgeScript = options.bridgeScript;
206
+ this.envOverrides = options.env ?? {};
207
+ this.cwd = options.cwd;
208
+ this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
209
+ this.restartAfterRequests = options.restartAfterRequests ?? 0;
210
+ this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
211
+ }
212
+
213
+ // ===========================================================================
214
+ // TRANSPORT INTERFACE
215
+ // ===========================================================================
216
+
217
+ /**
218
+ * Send a message and wait for the response.
219
+ *
220
+ * @param message - The JSON-encoded protocol message
221
+ * @param timeoutMs - Timeout in milliseconds (0 = no timeout)
222
+ * @param signal - Optional AbortSignal for cancellation
223
+ * @returns The raw JSON response string
224
+ */
225
+ async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
226
+ // Check disposed state
227
+ if (this.isDisposed) {
228
+ throw new BridgeDisposedError('Transport has been disposed');
229
+ }
230
+
231
+ // Auto-initialize if needed
232
+ if (!this.isReady) {
233
+ await this.init();
234
+ }
235
+
236
+ // Check if process is alive
237
+ if (this.processExited || !this.process) {
238
+ const stderrTail = this.getStderrTail();
239
+ const baseMsg = 'Python process is not running';
240
+ const msg = stderrTail ? `${baseMsg}. Stderr:\n${stderrTail}` : baseMsg;
241
+ throw new BridgeProtocolError(msg);
242
+ }
243
+
244
+ // Check if already aborted
245
+ if (signal?.aborted) {
246
+ throw new BridgeTimeoutError('Operation aborted');
247
+ }
248
+
249
+ // Extract message ID for response correlation
250
+ const messageId = extractMessageId(message);
251
+ if (messageId === null) {
252
+ throw new BridgeProtocolError('Message must contain an "id" field');
253
+ }
254
+
255
+ // Check for restart condition (either scheduled restart or forced by stream error)
256
+ if (
257
+ this.needsRestart ||
258
+ (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)
259
+ ) {
260
+ await this.restartProcess();
261
+ }
262
+
263
+ // Create promise for response
264
+ return new Promise<string>((resolve, reject) => {
265
+ // Set up timeout if specified
266
+ let timer: NodeJS.Timeout | undefined;
267
+ if (timeoutMs > 0) {
268
+ timer = setTimeout(() => {
269
+ this.pending.delete(messageId);
270
+ this.timedOutRequests.mark(messageId);
271
+ const stderrTail = this.getStderrTail();
272
+ const baseMsg = `Operation timed out after ${timeoutMs}ms`;
273
+ const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
274
+ reject(new BridgeTimeoutError(msg));
275
+ }, timeoutMs);
276
+ }
277
+
278
+ // Set up abort handler
279
+ const abortHandler = (): void => {
280
+ if (timer) {
281
+ clearTimeout(timer);
282
+ }
283
+ this.pending.delete(messageId);
284
+ this.timedOutRequests.mark(messageId);
285
+ reject(new BridgeTimeoutError('Operation aborted'));
286
+ };
287
+
288
+ if (signal) {
289
+ signal.addEventListener('abort', abortHandler, { once: true });
290
+ }
291
+
292
+ // Wrap resolve/reject to clean up abort listener
293
+ const wrappedResolve = (value: string): void => {
294
+ if (timer) {
295
+ clearTimeout(timer);
296
+ }
297
+ signal?.removeEventListener('abort', abortHandler);
298
+ resolve(value);
299
+ };
300
+
301
+ const wrappedReject = (error: Error): void => {
302
+ if (timer) {
303
+ clearTimeout(timer);
304
+ }
305
+ signal?.removeEventListener('abort', abortHandler);
306
+ reject(error);
307
+ };
308
+
309
+ // Register pending request
310
+ this.pending.set(messageId, {
311
+ resolve: wrappedResolve,
312
+ reject: wrappedReject,
313
+ timer,
314
+ });
315
+
316
+ // Write message to stdin
317
+ this.writeToStdin(`${message}\n`).catch(err => {
318
+ this.pending.delete(messageId);
319
+ if (timer) {
320
+ clearTimeout(timer);
321
+ }
322
+ signal?.removeEventListener('abort', abortHandler);
323
+ reject(this.classifyError(err));
324
+ });
325
+
326
+ this.requestCount++;
327
+ });
328
+ }
329
+
330
+ // ===========================================================================
331
+ // BOUNDED CONTEXT LIFECYCLE
332
+ // ===========================================================================
333
+
334
+ /**
335
+ * Initialize the transport by spawning the Python process.
336
+ */
337
+ protected async doInit(): Promise<void> {
338
+ await this.spawnProcess();
339
+ }
340
+
341
+ /**
342
+ * Dispose the transport by killing the Python process.
343
+ */
344
+ protected async doDispose(): Promise<void> {
345
+ // Reject all pending requests
346
+ const stderrTail = this.getStderrTail();
347
+ const msg = stderrTail ? `Transport disposed. Stderr:\n${stderrTail}` : 'Transport disposed';
348
+ const error = new BridgeDisposedError(msg);
349
+
350
+ for (const [, pending] of this.pending) {
351
+ if (pending.timer) {
352
+ clearTimeout(pending.timer);
353
+ }
354
+ pending.reject(error);
355
+ }
356
+ this.pending.clear();
357
+
358
+ // Clear write queue
359
+ for (const queued of this.writeQueue) {
360
+ this.clearQueuedWriteTimeout(queued);
361
+ queued.reject(error);
362
+ }
363
+ this.writeQueue.length = 0;
364
+
365
+ // Kill process
366
+ await this.killProcess();
367
+
368
+ // Clear buffers
369
+ this.stdoutBuffer = '';
370
+ this.stderrBuffer = '';
371
+ this.timedOutRequests.clear();
372
+ this.requestCount = 0;
373
+ }
374
+
375
+ // ===========================================================================
376
+ // ABSTRACT METHOD STUBS (from BoundedContext)
377
+ // ===========================================================================
378
+
379
+ /**
380
+ * Not implemented - ProcessIO is a transport, not a full bridge.
381
+ */
382
+ call<T = unknown>(
383
+ _module: string,
384
+ _functionName: string,
385
+ _args: unknown[],
386
+ _kwargs?: Record<string, unknown>
387
+ ): Promise<T> {
388
+ throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
389
+ }
390
+
391
+ /**
392
+ * Not implemented - ProcessIO is a transport, not a full bridge.
393
+ */
394
+ instantiate<T = unknown>(
395
+ _module: string,
396
+ _className: string,
397
+ _args: unknown[],
398
+ _kwargs?: Record<string, unknown>
399
+ ): Promise<T> {
400
+ throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
401
+ }
402
+
403
+ /**
404
+ * Not implemented - ProcessIO is a transport, not a full bridge.
405
+ */
406
+ callMethod<T = unknown>(
407
+ _handle: string,
408
+ _methodName: string,
409
+ _args: unknown[],
410
+ _kwargs?: Record<string, unknown>
411
+ ): Promise<T> {
412
+ throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
413
+ }
414
+
415
+ /**
416
+ * Not implemented - ProcessIO is a transport, not a full bridge.
417
+ */
418
+ disposeInstance(_handle: string): Promise<void> {
419
+ throw new BridgeExecutionError('ProcessIO is a transport, use BridgeProtocol for operations');
420
+ }
421
+
422
+ // ===========================================================================
423
+ // PROCESS MANAGEMENT
424
+ // ===========================================================================
425
+
426
+ /**
427
+ * Spawn the Python subprocess.
428
+ */
429
+ private async spawnProcess(): Promise<void> {
430
+ // Build environment - use provided env or inherit from process.env
431
+ // If env is provided, it should be the complete environment (already filtered by NodeBridge)
432
+ // We only add Python-specific variables on top
433
+ const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
434
+ const env: NodeJS.ProcessEnv = {
435
+ ...baseEnv,
436
+ // Ensure Python uses UTF-8
437
+ PYTHONUTF8: '1',
438
+ PYTHONIOENCODING: 'UTF-8',
439
+ // Disable Python buffering
440
+ PYTHONUNBUFFERED: '1',
441
+ };
442
+
443
+ // Spawn process
444
+ this.process = spawn(this.pythonPath, [this.bridgeScript], {
445
+ stdio: ['pipe', 'pipe', 'pipe'],
446
+ env,
447
+ cwd: this.cwd,
448
+ });
449
+
450
+ this.processExited = false;
451
+ this.processError = null;
452
+
453
+ // Set up event handlers
454
+ this.process.on('error', this.handleProcessError.bind(this));
455
+ this.process.on('exit', this.handleProcessExit.bind(this));
456
+
457
+ if (this.process.stdout) {
458
+ this.process.stdout.on('data', this.handleStdoutData.bind(this));
459
+ this.process.stdout.on('error', this.handleStdoutError.bind(this));
460
+ }
461
+
462
+ if (this.process.stderr) {
463
+ this.process.stderr.on('data', this.handleStderrData.bind(this));
464
+ this.process.stderr.on('error', this.handleStderrError.bind(this));
465
+ }
466
+
467
+ if (this.process.stdin) {
468
+ this.process.stdin.on('drain', this.handleStdinDrain.bind(this));
469
+ this.process.stdin.on('error', this.handleStdinError.bind(this));
470
+ }
471
+
472
+ // Wait for process to be ready (first heartbeat could be here)
473
+ // For now, just resolve immediately - the process is spawned
474
+ await Promise.resolve();
475
+ }
476
+
477
+ /**
478
+ * Kill the Python subprocess.
479
+ */
480
+ private async killProcess(): Promise<void> {
481
+ if (!this.process) {
482
+ return;
483
+ }
484
+
485
+ const proc = this.process;
486
+ this.process = null;
487
+
488
+ // Add a catch-all error handler to prevent uncaught exceptions during shutdown
489
+ // This must be added BEFORE removing other listeners and ending stdin
490
+ const noopErrorHandler = (): void => {
491
+ // Ignore errors during shutdown (e.g., EPIPE)
492
+ };
493
+ proc.stdin?.on('error', noopErrorHandler);
494
+ proc.on('error', noopErrorHandler);
495
+
496
+ // Gracefully end stdin to prevent EPIPE on pending writes
497
+ try {
498
+ proc.stdin?.end();
499
+ } catch {
500
+ // Ignore errors ending stdin
501
+ }
502
+
503
+ // Remove other listeners to prevent callbacks after disposal
504
+ proc.removeAllListeners('exit');
505
+ proc.removeAllListeners('close');
506
+ proc.stdout?.removeAllListeners();
507
+ proc.stderr?.removeAllListeners();
508
+
509
+ // Kill the process
510
+ if (!proc.killed) {
511
+ proc.kill('SIGTERM');
512
+
513
+ // Wait briefly for graceful exit
514
+ await new Promise<void>(resolve => {
515
+ const timeout = setTimeout(() => {
516
+ if (!proc.killed) {
517
+ proc.kill('SIGKILL');
518
+ }
519
+ resolve();
520
+ }, 1000);
521
+
522
+ proc.once('exit', () => {
523
+ clearTimeout(timeout);
524
+ resolve();
525
+ });
526
+ });
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Restart the Python process.
532
+ */
533
+ private async restartProcess(): Promise<void> {
534
+ // Kill existing process
535
+ await this.killProcess();
536
+
537
+ // Clear buffers and restart flags
538
+ this.stdoutBuffer = '';
539
+ this.stderrBuffer = '';
540
+ this.requestCount = 0;
541
+ this.needsRestart = false;
542
+
543
+ // Spawn new process
544
+ await this.spawnProcess();
545
+ }
546
+
547
+ /**
548
+ * Mark the process for restart on the next send.
549
+ * This is called after stream errors to ensure the next request uses a fresh process.
550
+ * Works independently of restartAfterRequests setting.
551
+ */
552
+ private markForRestart(): void {
553
+ this.needsRestart = true;
554
+ }
555
+
556
+ // ===========================================================================
557
+ // STREAM HANDLERS
558
+ // ===========================================================================
559
+
560
+ /**
561
+ * Handle stdout data from the Python process.
562
+ */
563
+ private handleStdoutData(chunk: Buffer | string): void {
564
+ this.stdoutBuffer += chunk.toString();
565
+
566
+ // Check for excessive line length without newline
567
+ if (this.stdoutBuffer.length > this.maxLineLength && !this.stdoutBuffer.includes('\n')) {
568
+ const snippet = this.stdoutBuffer.slice(0, 500);
569
+ this.stdoutBuffer = '';
570
+ this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
571
+ return;
572
+ }
573
+
574
+ // Process complete lines
575
+ let newlineIndex: number;
576
+ while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
577
+ const line = this.stdoutBuffer.slice(0, newlineIndex);
578
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
579
+
580
+ // Skip empty lines
581
+ if (!line.trim()) {
582
+ continue;
583
+ }
584
+
585
+ // Check line length
586
+ if (line.length > this.maxLineLength) {
587
+ const snippet = line.slice(0, 500);
588
+ this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
589
+ return;
590
+ }
591
+
592
+ this.handleResponseLine(line);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Handle a complete response line from stdout.
598
+ */
599
+ private handleResponseLine(line: string): void {
600
+ // Extract ID to find pending request
601
+ const messageId = extractMessageId(line);
602
+ if (messageId === null) {
603
+ this.handleProtocolError('Response missing "id" field', line);
604
+ return;
605
+ }
606
+
607
+ const pending = this.pending.get(messageId);
608
+ if (!pending) {
609
+ // Ignore expected late responses from timed-out/cancelled requests.
610
+ if (this.timedOutRequests.consume(messageId)) {
611
+ return;
612
+ }
613
+ // Unknown IDs while requests are pending indicate protocol desync.
614
+ this.handleProtocolError(`Unexpected response id ${messageId}`, line);
615
+ return;
616
+ }
617
+
618
+ // Remove from pending
619
+ this.pending.delete(messageId);
620
+
621
+ // Clear timeout
622
+ if (pending.timer) {
623
+ clearTimeout(pending.timer);
624
+ }
625
+
626
+ // Resolve with raw response
627
+ pending.resolve(line);
628
+ }
629
+
630
+ /**
631
+ * Handle stderr data from the Python process.
632
+ */
633
+ private handleStderrData(chunk: Buffer | string): void {
634
+ try {
635
+ this.stderrBuffer += sanitizeStderr(chunk.toString());
636
+
637
+ // Keep only the tail
638
+ if (this.stderrBuffer.length > MAX_STDERR_BYTES) {
639
+ this.stderrBuffer = this.stderrBuffer.slice(this.stderrBuffer.length - MAX_STDERR_BYTES);
640
+ }
641
+ } catch {
642
+ // Ignore stderr buffering errors
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Handle process error event.
648
+ */
649
+ private handleProcessError(err: Error): void {
650
+ this.processError = err;
651
+ this.processExited = true;
652
+
653
+ const msg = `Python process error: ${err.message}`;
654
+ const error = new BridgeProtocolError(msg);
655
+
656
+ this.rejectAllPending(error);
657
+ }
658
+
659
+ /**
660
+ * Handle process exit event.
661
+ */
662
+ private handleProcessExit(code: number | null, signal: string | null): void {
663
+ this.processExited = true;
664
+
665
+ const stderrTail = this.getStderrTail();
666
+ let msg: string;
667
+
668
+ if (signal) {
669
+ msg = `Python process killed by signal ${signal}`;
670
+ } else if (code !== null && code !== 0) {
671
+ msg = `Python process exited with code ${code}`;
672
+ } else {
673
+ msg = 'Python process exited';
674
+ }
675
+
676
+ if (stderrTail) {
677
+ msg += `. Stderr:\n${stderrTail}`;
678
+ }
679
+
680
+ const error = new BridgeProtocolError(msg);
681
+ this.rejectAllPending(error);
682
+ }
683
+
684
+ /**
685
+ * Handle stdin drain event (backpressure relief).
686
+ */
687
+ private handleStdinDrain(): void {
688
+ this.draining = false;
689
+ this.flushWriteQueue();
690
+ }
691
+
692
+ /**
693
+ * Handle stdin error event.
694
+ */
695
+ private handleStdinError(err: Error): void {
696
+ // EPIPE means process died
697
+ const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
698
+
699
+ // Reject all pending writes
700
+ for (const queued of this.writeQueue) {
701
+ this.clearQueuedWriteTimeout(queued);
702
+ queued.reject(error);
703
+ }
704
+ this.writeQueue.length = 0;
705
+
706
+ // Reject all pending requests
707
+ this.rejectAllPending(error);
708
+
709
+ // Mark for restart on next send
710
+ this.markForRestart();
711
+ }
712
+
713
+ /**
714
+ * Handle stdout error event.
715
+ * This can occur during pipe errors or when the process crashes.
716
+ */
717
+ private handleStdoutError(err: Error): void {
718
+ const error = new BridgeProtocolError(this.withStderrTail(`stdout error: ${err.message}`));
719
+ this.rejectAllPending(error);
720
+ this.markForRestart();
721
+ }
722
+
723
+ /**
724
+ * Handle stderr error event.
725
+ * This can occur during pipe errors or when the process crashes.
726
+ */
727
+ private handleStderrError(err: Error): void {
728
+ // Stderr errors are less critical but still indicate process health issues
729
+ const error = new BridgeProtocolError(this.withStderrTail(`stderr error: ${err.message}`));
730
+ this.rejectAllPending(error);
731
+ this.markForRestart();
732
+ }
733
+
734
+ // ===========================================================================
735
+ // WRITE MANAGEMENT
736
+ // ===========================================================================
737
+
738
+ /**
739
+ * Create a queued write entry with a timeout timer.
740
+ * The timer fires if the drain event never comes.
741
+ */
742
+ private createQueuedWrite(
743
+ data: string,
744
+ resolve: () => void,
745
+ reject: (error: Error) => void
746
+ ): QueuedWrite {
747
+ const queuedAt = Date.now();
748
+ const entry: QueuedWrite = { data, resolve, reject, queuedAt };
749
+
750
+ // Set up timeout timer that fires if drain never happens
751
+ entry.timeoutHandle = setTimeout(() => {
752
+ // Remove this entry from the queue
753
+ const index = this.writeQueue.indexOf(entry);
754
+ if (index !== -1) {
755
+ this.writeQueue.splice(index, 1);
756
+ reject(
757
+ new BridgeTimeoutError(
758
+ `Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`
759
+ )
760
+ );
761
+ }
762
+ }, this.writeQueueTimeoutMs);
763
+
764
+ // Unref the timer so it doesn't keep the process alive (best-effort for non-Node runtimes)
765
+ if (typeof entry.timeoutHandle.unref === 'function') {
766
+ entry.timeoutHandle.unref();
767
+ }
768
+
769
+ return entry;
770
+ }
771
+
772
+ /**
773
+ * Clear the timeout for a queued write entry.
774
+ */
775
+ private clearQueuedWriteTimeout(entry: QueuedWrite): void {
776
+ if (entry.timeoutHandle) {
777
+ clearTimeout(entry.timeoutHandle);
778
+ entry.timeoutHandle = undefined;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Write data to stdin with backpressure handling.
784
+ */
785
+ private writeToStdin(data: string): Promise<void> {
786
+ return new Promise<void>((resolve, reject) => {
787
+ if (!this.process?.stdin || this.processExited) {
788
+ reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
789
+ return;
790
+ }
791
+
792
+ if (this.draining || this.writeQueue.length > 0) {
793
+ // Queue the write with timestamp and timeout timer
794
+ this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
795
+ return;
796
+ }
797
+
798
+ // Try direct write (wrap in try-catch for synchronous EPIPE errors)
799
+ try {
800
+ const canWrite = this.process.stdin.write(data);
801
+
802
+ if (canWrite) {
803
+ resolve();
804
+ } else {
805
+ // Backpressure - current write is accepted by Node's stream buffer.
806
+ // We only pause subsequent writes until "drain".
807
+ this.draining = true;
808
+ resolve();
809
+ }
810
+ } catch (err) {
811
+ // Synchronous write error (e.g., EPIPE)
812
+ this.markForRestart();
813
+ const errorMessage = err instanceof Error ? err.message : 'unknown';
814
+ reject(new BridgeProtocolError(this.withStderrTail(`Write error: ${errorMessage}`)));
815
+ }
816
+ });
817
+ }
818
+
819
+ /**
820
+ * Flush queued writes when backpressure clears.
821
+ */
822
+ private flushWriteQueue(): void {
823
+ const now = Date.now();
824
+
825
+ while (this.writeQueue.length > 0 && !this.draining) {
826
+ if (!this.process?.stdin || this.processExited) {
827
+ // Process died - reject all queued writes
828
+ for (const q of this.writeQueue) {
829
+ this.clearQueuedWriteTimeout(q);
830
+ q.reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
831
+ }
832
+ this.writeQueue.length = 0;
833
+ this.markForRestart();
834
+ return;
835
+ }
836
+
837
+ const queued = this.writeQueue.shift();
838
+ if (!queued) {
839
+ return;
840
+ }
841
+
842
+ // Clear the timeout since we're processing this entry now
843
+ this.clearQueuedWriteTimeout(queued);
844
+
845
+ // Check for write queue timeout (fallback check, timer should have handled this)
846
+ if (now - queued.queuedAt > this.writeQueueTimeoutMs) {
847
+ queued.reject(
848
+ new BridgeTimeoutError(
849
+ `Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`
850
+ )
851
+ );
852
+ continue; // Process next entry
853
+ }
854
+
855
+ try {
856
+ const canWrite = this.process.stdin.write(queued.data);
857
+
858
+ if (canWrite) {
859
+ queued.resolve();
860
+ } else {
861
+ // Backpressure - this write has been accepted by stream buffer.
862
+ // Pause further queued writes until the next "drain" event.
863
+ queued.resolve();
864
+ this.draining = true;
865
+ return;
866
+ }
867
+ } catch (err) {
868
+ // Synchronous write error (e.g., EPIPE) - reject this and all remaining writes
869
+ const errorMessage = err instanceof Error ? err.message : 'unknown';
870
+ const error = new BridgeProtocolError(this.withStderrTail(`Write error: ${errorMessage}`));
871
+ queued.reject(error);
872
+ for (const q of this.writeQueue) {
873
+ this.clearQueuedWriteTimeout(q);
874
+ q.reject(error);
875
+ }
876
+ this.writeQueue.length = 0;
877
+ this.markForRestart();
878
+ return;
879
+ }
880
+ }
881
+ }
882
+
883
+ // ===========================================================================
884
+ // HELPERS
885
+ // ===========================================================================
886
+
887
+ /**
888
+ * Get the tail of stderr for diagnostics.
889
+ */
890
+ private getStderrTail(): string {
891
+ return this.stderrBuffer.trim();
892
+ }
893
+
894
+ /**
895
+ * Append stderr context when available.
896
+ */
897
+ private withStderrTail(message: string): string {
898
+ const stderrTail = this.getStderrTail();
899
+ return stderrTail ? `${message}. Stderr:\n${stderrTail}` : message;
900
+ }
901
+
902
+ /**
903
+ * Handle a protocol error by rejecting all pending requests.
904
+ */
905
+ private handleProtocolError(details: string, line?: string): void {
906
+ const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
907
+
908
+ const hint = 'Ensure Python code does not print to stdout and bridge outputs only JSON lines.';
909
+
910
+ const msg = snippet
911
+ ? `Protocol error: ${details}\n${hint}\nOffending line: ${snippet}`
912
+ : `Protocol error: ${details}\n${hint}`;
913
+
914
+ const error = new BridgeProtocolError(msg);
915
+ this.rejectAllPending(error);
916
+ }
917
+
918
+ /**
919
+ * Reject all pending requests with an error.
920
+ */
921
+ private rejectAllPending(error: Error): void {
922
+ for (const [, pending] of this.pending) {
923
+ if (pending.timer) {
924
+ clearTimeout(pending.timer);
925
+ }
926
+ pending.reject(error);
927
+ }
928
+ this.pending.clear();
929
+ }
930
+ }