tywrap 0.7.0 → 0.9.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 (165) hide show
  1. package/README.md +36 -11
  2. package/SECURITY.md +39 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/emit-call.d.ts.map +1 -1
  11. package/dist/core/emit-call.js +1 -1
  12. package/dist/core/emit-call.js.map +1 -1
  13. package/dist/core/generator.d.ts +23 -4
  14. package/dist/core/generator.d.ts.map +1 -1
  15. package/dist/core/generator.js +243 -170
  16. package/dist/core/generator.js.map +1 -1
  17. package/dist/core/mapper.d.ts +3 -2
  18. package/dist/core/mapper.d.ts.map +1 -1
  19. package/dist/core/mapper.js +5 -5
  20. package/dist/core/mapper.js.map +1 -1
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +1 -3
  23. package/dist/dev.js.map +1 -1
  24. package/dist/index.d.ts +7 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/base-bridge.d.ts +3 -7
  29. package/dist/runtime/base-bridge.d.ts.map +1 -1
  30. package/dist/runtime/base-bridge.js +4 -17
  31. package/dist/runtime/base-bridge.js.map +1 -1
  32. package/dist/runtime/bounded-context.d.ts +3 -22
  33. package/dist/runtime/bounded-context.d.ts.map +1 -1
  34. package/dist/runtime/bounded-context.js +13 -53
  35. package/dist/runtime/bounded-context.js.map +1 -1
  36. package/dist/runtime/bridge-codec.d.ts +1 -1
  37. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  38. package/dist/runtime/bridge-codec.js +78 -55
  39. package/dist/runtime/bridge-codec.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +16 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +17 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/frame-codec.d.ts +111 -0
  45. package/dist/runtime/frame-codec.d.ts.map +1 -0
  46. package/dist/runtime/frame-codec.js +352 -0
  47. package/dist/runtime/frame-codec.js.map +1 -0
  48. package/dist/runtime/http-transport.d.ts +1 -1
  49. package/dist/runtime/http-transport.d.ts.map +1 -1
  50. package/dist/runtime/http-transport.js +1 -1
  51. package/dist/runtime/http-transport.js.map +1 -1
  52. package/dist/runtime/index.d.ts +2 -1
  53. package/dist/runtime/index.d.ts.map +1 -1
  54. package/dist/runtime/index.js +2 -1
  55. package/dist/runtime/index.js.map +1 -1
  56. package/dist/runtime/node.d.ts +7 -23
  57. package/dist/runtime/node.d.ts.map +1 -1
  58. package/dist/runtime/node.js +5 -78
  59. package/dist/runtime/node.js.map +1 -1
  60. package/dist/runtime/pooled-transport.d.ts +120 -59
  61. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  62. package/dist/runtime/pooled-transport.js +345 -78
  63. package/dist/runtime/pooled-transport.js.map +1 -1
  64. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  65. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  66. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  67. package/dist/runtime/pyodide-transport.d.ts +1 -1
  68. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  69. package/dist/runtime/pyodide-transport.js +2 -3
  70. package/dist/runtime/pyodide-transport.js.map +1 -1
  71. package/dist/runtime/rpc-client.d.ts +7 -36
  72. package/dist/runtime/rpc-client.d.ts.map +1 -1
  73. package/dist/runtime/rpc-client.js +37 -72
  74. package/dist/runtime/rpc-client.js.map +1 -1
  75. package/dist/runtime/subprocess-transport.d.ts +134 -8
  76. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  77. package/dist/runtime/subprocess-transport.js +490 -65
  78. package/dist/runtime/subprocess-transport.js.map +1 -1
  79. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  80. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  81. package/dist/runtime/transport.d.ts +90 -18
  82. package/dist/runtime/transport.d.ts.map +1 -1
  83. package/dist/runtime/transport.js +21 -1
  84. package/dist/runtime/transport.js.map +1 -1
  85. package/dist/runtime/validators.d.ts +49 -0
  86. package/dist/runtime/validators.d.ts.map +1 -1
  87. package/dist/runtime/validators.js +152 -0
  88. package/dist/runtime/validators.js.map +1 -1
  89. package/dist/types/index.d.ts +14 -15
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/tywrap.d.ts +3 -2
  92. package/dist/tywrap.d.ts.map +1 -1
  93. package/dist/tywrap.js +140 -21
  94. package/dist/tywrap.js.map +1 -1
  95. package/dist/utils/cache.d.ts +3 -16
  96. package/dist/utils/cache.d.ts.map +1 -1
  97. package/dist/utils/codec.d.ts +1 -0
  98. package/dist/utils/codec.d.ts.map +1 -1
  99. package/dist/utils/codec.js +165 -17
  100. package/dist/utils/codec.js.map +1 -1
  101. package/dist/utils/ir-cache.d.ts +2 -1
  102. package/dist/utils/ir-cache.d.ts.map +1 -1
  103. package/dist/utils/runtime.d.ts +0 -29
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +16 -107
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +7 -6
  109. package/runtime/frame_codec.py +430 -0
  110. package/runtime/python_bridge.py +213 -54
  111. package/runtime/tywrap_bridge_core.py +217 -147
  112. package/src/config/index.ts +11 -0
  113. package/src/core/annotation-parser.ts +9 -5
  114. package/src/core/emit-call.ts +1 -7
  115. package/src/core/generator.ts +315 -205
  116. package/src/core/mapper.ts +8 -8
  117. package/src/dev.ts +1 -3
  118. package/src/index.ts +7 -3
  119. package/src/runtime/base-bridge.ts +5 -30
  120. package/src/runtime/bounded-context.ts +12 -67
  121. package/src/runtime/bridge-codec.ts +94 -65
  122. package/src/runtime/errors.ts +21 -0
  123. package/src/runtime/frame-codec.ts +469 -0
  124. package/src/runtime/http-transport.ts +6 -1
  125. package/src/runtime/index.ts +7 -6
  126. package/src/runtime/node.ts +17 -104
  127. package/src/runtime/pooled-transport.ts +424 -90
  128. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  129. package/src/runtime/pyodide-transport.ts +7 -3
  130. package/src/runtime/rpc-client.ts +58 -93
  131. package/src/runtime/subprocess-transport.ts +585 -80
  132. package/src/runtime/timed-out-request-tracker.ts +1 -1
  133. package/src/runtime/transport.ts +112 -22
  134. package/src/runtime/validators.ts +204 -0
  135. package/src/types/index.ts +21 -35
  136. package/src/tywrap.ts +157 -30
  137. package/src/utils/cache.ts +3 -3
  138. package/src/utils/codec.ts +205 -16
  139. package/src/utils/ir-cache.ts +1 -1
  140. package/src/utils/runtime.ts +17 -128
  141. package/src/version.ts +1 -1
  142. package/dist/core/discovery.d.ts +0 -103
  143. package/dist/core/discovery.d.ts.map +0 -1
  144. package/dist/core/discovery.js +0 -380
  145. package/dist/core/discovery.js.map +0 -1
  146. package/dist/core/validation.d.ts +0 -102
  147. package/dist/core/validation.d.ts.map +0 -1
  148. package/dist/core/validation.js +0 -490
  149. package/dist/core/validation.js.map +0 -1
  150. package/dist/runtime/base.d.ts +0 -22
  151. package/dist/runtime/base.d.ts.map +0 -1
  152. package/dist/runtime/base.js +0 -23
  153. package/dist/runtime/base.js.map +0 -1
  154. package/dist/runtime/transport-pool.d.ts +0 -196
  155. package/dist/runtime/transport-pool.d.ts.map +0 -1
  156. package/dist/runtime/transport-pool.js +0 -418
  157. package/dist/runtime/transport-pool.js.map +0 -1
  158. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  159. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  160. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  161. package/runtime/safe_codec.py +0 -352
  162. package/src/core/discovery.ts +0 -477
  163. package/src/core/validation.ts +0 -729
  164. package/src/runtime/base.ts +0 -24
  165. package/src/runtime/transport-pool.ts +0 -538
