tywrap 0.1.2 → 0.2.1

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