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