@@ -17,8 +17,9 @@ import { spawn, type ChildProcess } from 'child_process';
17
17
  import type { Writable } from 'stream';
18
18
  import { DisposableBase } from './bounded-context.js';
19
19
  import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
20
+ import { Reassembler, encodeFrames, utf8ByteLength } from './frame-codec.js';
20
21
  import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
21
- import type { Transport, TransportCapabilities } from './transport.js';
22
+ import { type Transport, type TransportCapabilities } from './transport.js';
22
23
 
23
24
  // =============================================================================
24
25
  // CONSTANTS
@@ -36,6 +37,34 @@ const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
36
37
  /** Track timed-out/cancelled request IDs long enough to ignore late responses. */
37
38
  const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
38
39
 
40
+ /**
41
+ * Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
42
+ * when sizing the frame-aware stdout line ceiling.
43
+ *
44
+ * A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
45
+ * "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
46
+ * "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
47
+ * plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
48
+ * upper bound that never under-allocates.
49
+ */
50
+ const FRAME_ENVELOPE_HEADROOM = 1024;
51
+
52
+ /**
53
+ * Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
54
+ * fragment of a JSON response (already-escaped, printable content), so realistic
55
+ * expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
56
+ * ceiling sound without over-allocating buffer headroom.
57
+ */
58
+ const FRAME_DATA_ESCAPE_FACTOR = 2;
59
+
60
+ /**
61
+ * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
62
+ * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
63
+ * buffers more than the codec would ultimately accept; `NodeBridge` overrides it
64
+ * with the configured `codec.maxPayloadBytes`.
65
+ */
66
+ const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
67
+
39
68
  /** Regex for ANSI escape sequences */
40
69
  const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
41
70
 
@@ -70,6 +99,15 @@ export interface SubprocessTransportOptions {
70
99
 
71
100
  /** Write queue timeout in milliseconds. Default: 30000ms */
72
101
  writeQueueTimeoutMs?: number;
102
+
103
+ /**
104
+ * Cap (UTF-8 bytes) on a single chunked RESPONSE reassembled in memory. A
105
+ * frame stream whose declared or accumulated payload exceeds this fails loud
106
+ * instead of buffering, so chunking cannot OOM the process before the codec's
107
+ * payload cap rejects the result. Should track the codec's `maxPayloadBytes`.
108
+ * Default: 10 MiB (matches the codec default).
109
+ */
110
+ maxReassemblyBytes?: number;
73
111
  }
74
112
 
75
113
  /**
@@ -92,6 +130,15 @@ interface QueuedWrite {
92
130
  queuedAt: number;
93
131
  /** Timeout handle for write queue timeout */
94
132
  timeoutHandle?: NodeJS.Timeout;
133
+ /**
134
+ * Liveness predicate. If present and `false` at flush time, the write is
135
+ * SKIPPED (resolved as a no-op) instead of sent — so a request that timed out
136
+ * or aborted while its write sat in the stdin backpressure queue never reaches
137
+ * (and so never executes on) Python.
138
+ */
139
+ isLive?: () => boolean;
140
+ /** True while this entry is waiting to be flushed. */
141
+ isQueued?: boolean;
95
142
  }
96
143
 
97
144
  // =============================================================================
@@ -129,6 +176,36 @@ function extractMessageId(json: string): number | null {
129
176
  return id;
130
177
  }
131
178
 
179
+ /**
180
+ * Result of inspecting a stdout line for `tywrap-frame/1` framing.
181
+ * - `{ kind: 'frame', value }`: the line parsed as JSON carrying a
182
+ * `__tywrap_frame__` marker (handed to the reassembler, which validates it).
183
+ * - `{ kind: 'plain' }`: valid JSON without a framing marker (a normal,
184
+ * single-line response).
185
+ * - `{ kind: 'invalid' }`: not parseable as JSON.
186
+ */
187
+ type FrameLineProbe = { kind: 'frame'; value: unknown } | { kind: 'plain' } | { kind: 'invalid' };
188
+
189
+ /**
190
+ * Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
191
+ *
192
+ * A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
193
+ * envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
194
+ * by the {@link Reassembler}, not here — this only routes the line.
195
+ */
196
+ function probeFrameLine(line: string): FrameLineProbe {
197
+ let parsed: unknown;
198
+ try {
199
+ parsed = JSON.parse(line);
200
+ } catch {
201
+ return { kind: 'invalid' };
202
+ }
203
+ if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
204
+ return { kind: 'frame', value: parsed };
205
+ }
206
+ return { kind: 'plain' };
207
+ }
208
+
132
209
  // =============================================================================
