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