133
210
  // PROCESS IO TRANSPORT
134
211
  // =============================================================================
@@ -165,9 +242,24 @@ export class SubprocessTransport extends DisposableBase implements Transport {
165
242
  private readonly envOverrides: Record<string, string>;
166
243
  private readonly cwd: string | undefined;
167
244
  private readonly maxLineLength: number;
245
+ private readonly maxReassemblyBytes: number;
168
246
  private readonly restartAfterRequests: number;
169
247
  private readonly writeQueueTimeoutMs: number;
170
248
 
249
+ /**
250
+ * Per-frame UTF-8 data ceiling. The packaged Python peer receives this
251
+ * transport's `maxLineLength` when it is spawned, so request and response
252
+ * framing use the same fixed ceiling without runtime negotiation.
253
+ */
254
+ private readonly frameBytes: number;
255
+
256
+ /**
257
+ * Reassembles `tywrap-frame/1` response frames into single logical response
258
+ * lines. Per-id discard tracks
259
+ * timed-out/aborted streams so late frames cannot desync stdout.
260
+ */
261
+ private responseReassembler: Reassembler | null = null;
262
+
171
263
  // Process state
172
264
  private process: ChildProcess | null = null;
173
265
  private processExited = false;
@@ -175,6 +267,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
175
267
 
176
268
  // Stream buffers
177
269
  private stdoutBuffer = '';
270
+ private stdoutScanOffset = 0;
178
271
  private stderrBuffer = '';
179
272
 
180
273
  // Request tracking
@@ -184,11 +277,27 @@ export class SubprocessTransport extends DisposableBase implements Transport {
184
277
  });
185
278
  private requestCount = 0;
186
279
  private needsRestart = false;
280
+ /** Serializes restart decisions with dispatch reservations. */
281
+ private dispatchMutex: Promise<void> = Promise.resolve();
282
+ private activeDispatches = 0;
283
+ private readonly dispatchDrainWaiters: Array<() => void> = [];
187
284
 
188
285
  // Write queue for backpressure
189
286
  private readonly writeQueue: QueuedWrite[] = [];
287
+ private writeQueueHead = 0;
190
288
  private draining = false;
191
289
 
290
+ /**
291
+ * Per-logical-request write mutex (W5). When a request is chunked into
292
+ * `tywrap-frame/1` frames, all of that request's frames must reach stdin
293
+ * contiguously — no other request's frame (or single line) may interleave —
294
+ * or the Python reassembler would see frames from two ids mixed on one stream.
295
+ * `writeChunkedRequest` chains the whole frame burst onto this tail; the
296
+ * single-line write path also serializes behind it so a small request issued
297
+ * concurrently never slips between another request's frames.
298
+ */
299
+ private writeMutex: Promise<void> = Promise.resolve();
300
+
192
301
  /**
193
302
  * Create a new SubprocessTransport.
194
303
  *
@@ -204,6 +313,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
204
313
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
205
314
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
206
315
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
316
+ this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
317
+ this.frameBytes = this.maxLineLength;
318
+ this.responseReassembler = new Reassembler({
319
+ maxReassemblyBytes: this.maxReassemblyBytes,
320
+ expectedStream: 'response',
321
+ });
207
322
  }
208
323
 
209
324
  // ===========================================================================
@@ -218,7 +333,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
218
333
  * @param signal - Optional AbortSignal for cancellation
219
334
  * @returns The raw JSON response string
220
335
  */
221
- async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
336
+ async send(
337
+ message: string,
338
+ timeoutMs: number,
339
+ signal?: AbortSignal,
340
+ requestId?: number
341
+ ): Promise<string> {
222
342
  // Check disposed state
223
343
  if (this.isDisposed) {
224
344
  throw new BridgeDisposedError('Transport has been disposed');
@@ -243,27 +363,50 @@ export class SubprocessTransport extends DisposableBase implements Transport {
243
363
  }
244
364
 
245
365
  // Extract message ID for response correlation
246
- const messageId = extractMessageId(message);
366
+ const messageId = requestId ?? extractMessageId(message);
247
367
  if (messageId === null) {
248
368
  throw new BridgeProtocolError('Message must contain an "id" field');
249
369
  }
250
370
 
251
- // Check for restart condition (either scheduled restart or forced by stream error)
252
- if (
253
- this.needsRestart ||
254
- (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)
255
- ) {
256
- await this.restartProcess();
257
- }
371
+ let ownsDispatchReservation = false;
372
+ let reservationReleased = false;
373
+ const releaseOwnedReservation = (): void => {
374
+ if (ownsDispatchReservation && !reservationReleased) {
375
+ reservationReleased = true;
376
+ this.releaseDispatch();
377
+ }
378
+ };
258
379
 
259
380
  // Create promise for response
260
- return new Promise<string>((resolve, reject) => {
381
+ const response = new Promise<string>((resolve, reject) => {
261
382
  // Set up timeout if specified
262
383
  let timer: NodeJS.Timeout | undefined;
384
+
385
+ // Defined before the timer so the timeout path can also detach it.
386
+ const abortHandler = (): void => {
387
+ if (timer) {
388
+ clearTimeout(timer);
389
+ }
390
+ this.pending.delete(messageId);
391
+ this.timedOutRequests.mark(messageId);
392
+ // Same late-frame discard as the timeout path (see below).
393
+ this.responseReassembler?.discard(messageId);
394
+ reject(new BridgeTimeoutError('Operation aborted'));
395
+ };
396
+
263
397
  if (timeoutMs > 0) {
264
398
  timer = setTimeout(() => {
265
399
  this.pending.delete(messageId);
266
400
  this.timedOutRequests.mark(messageId);
401
+ // Discard any in-flight chunked response stream for this id so late
402
+ // frames are dropped rather than desyncing stdout (the single-line
403
+ // timedOutRequests.consume above is one-shot and insufficient for a
404
+ // multi-frame stream).
405
+ this.responseReassembler?.discard(messageId);
406
+ // Detach the abort listener: on timeout the abort never fires, so
407
+ // without this it would leak on a long-lived AbortSignal and could
408
+ // re-enter abortHandler after this promise has already settled.
409
+ signal?.removeEventListener('abort', abortHandler);
267
410
  const stderrTail = this.getStderrTail();
268
411
  const baseMsg = `Operation timed out after ${timeoutMs}ms`;
269
412
  const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
@@ -271,16 +414,6 @@ export class SubprocessTransport extends DisposableBase implements Transport {
271
414
  }, timeoutMs);
272
415
  }
273
416
 
274
- // Set up abort handler
275
- const abortHandler = (): void => {
276
- if (timer) {
277
- clearTimeout(timer);
278
- }
279
- this.pending.delete(messageId);
280
- this.timedOutRequests.mark(messageId);
281
- reject(new BridgeTimeoutError('Operation aborted'));
282
- };
283
-
284
417
  if (signal) {
285
418
  signal.addEventListener('abort', abortHandler, { once: true });
286
419
  }
@@ -302,41 +435,130 @@ export class SubprocessTransport extends DisposableBase implements Transport {
302
435
  reject(error);
303
436
  };
304
437
 
305
- // Register pending request
306
- this.pending.set(messageId, {
438
+ // Register pending request. Captured by reference so the write path can
439
+ // bind liveness to THIS exact entry (not just the id) — see writeRequest.
440
+ const pendingEntry: PendingRequest = {
307
441
  resolve: wrappedResolve,
308
442
  reject: wrappedReject,
309
443
  timer,
310
- });
444
+ };
445
+ this.pending.set(messageId, pendingEntry);
446
+
447
+ // Write message to stdin. When the encoded request exceeds the per-frame
448
+ // ceiling, it is fragmented into
449
+ // `tywrap-frame/1` request frames written contiguously under the write
450
+ // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
451
+ // serialize through the same mutex so a small request can never slip
452
+ // between another request's frames.
453
+ this.reserveDispatch(() => this.pending.get(messageId) === pendingEntry && !signal?.aborted)
454
+ .then(reserved => {
455
+ if (!reserved) {
456
+ return;
457
+ }
458
+ ownsDispatchReservation = true;
459
+ // Timeout/abort may have settled while this reservation waited behind
460
+ // a generation restart. Do not write the abandoned request, and release
461
+ // only the reservation this send actually acquired.
462
+ if (this.pending.get(messageId) !== pendingEntry) {
463
+ releaseOwnedReservation();
464
+ return;
465
+ }
466
+ return this.writeRequest(message, messageId, signal, pendingEntry);
467
+ })
468
+ .catch(err => {
469
+ this.pending.delete(messageId);
470
+ if (timer) {
471
+ clearTimeout(timer);
472
+ }
473
+ signal?.removeEventListener('abort', abortHandler);
474
+ reject(this.classifyError(err));
475
+ });
476
+ });
477
+ return response.finally(releaseOwnedReservation);
478
+ }
311
479
 
312
- // Write message to stdin
313
- this.writeToStdin(`${message}\n`).catch(err => {
314
- this.pending.delete(messageId);
315
- if (timer) {
316
- clearTimeout(timer);
317
- }
318
- signal?.removeEventListener('abort', abortHandler);
319
- reject(this.classifyError(err));
320
- });
480
+ /**
481
+ * Atomically reserve the current process generation for one request. A
482
+ * threshold restart waits for every request reserved on the old generation
483
+ * to settle before killing it; later callers remain queued behind this lock.
484
+ */
485
+ private async withDispatchMutex<T>(operation: () => Promise<T>): Promise<T> {
486
+ let unlock!: () => void;
487
+ const previous = this.dispatchMutex;
488
+ this.dispatchMutex = new Promise<void>(resolve => {
489
+ unlock = resolve;
490
+ });
491
+ await previous;
492
+ try {
493
+ return await operation();
494
+ } finally {
495
+ unlock();
496
+ }
497
+ }
321
498
 
499
+ private async reserveDispatch(isLive: () => boolean): Promise<boolean> {
500
+ return this.withDispatchMutex(async () => {
501
+ // Cancellation may win while this request waits behind an earlier
502
+ // generation. It must consume neither a request slot nor a restart.
503
+ if (!isLive()) {
504
+ return false;
505
+ }
506
+ if (this.isLifecycleEnding()) {
507
+ throw new BridgeDisposedError('Transport has been disposed');
508
+ }
509
+ if (
510
+ this.needsRestart ||
511
+ (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)
512
+ ) {
513
+ if (this.activeDispatches > 0) {
514
+ await new Promise<void>(resolve => this.dispatchDrainWaiters.push(resolve));
515
+ }
516
+ // The owner or transport can settle while waiting for the old
517
+ // generation to drain. Do not restart for work that no longer exists.
518
+ if (!isLive()) {
519
+ return false;
520
+ }
521
+ if (this.isLifecycleEnding()) {
522
+ throw new BridgeDisposedError('Transport has been disposed');
523
+ }
524
+ await this.restartProcess();
525
+ }
526
+ this.activeDispatches++;
322
527
  this.requestCount++;
528
+ return true;
323
529
  });
324
530
  }
325
531
 
532
+ private releaseDispatch(): void {
533
+ this.activeDispatches = Math.max(0, this.activeDispatches - 1);
534
+ if (this.activeDispatches === 0) {
535
+ for (const resolve of this.dispatchDrainWaiters.splice(0)) {
536
+ resolve();
537
+ }
538
+ }
539
+ }
540
+
541
+ private isLifecycleEnding(): boolean {
542
+ return this.state === 'disposing' || this.isDisposed;
543
+ }
544
+
326
545
  /**
327
546
  * Static capability descriptor for the subprocess backend.
328
547
  *
329
- * Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes) over the
330
- * JSONL stream. Chunking/streaming are not implemented (0.8.0). `maxFrameBytes`
331
- * is the configured JSONL line-length limit the largest single response line
332
- * this transport will accept before raising a protocol error.
548
+ * Per the {@link Transport.capabilities} contract this is lifecycle-independent
549
+ * (safe before `init()` / after `dispose()`) and never makes a Python round
550
+ * trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
551
+ * over the JSONL stream. `supportsChunking` is always `true` because the npm
552
+ * package ships both `tywrap-frame/1` peers. `supportsStreaming` stays `false`.
553
+ * `maxFrameBytes` is the fixed per-frame data ceiling supplied to the Python
554
+ * bridge at spawn time.
333
555
  */
334
556
  capabilities(): TransportCapabilities {
335
557
  return {
336
558
  backend: 'subprocess',
337
559
  supportsArrow: true,
338
560
  supportsBinary: true,
339
- supportsChunking: false,
561
+ supportsChunking: true,
340
562
  supportsStreaming: false,
341
563
  maxFrameBytes: this.maxLineLength,
342
564
  };
@@ -347,7 +569,8 @@ export class SubprocessTransport extends DisposableBase implements Transport {
347
569
  // ===========================================================================
348
570
 
349
571
  /**
350
- * Initialize the transport by spawning the Python process.
572
+ * Initialize the transport by spawning the Python process. The packaged
573
+ * bridge always speaks `tywrap-frame/1`.
351
574
  */
352
575
  protected async doInit(): Promise<void> {
353
576
  await this.spawnProcess();
@@ -371,20 +594,26 @@ export class SubprocessTransport extends DisposableBase implements Transport {
371
594
  this.pending.clear();
372
595
 
373
596
  // Clear write queue
374
- for (const queued of this.writeQueue) {
375
- this.clearQueuedWriteTimeout(queued);
376
- queued.reject(error);
377
- }
378
- this.writeQueue.length = 0;
597
+ this.rejectAllQueuedWrites(error);
379
598
 
380
- // Kill process
381
- await this.killProcess();
599
+ // Fence queued restart work before killing the process. A restart already
600
+ // holding the mutex observes the disposing state before it can spawn; if it
601
+ // spawned just before disposal began, this barrier kills that child too.
602
+ await this.withDispatchMutex(() => this.killProcess());
382
603
 
383
604
  // Clear buffers
384
605
  this.stdoutBuffer = '';
606
+ this.stdoutScanOffset = 0;
385
607
  this.stderrBuffer = '';
386
608
  this.timedOutRequests.clear();
387
609
  this.requestCount = 0;
610
+ this.responseReassembler = new Reassembler({
611
+ maxReassemblyBytes: this.maxReassemblyBytes,
612
+ expectedStream: 'response',
613
+ });
614
+ // Reset the per-request write mutex so a disposed transport starts from a
615
+ // clean (resolved) tail if reused.
616
+ this.writeMutex = Promise.resolve();
388
617
  }
389
618
 
390
619
  // ===========================================================================
@@ -399,6 +628,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
399
628
  // If env is provided, it should be the complete environment (already filtered by NodeBridge)
400
629
  // We only add Python-specific variables on top
401
630
  const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
631
+
632
+ // Advertise `tywrap-frame/1` chunked transport so the bridge fragments
633
+ // oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
634
+ // caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
635
+ // (see the frame-aware line ceiling) allows for the JSON envelope + escaping
636
+ // on top of it. Spread (rather than dynamic index assignment) keeps the
637
+ // computed keys off ESLint's object-injection sink. See
638
+ // docs/transport-framing.md.
402
639
  const env: NodeJS.ProcessEnv = {
403
640
  ...baseEnv,
404
641
  // Ensure Python uses UTF-8
@@ -409,11 +646,15 @@ export class SubprocessTransport extends DisposableBase implements Transport {
409
646
  };
410
647
 
411
648
  // Spawn process
412
- this.process = spawn(this.pythonPath, [this.bridgeScript], {
413
- stdio: ['pipe', 'pipe', 'pipe'],
414
- env,
415
- cwd: this.cwd,
416
- });
649
+ this.process = spawn(
650
+ this.pythonPath,
651
+ [this.bridgeScript, '--tywrap-max-frame-bytes', String(this.frameBytes)],
652
+ {
653
+ stdio: ['pipe', 'pipe', 'pipe'],
654
+ env,
655
+ cwd: this.cwd,
656
+ }
657
+ );
417
658
 
418
659
  this.processExited = false;
419
660
  this.processError = null;
@@ -499,16 +740,37 @@ export class SubprocessTransport extends DisposableBase implements Transport {
499
740
  * Restart the Python process.
500
741
  */
501
742
  private async restartProcess(): Promise<void> {
743
+ if (this.isLifecycleEnding()) {
744
+ throw new BridgeDisposedError('Transport has been disposed');
745
+ }
746
+
502
747
  // Kill existing process
503
748
  await this.killProcess();
504
749
 
505
750
  // Clear buffers and restart flags
506
751
  this.stdoutBuffer = '';
752
+ this.stdoutScanOffset = 0;
507
753
  this.stderrBuffer = '';
508
754
  this.requestCount = 0;
509
755
  this.needsRestart = false;
756
+ // Drop any partial reassembly + discard tracking: the new process owns a
757
+ // fresh stdout stream, so stale per-id state from the dead process must not
758
+ // leak across the restart boundary.
759
+ this.responseReassembler = new Reassembler({
760
+ maxReassemblyBytes: this.maxReassemblyBytes,
761
+ expectedStream: 'response',
762
+ });
763
+ // The new process owns a fresh stdin stream; reset the write mutex so a
764
+ // pending frame burst against the dead process cannot serialize behind it.
765
+ this.writeMutex = Promise.resolve();
766
+
767
+ // Disposal may begin while the old child is being killed. Never publish a
768
+ // replacement after lifecycle teardown has started.
769
+ if (this.isLifecycleEnding()) {
770
+ throw new BridgeDisposedError('Transport has been disposed');
771
+ }
510
772
 
511
- // Spawn new process
773
+ // Spawn a fresh process with the packaged always-on framing protocol.
512
774
  await this.spawnProcess();
513
775
  }
514
776
 
@@ -525,46 +787,81 @@ export class SubprocessTransport extends DisposableBase implements Transport {
525
787
  // STREAM HANDLERS
526
788
  // ===========================================================================
527
789
 
790
+ /**
791
+ * Effective stdout line ceiling.
792
+ *
793
+ * A single wire line may be a `tywrap-frame/1` envelope whose
794
+ * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
795
+ * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
796
+ * to bound that overhead so a legitimate frame line is never rejected while a
797
+ * runaway/garbage line still is.
798
+ */
799
+ private effectiveLineCeiling(): number {
800
+ return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
801
+ }
802
+
528
803
  /**
529
804
  * Handle stdout data from the Python process.
530
805
  */
531
806
  private handleStdoutData(chunk: Buffer | string): void {
532
807
  this.stdoutBuffer += chunk.toString();
533
808
 
534
- // Check for excessive line length without newline
535
- if (this.stdoutBuffer.length > this.maxLineLength && !this.stdoutBuffer.includes('\n')) {
809
+ const ceiling = this.effectiveLineCeiling();
810
+
811
+ // Scan only bytes newly appended since the last incomplete line.
812
+ let newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
813
+ if (this.stdoutBuffer.length > ceiling && newlineIndex === -1) {
536
814
  const snippet = this.stdoutBuffer.slice(0, 500);
537
815
  this.stdoutBuffer = '';
538
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
816
+ this.stdoutScanOffset = 0;
817
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
539
818
  return;
540
819
  }
541
820
 
542
821
  // Process complete lines
543
- let newlineIndex: number;
544
- while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
822
+ while (newlineIndex !== -1) {
545
823
  const line = this.stdoutBuffer.slice(0, newlineIndex);
546
824
  this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
825
+ this.stdoutScanOffset = 0;
547
826
 
548
827
  // Skip empty lines
549
828
  if (!line.trim()) {
829
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
550
830
  continue;
551
831
  }
552
832
 
553
833
  // Check line length
554
- if (line.length > this.maxLineLength) {
834
+ if (line.length > ceiling) {
555
835
  const snippet = line.slice(0, 500);
556
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
836
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
557
837
  return;
558
838
  }
559
839
 
560
840
  this.handleResponseLine(line);
841
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
561
842
  }
843
+ this.stdoutScanOffset = this.stdoutBuffer.length;
562
844
  }
563
845
 
564
846
  /**
565
847
  * Handle a complete response line from stdout.
848
+ *
849
+ * A line may be a `tywrap-frame/1` envelope: it is
850
+ * routed into the per-id {@link Reassembler}, which returns the reassembled
851
+ * logical response only once the stream is complete and valid. Single-line
852
+ * (non-frame) responses keep the original fast path unchanged.
566
853
  */
567
854
  private handleResponseLine(line: string): void {
855
+ if (this.responseReassembler) {
856
+ const probe = probeFrameLine(line);
857
+ if (probe.kind === 'frame') {
858
+ this.handleResponseFrame(probe.value, line);
859
+ return;
860
+ }
861
+ // probe.kind 'plain'/'invalid' falls through to the legacy single-line
862
+ // path below, which extracts the id and validates JSON as before.
863
+ }
864
+
568
865
  // Extract ID to find pending request
569
866
  const messageId = extractMessageId(line);
570
867
  if (messageId === null) {
@@ -595,6 +892,75 @@ export class SubprocessTransport extends DisposableBase implements Transport {
595
892
  pending.resolve(line);
596
893
  }
597
894
 
895
+ /**
896
+ * Route one `tywrap-frame/1` response frame into the reassembler.
897
+ *
898
+ * On completion the reassembled logical line resolves the pending request. The
899
+ * reassembler validates structure, ordering, byte count, and UTF-8 internally
900
+ * and throws on any framing violation (malformed/duplicate/byte-mismatch/
901
+ * unknown-protocol) — those reject the pending id and mark the subprocess for
902
+ * restart, since stdout can no longer be trusted to be frame-aligned. Frames
903
+ * for a timed-out/aborted id are silently discarded by the reassembler (it
904
+ * returns `null` and tracks the discard set) so late multi-frame responses
905
+ * cannot desync the stream.
906
+ */
907
+ private handleResponseFrame(rawFrame: unknown, line: string): void {
908
+ const reassembler = this.responseReassembler;
909
+ if (!reassembler) {
910
+ // Unreachable: the always-on framing path constructs the reassembler.
911
+ this.handleProtocolError('Received frame with no reassembler', line);
912
+ return;
913
+ }
914
+
915
+ const frameId = (rawFrame as { id?: unknown }).id;
916
+
917
+ let reassembled: string | null;
918
+ try {
919
+ reassembled = reassembler.accept(rawFrame);
920
+ } catch (err) {
921
+ // Framing corruption: stdout is no longer frame-aligned. Reject the
922
+ // correlated pending request (if any) and force a restart.
923
+ const message = err instanceof Error ? err.message : String(err);
924
+ this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
925
+ this.markForRestart();
926
+ return;
927
+ }
928
+
929
+ if (reassembled === null) {
930
+ // More frames needed, or this frame belonged to a discarded (timed-out)
931
+ // id and was dropped. Either way: nothing to resolve yet.
932
+ return;
933
+ }
934
+
935
+ // Stream complete: resolve the correlated pending request with the single
936
+ // logical response line. extractMessageId is reused so the resolution path
937
+ // matches the non-chunked case exactly.
938
+ this.handleResponseLine(reassembled);
939
+ }
940
+
941
+ /**
942
+ * Reject the pending request correlated to a frame id, if one exists.
943
+ *
944
+ * Used when frame reassembly throws. If the id is unknown (e.g. it already
945
+ * timed out and was dropped from `pending`), the error still surfaces as a
946
+ * protocol error so the desync is not swallowed silently.
947
+ */
948
+ private rejectFrameId(frameId: unknown, details: string, line: string): void {
949
+ if (typeof frameId === 'number' && Number.isInteger(frameId)) {
950
+ const pending = this.pending.get(frameId);
951
+ if (pending) {
952
+ this.pending.delete(frameId);
953
+ if (pending.timer) {
954
+ clearTimeout(pending.timer);
955
+ }
956
+ pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
957
+ return;
958
+ }
959
+ }
960
+ // No correlated pending request: still a protocol-level desync.
961
+ this.handleProtocolError(details, line);
962
+ }
963
+
598
964
  /**
599
965
  * Handle stderr data from the Python process.
600
966
  */
@@ -665,11 +1031,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
665
1031
  const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
666
1032
 
667
1033
  // Reject all pending writes
668
- for (const queued of this.writeQueue) {
669
- this.clearQueuedWriteTimeout(queued);
670
- queued.reject(error);
671
- }
672
- this.writeQueue.length = 0;
1034
+ this.rejectAllQueuedWrites(error);
673
1035
 
674
1036
  // Reject all pending requests
675
1037
  this.rejectAllPending(error);
@@ -710,17 +1072,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
710
1072
  private createQueuedWrite(
711
1073
  data: string,
712
1074
  resolve: () => void,
713
- reject: (error: Error) => void
1075
+ reject: (error: Error) => void,
1076
+ isLive?: () => boolean
714
1077
  ): QueuedWrite {
715
1078
  const queuedAt = Date.now();
716
- const entry: QueuedWrite = { data, resolve, reject, queuedAt };
1079
+ const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive, isQueued: true };
717
1080
 
718
1081
  // Set up timeout timer that fires if drain never happens
719
1082
  entry.timeoutHandle = setTimeout(() => {
720
- // Remove this entry from the queue
721
- const index = this.writeQueue.indexOf(entry);
722
- if (index !== -1) {
723
- this.writeQueue.splice(index, 1);
1083
+ if (entry.isQueued) {
1084
+ entry.isQueued = false;
724
1085
  reject(
725
1086
  new BridgeTimeoutError(
726
1087
  `Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`
@@ -750,16 +1111,24 @@ export class SubprocessTransport extends DisposableBase implements Transport {
750
1111
  /**
751
1112
  * Write data to stdin with backpressure handling.
752
1113
  */
753
- private writeToStdin(data: string): Promise<void> {
1114
+ private writeToStdin(data: string, isLive?: () => boolean): Promise<void> {
754
1115
  return new Promise<void>((resolve, reject) => {
755
1116
  if (!this.process?.stdin || this.processExited) {
756
1117
  reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
757
1118
  return;
758
1119
  }
759
1120
 
760
- if (this.draining || this.writeQueue.length > 0) {
761
- // Queue the write with timestamp and timeout timer
762
- this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
1121
+ if (this.draining || this.writeQueue.length > this.writeQueueHead) {
1122
+ // Queue the write with timestamp, timeout timer, and liveness predicate
1123
+ // (checked again at flush — see processQueuedWrite).
1124
+ this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
1125
+ return;
1126
+ }
1127
+
1128
+ // Skip a write whose request was abandoned (timed out/aborted) before it
1129
+ // reached stdin — never execute an operation the caller gave up on.
1130
+ if (isLive && !isLive()) {
1131
+ resolve();
763
1132
  return;
764
1133
  }
765
1134
 
@@ -784,16 +1153,124 @@ export class SubprocessTransport extends DisposableBase implements Transport {
784
1153
  });
785
1154
  }
786
1155
 
1156
+ /**
1157
+ * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
1158
+ * request frames when the encoded request exceeds
1159
+ * the per-frame ceiling (W5 — the mirror of W4's response chunking).
1160
+ *
1161
+ * Both the chunked and single-line paths run under {@link writeMutex} so a
1162
+ * logical request's bytes (one line, or a burst of frames) reach stdin
1163
+ * contiguously: a small request issued concurrently can never interleave
1164
+ * between another request's frames, which would desync the Python
1165
+ * reassembler (it correlates frames by id, but the JSONL stream itself must
1166
+ * stay frame-aligned). The mutex tail is advanced regardless of success so a
1167
+ * failed write never wedges every subsequent request.
1168
+ *
1169
+ * @param message - the encoded logical JSON request (no trailing newline)
1170
+ * @param messageId - the request's correlation id (already validated integer)
1171
+ * @param signal - optional abort signal; an abort observed between frames
1172
+ * stops further frames and rejects this send (the pending entry is rejected
1173
+ * by the abort handler / the caller's `.catch`).
1174
+ */
1175
+ private writeRequest(
1176
+ message: string,
1177
+ messageId: number,
1178
+ signal?: AbortSignal,
1179
+ pendingEntry?: PendingRequest
1180
+ ): Promise<void> {
1181
+ // The request is "live" only while THIS send's exact pending entry is still
1182
+ // registered (the timeout and abort handlers delete it) and the signal is not
1183
+ // aborted. Binding to the entry IDENTITY — not just the id — closes the
1184
+ // id-reuse hole: if the id is recycled by a later send while this write is
1185
+ // still queued, the stale write sees a different entry and is skipped.
1186
+ // (RpcClient ids are monotonic, but Transport.send is public and does not
1187
+ // enforce id uniqueness.) Gating EVERY write point on this — the run closure,
1188
+ // the direct stdin write, the backpressure-queue flush, and each chunked
1189
+ // frame — prevents an abandoned request from executing on Python, even one
1190
+ // whose write sat queued under backpressure past the cancellation.
1191
+ const isLive = (): boolean =>
1192
+ !signal?.aborted &&
1193
+ (pendingEntry !== undefined
1194
+ ? this.pending.get(messageId) === pendingEntry
1195
+ : this.pending.has(messageId));
1196
+ const run = (): Promise<void> => {
1197
+ if (!isLive()) {
1198
+ return Promise.resolve();
1199
+ }
1200
+ // Chunk when the encoded request exceeds the fixed per-frame ceiling
1201
+ // supplied to the packaged Python peer. Otherwise use one JSONL line.
1202
+ if (utf8ByteLength(message) > this.frameBytes) {
1203
+ return this.writeChunkedRequest(message, messageId, signal, isLive);
1204
+ }
1205
+ return this.writeToStdin(`${message}\n`, isLive);
1206
+ };
1207
+
1208
+ // Serialize the whole logical write onto the mutex tail. We chain the next
1209
+ // tail off the settled (caught) result so one failed/aborted write does not
1210
+ // poison the chain for later requests.
1211
+ const result = this.writeMutex.then(run);
1212
+ this.writeMutex = result.then(
1213
+ () => undefined,
1214
+ () => undefined
1215
+ );
1216
+ return result;
1217
+ }
1218
+
1219
+ /**
1220
+ * Fragment a logical request into `tywrap-frame/1` request frames and write
1221
+ * them contiguously (one JSONL line per frame). Runs while holding the write
1222
+ * mutex (see {@link writeRequest}), so no other write interleaves.
1223
+ *
1224
+ * Each frame is awaited in turn so backpressure on stdin is respected. Before
1225
+ * every frame the abort signal and process liveness are re-checked: an abort
1226
+ * mid-burst stops the remaining frames and rejects the send LOUD (the Python
1227
+ * reassembler drops the now-incomplete id when the next request's frames /
1228
+ * timeout arrive, exactly as the response side handles a discarded id).
1229
+ */
1230
+ private async writeChunkedRequest(
1231
+ message: string,
1232
+ messageId: number,
1233
+ signal?: AbortSignal,
1234
+ isLive?: () => boolean
1235
+ ): Promise<void> {
1236
+ const frames = encodeFrames(message, {
1237
+ id: messageId,
1238
+ stream: 'request',
1239
+ maxFrameBytes: this.frameBytes,
1240
+ });
1241
+
1242
+ for (const frame of frames) {
1243
+ // Stop the burst if the caller aborted, the request was abandoned (timed
1244
+ // out -> pending deleted, caught by isLive), or the process died between
1245
+ // frames. A partial request stream is dropped by the Python reassembler.
1246
+ if (signal?.aborted || (isLive && !isLive())) {
1247
+ throw new BridgeTimeoutError('Operation aborted');
1248
+ }
1249
+ if (this.processExited || !this.process) {
1250
+ throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
1251
+ }
1252
+ // One frame per JSONL line; await each so stdin backpressure is honored.
1253
+ // isLive gates a frame that ends up queued under backpressure past a late
1254
+ // cancellation, so an abandoned chunked request never completes on Python.
1255
+ await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
1256
+ }
1257
+ }
1258
+
787
1259
  /**
788
1260
  * Reject and clear every entry currently in the write queue.
789
1261
  * Clears each entry's timeout before rejecting so no late timer fires.
790
1262
  */
791
1263
  private rejectAllQueuedWrites(error: Error): void {
792
- for (const q of this.writeQueue) {
793
- this.clearQueuedWriteTimeout(q);
794
- q.reject(error);
1264
+ for (let index = this.writeQueueHead; index < this.writeQueue.length; index += 1) {
1265
+ const queued = this.writeQueue[index];
1266
+ if (queued?.isQueued) {
1267
+ queued.isQueued = false;
1268
+ this.clearQueuedWriteTimeout(queued);
1269
+ queued.reject(error);
1270
+ }
795
1271
  }
796
1272
  this.writeQueue.length = 0;
1273
+ this.writeQueueHead = 0;
797
1274
  }
798
1275
 
799
1276
  /**
@@ -821,6 +1298,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
821
1298
  return 'continue';
822
1299
  }
823
1300
 
1301
+ // Skip the write if the request was abandoned (timed out/aborted) while it
1302
+ // sat in the backpressure queue — never execute an operation the caller gave
1303
+ // up on. Resolve as a no-op so the mutex chain stays healthy.
1304
+ if (queued.isLive && !queued.isLive()) {
1305
+ queued.resolve();
1306
+ return 'continue';
1307
+ }
1308
+
824
1309
  try {
825
1310
  const canWrite = stdin.write(queued.data);
826
1311
 
@@ -851,7 +1336,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
851
1336
  private flushWriteQueue(): void {
852
1337
  const now = Date.now();
853
1338
 
854
- while (this.writeQueue.length > 0 && !this.draining) {
1339
+ while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
855
1340
  const stdin = this.process?.stdin;
856
1341
  if (!stdin || this.processExited) {
857
1342
  // Process died - reject all queued writes
@@ -862,11 +1347,17 @@ export class SubprocessTransport extends DisposableBase implements Transport {
862
1347
  return;
863
1348
  }
864
1349
 
865
- const queued = this.writeQueue.shift();
1350
+ const queued = this.writeQueue[this.writeQueueHead];
1351
+ this.writeQueueHead += 1;
866
1352
  if (!queued) {
867
1353
  return;
868
1354
  }
869
1355
 
1356
+ if (!queued.isQueued) {
1357
+ continue;
1358
+ }
1359
+ queued.isQueued = false;
1360
+
870
1361
  // Clear the timeout since we're processing this entry now
871
1362
  this.clearQueuedWriteTimeout(queued);
872
1363
 
@@ -875,6 +1366,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
875
1366
  return;
876
1367
  }
877
1368
  }
1369
+
1370
+ if (this.writeQueueHead === this.writeQueue.length) {
1371
+ this.writeQueue.length = 0;
1372
+ this.writeQueueHead = 0;
1373
+ }
878
1374
  }
879
1375
 
880
1376
  // ===========================================================================
@@ -897,7 +1393,15 @@ export class SubprocessTransport extends DisposableBase implements Transport {
897
1393
  }
898
1394
 
899
1395
  /**
900
- * Handle a protocol error by rejecting all pending requests.
1396
+ * Handle a protocol error by rejecting all pending requests and marking the
1397
+ * subprocess for restart.
1398
+ *
1399
+ * Every caller represents genuine stdout-stream corruption (a too-long line, a
1400
+ * response with no `id`, a frame with no reassembler, or a truly unexpected id
1401
+ * — benign late responses from timed-out requests are already filtered upstream
1402
+ * via {@link timedOutRequests}). After such an error stdout can no longer be
1403
+ * trusted to be line/frame-aligned, so the process is marked for restart —
1404
+ * matching the frame-reassembly-corruption path and the framing spec.
901
1405
  */
902
1406
  private handleProtocolError(details: string, line?: string): void {
903
1407
  const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
@@ -910,6 +1414,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
910
1414
 
911
1415
  const error = new BridgeProtocolError(msg);
912
1416
  this.rejectAllPending(error);
1417
+ this.markForRestart();
913
1418
  }
914
1419
 
915
1420
  /**