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
@@ -15,6 +15,7 @@
15
15
  import { spawn } from 'child_process';
16
16
  import { DisposableBase } from './bounded-context.js';
17
17
  import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
18
+ import { Reassembler, encodeFrames, utf8ByteLength } from './frame-codec.js';
18
19
  import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
19
20
  // =============================================================================
20
21
  // CONSTANTS
@@ -27,6 +28,31 @@ const MAX_STDERR_BYTES = 8 * 1024;
27
28
  const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
28
29
  /** Track timed-out/cancelled request IDs long enough to ignore late responses. */
29
30
  const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
31
+ /**
32
+ * Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
33
+ * when sizing the frame-aware stdout line ceiling.
34
+ *
35
+ * A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
36
+ * "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
37
+ * "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
38
+ * plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
39
+ * upper bound that never under-allocates.
40
+ */
41
+ const FRAME_ENVELOPE_HEADROOM = 1024;
42
+ /**
43
+ * Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
44
+ * fragment of a JSON response (already-escaped, printable content), so realistic
45
+ * expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
46
+ * ceiling sound without over-allocating buffer headroom.
47
+ */
48
+ const FRAME_DATA_ESCAPE_FACTOR = 2;
49
+ /**
50
+ * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
51
+ * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
52
+ * buffers more than the codec would ultimately accept; `NodeBridge` overrides it
53
+ * with the configured `codec.maxPayloadBytes`.
54
+ */
55
+ const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
30
56
  /** Regex for ANSI escape sequences */
31
57
  const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
32
58
  /** Regex for control characters */
@@ -61,6 +87,26 @@ function extractMessageId(json) {
61
87
  }
62
88
  return id;
63
89
  }
90
+ /**
91
+ * Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
92
+ *
93
+ * A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
94
+ * envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
95
+ * by the {@link Reassembler}, not here — this only routes the line.
96
+ */
97
+ function probeFrameLine(line) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(line);
101
+ }
102
+ catch {
103
+ return { kind: 'invalid' };
104
+ }
105
+ if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
106
+ return { kind: 'frame', value: parsed };
107
+ }
108
+ return { kind: 'plain' };
109
+ }
64
110
  // =============================================================================
65
111
  // PROCESS IO TRANSPORT
66
112
  // =============================================================================
@@ -96,14 +142,28 @@ export class SubprocessTransport extends DisposableBase {
96
142
  envOverrides;
97
143
  cwd;
98
144
  maxLineLength;
145
+ maxReassemblyBytes;
99
146
  restartAfterRequests;
100
147
  writeQueueTimeoutMs;
148
+ /**
149
+ * Per-frame UTF-8 data ceiling. The packaged Python peer receives this
150
+ * transport's `maxLineLength` when it is spawned, so request and response
151
+ * framing use the same fixed ceiling without runtime negotiation.
152
+ */
153
+ frameBytes;
154
+ /**
155
+ * Reassembles `tywrap-frame/1` response frames into single logical response
156
+ * lines. Per-id discard tracks
157
+ * timed-out/aborted streams so late frames cannot desync stdout.
158
+ */
159
+ responseReassembler = null;
101
160
  // Process state
102
161
  process = null;
103
162
  processExited = false;
104
163
  processError = null;
105
164
  // Stream buffers
106
165
  stdoutBuffer = '';
166
+ stdoutScanOffset = 0;
107
167
  stderrBuffer = '';
108
168
  // Request tracking
109
169
  pending = new Map();
@@ -112,9 +172,24 @@ export class SubprocessTransport extends DisposableBase {
112
172
  });
113
173
  requestCount = 0;
114
174
  needsRestart = false;
175
+ /** Serializes restart decisions with dispatch reservations. */
176
+ dispatchMutex = Promise.resolve();
177
+ activeDispatches = 0;
178
+ dispatchDrainWaiters = [];
115
179
  // Write queue for backpressure
116
180
  writeQueue = [];
181
+ writeQueueHead = 0;
117
182
  draining = false;
183
+ /**
184
+ * Per-logical-request write mutex (W5). When a request is chunked into
185
+ * `tywrap-frame/1` frames, all of that request's frames must reach stdin
186
+ * contiguously — no other request's frame (or single line) may interleave —
187
+ * or the Python reassembler would see frames from two ids mixed on one stream.
188
+ * `writeChunkedRequest` chains the whole frame burst onto this tail; the
189
+ * single-line write path also serializes behind it so a small request issued
190
+ * concurrently never slips between another request's frames.
191
+ */
192
+ writeMutex = Promise.resolve();
118
193
  /**
119
194
  * Create a new SubprocessTransport.
120
195
  *
@@ -129,6 +204,12 @@ export class SubprocessTransport extends DisposableBase {
129
204
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
130
205
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
131
206
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
207
+ this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
208
+ this.frameBytes = this.maxLineLength;
209
+ this.responseReassembler = new Reassembler({
210
+ maxReassemblyBytes: this.maxReassemblyBytes,
211
+ expectedStream: 'response',
212
+ });
132
213
  }
133
214
  // ===========================================================================
134
215
  // TRANSPORT INTERFACE
@@ -141,7 +222,7 @@ export class SubprocessTransport extends DisposableBase {
141
222
  * @param signal - Optional AbortSignal for cancellation
142
223
  * @returns The raw JSON response string
143
224
  */
144
- async send(message, timeoutMs, signal) {
225
+ async send(message, timeoutMs, signal, requestId) {
145
226
  // Check disposed state
146
227
  if (this.isDisposed) {
147
228
  throw new BridgeDisposedError('Transport has been disposed');
@@ -162,38 +243,52 @@ export class SubprocessTransport extends DisposableBase {
162
243
  throw new BridgeTimeoutError('Operation aborted');
163
244
  }
164
245
  // Extract message ID for response correlation
165
- const messageId = extractMessageId(message);
246
+ const messageId = requestId ?? extractMessageId(message);
166
247
  if (messageId === null) {
167
248
  throw new BridgeProtocolError('Message must contain an "id" field');
168
249
  }
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
- }
250
+ let ownsDispatchReservation = false;
251
+ let reservationReleased = false;
252
+ const releaseOwnedReservation = () => {
253
+ if (ownsDispatchReservation && !reservationReleased) {
254
+ reservationReleased = true;
255
+ this.releaseDispatch();
256
+ }
257
+ };
174
258
  // Create promise for response
175
- return new Promise((resolve, reject) => {
259
+ const response = new Promise((resolve, reject) => {
176
260
  // Set up timeout if specified
177
261
  let timer;
262
+ // Defined before the timer so the timeout path can also detach it.
263
+ const abortHandler = () => {
264
+ if (timer) {
265
+ clearTimeout(timer);
266
+ }
267
+ this.pending.delete(messageId);
268
+ this.timedOutRequests.mark(messageId);
269
+ // Same late-frame discard as the timeout path (see below).
270
+ this.responseReassembler?.discard(messageId);
271
+ reject(new BridgeTimeoutError('Operation aborted'));
272
+ };
178
273
  if (timeoutMs > 0) {
179
274
  timer = setTimeout(() => {
180
275
  this.pending.delete(messageId);
181
276
  this.timedOutRequests.mark(messageId);
277
+ // Discard any in-flight chunked response stream for this id so late
278
+ // frames are dropped rather than desyncing stdout (the single-line
279
+ // timedOutRequests.consume above is one-shot and insufficient for a
280
+ // multi-frame stream).
281
+ this.responseReassembler?.discard(messageId);
282
+ // Detach the abort listener: on timeout the abort never fires, so
283
+ // without this it would leak on a long-lived AbortSignal and could
284
+ // re-enter abortHandler after this promise has already settled.
285
+ signal?.removeEventListener('abort', abortHandler);
182
286
  const stderrTail = this.getStderrTail();
183
287
  const baseMsg = `Operation timed out after ${timeoutMs}ms`;
184
288
  const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
185
289
  reject(new BridgeTimeoutError(msg));
186
290
  }, timeoutMs);
187
291
  }
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
292
  if (signal) {
198
293
  signal.addEventListener('abort', abortHandler, { once: true });
199
294
  }
@@ -212,14 +307,36 @@ export class SubprocessTransport extends DisposableBase {
212
307
  signal?.removeEventListener('abort', abortHandler);
213
308
  reject(error);
214
309
  };
215
- // Register pending request
216
- this.pending.set(messageId, {
310
+ // Register pending request. Captured by reference so the write path can
311
+ // bind liveness to THIS exact entry (not just the id) — see writeRequest.
312
+ const pendingEntry = {
217
313
  resolve: wrappedResolve,
218
314
  reject: wrappedReject,
219
315
  timer,
220
- });
221
- // Write message to stdin
222
- this.writeToStdin(`${message}\n`).catch(err => {
316
+ };
317
+ this.pending.set(messageId, pendingEntry);
318
+ // Write message to stdin. When the encoded request exceeds the per-frame
319
+ // ceiling, it is fragmented into
320
+ // `tywrap-frame/1` request frames written contiguously under the write
321
+ // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
322
+ // serialize through the same mutex so a small request can never slip
323
+ // between another request's frames.
324
+ this.reserveDispatch(() => this.pending.get(messageId) === pendingEntry && !signal?.aborted)
325
+ .then(reserved => {
326
+ if (!reserved) {
327
+ return;
328
+ }
329
+ ownsDispatchReservation = true;
330
+ // Timeout/abort may have settled while this reservation waited behind
331
+ // a generation restart. Do not write the abandoned request, and release
332
+ // only the reservation this send actually acquired.
333
+ if (this.pending.get(messageId) !== pendingEntry) {
334
+ releaseOwnedReservation();
335
+ return;
336
+ }
337
+ return this.writeRequest(message, messageId, signal, pendingEntry);
338
+ })
339
+ .catch(err => {
223
340
  this.pending.delete(messageId);
224
341
  if (timer) {
225
342
  clearTimeout(timer);
@@ -227,23 +344,86 @@ export class SubprocessTransport extends DisposableBase {
227
344
  signal?.removeEventListener('abort', abortHandler);
228
345
  reject(this.classifyError(err));
229
346
  });
347
+ });
348
+ return response.finally(releaseOwnedReservation);
349
+ }
350
+ /**
351
+ * Atomically reserve the current process generation for one request. A
352
+ * threshold restart waits for every request reserved on the old generation
353
+ * to settle before killing it; later callers remain queued behind this lock.
354
+ */
355
+ async withDispatchMutex(operation) {
356
+ let unlock;
357
+ const previous = this.dispatchMutex;
358
+ this.dispatchMutex = new Promise(resolve => {
359
+ unlock = resolve;
360
+ });
361
+ await previous;
362
+ try {
363
+ return await operation();
364
+ }
365
+ finally {
366
+ unlock();
367
+ }
368
+ }
369
+ async reserveDispatch(isLive) {
370
+ return this.withDispatchMutex(async () => {
371
+ // Cancellation may win while this request waits behind an earlier
372
+ // generation. It must consume neither a request slot nor a restart.
373
+ if (!isLive()) {
374
+ return false;
375
+ }
376
+ if (this.isLifecycleEnding()) {
377
+ throw new BridgeDisposedError('Transport has been disposed');
378
+ }
379
+ if (this.needsRestart ||
380
+ (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)) {
381
+ if (this.activeDispatches > 0) {
382
+ await new Promise(resolve => this.dispatchDrainWaiters.push(resolve));
383
+ }
384
+ // The owner or transport can settle while waiting for the old
385
+ // generation to drain. Do not restart for work that no longer exists.
386
+ if (!isLive()) {
387
+ return false;
388
+ }
389
+ if (this.isLifecycleEnding()) {
390
+ throw new BridgeDisposedError('Transport has been disposed');
391
+ }
392
+ await this.restartProcess();
393
+ }
394
+ this.activeDispatches++;
230
395
  this.requestCount++;
396
+ return true;
231
397
  });
232
398
  }
399
+ releaseDispatch() {
400
+ this.activeDispatches = Math.max(0, this.activeDispatches - 1);
401
+ if (this.activeDispatches === 0) {
402
+ for (const resolve of this.dispatchDrainWaiters.splice(0)) {
403
+ resolve();
404
+ }
405
+ }
406
+ }
407
+ isLifecycleEnding() {
408
+ return this.state === 'disposing' || this.isDisposed;
409
+ }
233
410
  /**
234
411
  * Static capability descriptor for the subprocess backend.
235
412
  *
236
- * Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes) over the
237
- * JSONL stream. Chunking/streaming are not implemented (0.8.0). `maxFrameBytes`
238
- * is the configured JSONL line-length limit the largest single response line
239
- * this transport will accept before raising a protocol error.
413
+ * Per the {@link Transport.capabilities} contract this is lifecycle-independent
414
+ * (safe before `init()` / after `dispose()`) and never makes a Python round
415
+ * trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
416
+ * over the JSONL stream. `supportsChunking` is always `true` because the npm
417
+ * package ships both `tywrap-frame/1` peers. `supportsStreaming` stays `false`.
418
+ * `maxFrameBytes` is the fixed per-frame data ceiling supplied to the Python
419
+ * bridge at spawn time.
240
420
  */
241
421
  capabilities() {
242
422
  return {
243
423
  backend: 'subprocess',
244
424
  supportsArrow: true,
245
425
  supportsBinary: true,
246
- supportsChunking: false,
426
+ supportsChunking: true,
247
427
  supportsStreaming: false,
248
428
  maxFrameBytes: this.maxLineLength,
249
429
  };
@@ -252,7 +432,8 @@ export class SubprocessTransport extends DisposableBase {
252
432
  // BOUNDED CONTEXT LIFECYCLE
253
433
  // ===========================================================================
254
434
  /**
255
- * Initialize the transport by spawning the Python process.
435
+ * Initialize the transport by spawning the Python process. The packaged
436
+ * bridge always speaks `tywrap-frame/1`.
256
437
  */
257
438
  async doInit() {
258
439
  await this.spawnProcess();
@@ -273,18 +454,24 @@ export class SubprocessTransport extends DisposableBase {
273
454
  }
274
455
  this.pending.clear();
275
456
  // Clear write queue
276
- for (const queued of this.writeQueue) {
277
- this.clearQueuedWriteTimeout(queued);
278
- queued.reject(error);
279
- }
280
- this.writeQueue.length = 0;
281
- // Kill process
282
- await this.killProcess();
457
+ this.rejectAllQueuedWrites(error);
458
+ // Fence queued restart work before killing the process. A restart already
459
+ // holding the mutex observes the disposing state before it can spawn; if it
460
+ // spawned just before disposal began, this barrier kills that child too.
461
+ await this.withDispatchMutex(() => this.killProcess());
283
462
  // Clear buffers
284
463
  this.stdoutBuffer = '';
464
+ this.stdoutScanOffset = 0;
285
465
  this.stderrBuffer = '';
286
466
  this.timedOutRequests.clear();
287
467
  this.requestCount = 0;
468
+ this.responseReassembler = new Reassembler({
469
+ maxReassemblyBytes: this.maxReassemblyBytes,
470
+ expectedStream: 'response',
471
+ });
472
+ // Reset the per-request write mutex so a disposed transport starts from a
473
+ // clean (resolved) tail if reused.
474
+ this.writeMutex = Promise.resolve();
288
475
  }
289
476
  // ===========================================================================
290
477
  // PROCESS MANAGEMENT
@@ -297,6 +484,13 @@ export class SubprocessTransport extends DisposableBase {
297
484
  // If env is provided, it should be the complete environment (already filtered by NodeBridge)
298
485
  // We only add Python-specific variables on top
299
486
  const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
487
+ // Advertise `tywrap-frame/1` chunked transport so the bridge fragments
488
+ // oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
489
+ // caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
490
+ // (see the frame-aware line ceiling) allows for the JSON envelope + escaping
491
+ // on top of it. Spread (rather than dynamic index assignment) keeps the
492
+ // computed keys off ESLint's object-injection sink. See
493
+ // docs/transport-framing.md.
300
494
  const env = {
301
495
  ...baseEnv,
302
496
  // Ensure Python uses UTF-8
@@ -306,7 +500,7 @@ export class SubprocessTransport extends DisposableBase {
306
500
  PYTHONUNBUFFERED: '1',
307
501
  };
308
502
  // Spawn process
309
- this.process = spawn(this.pythonPath, [this.bridgeScript], {
503
+ this.process = spawn(this.pythonPath, [this.bridgeScript, '--tywrap-max-frame-bytes', String(this.frameBytes)], {
310
504
  stdio: ['pipe', 'pipe', 'pipe'],
311
505
  env,
312
506
  cwd: this.cwd,
@@ -382,14 +576,33 @@ export class SubprocessTransport extends DisposableBase {
382
576
  * Restart the Python process.
383
577
  */
384
578
  async restartProcess() {
579
+ if (this.isLifecycleEnding()) {
580
+ throw new BridgeDisposedError('Transport has been disposed');
581
+ }
385
582
  // Kill existing process
386
583
  await this.killProcess();
387
584
  // Clear buffers and restart flags
388
585
  this.stdoutBuffer = '';
586
+ this.stdoutScanOffset = 0;
389
587
  this.stderrBuffer = '';
390
588
  this.requestCount = 0;
391
589
  this.needsRestart = false;
392
- // Spawn new process
590
+ // Drop any partial reassembly + discard tracking: the new process owns a
591
+ // fresh stdout stream, so stale per-id state from the dead process must not
592
+ // leak across the restart boundary.
593
+ this.responseReassembler = new Reassembler({
594
+ maxReassemblyBytes: this.maxReassemblyBytes,
595
+ expectedStream: 'response',
596
+ });
597
+ // The new process owns a fresh stdin stream; reset the write mutex so a
598
+ // pending frame burst against the dead process cannot serialize behind it.
599
+ this.writeMutex = Promise.resolve();
600
+ // Disposal may begin while the old child is being killed. Never publish a
601
+ // replacement after lifecycle teardown has started.
602
+ if (this.isLifecycleEnding()) {
603
+ throw new BridgeDisposedError('Transport has been disposed');
604
+ }
605
+ // Spawn a fresh process with the packaged always-on framing protocol.
393
606
  await this.spawnProcess();
394
607
  }
395
608
  /**
@@ -403,40 +616,72 @@ export class SubprocessTransport extends DisposableBase {
403
616
  // ===========================================================================
404
617
  // STREAM HANDLERS
405
618
  // ===========================================================================
619
+ /**
620
+ * Effective stdout line ceiling.
621
+ *
622
+ * A single wire line may be a `tywrap-frame/1` envelope whose
623
+ * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
624
+ * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
625
+ * to bound that overhead so a legitimate frame line is never rejected while a
626
+ * runaway/garbage line still is.
627
+ */
628
+ effectiveLineCeiling() {
629
+ return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
630
+ }
406
631
  /**
407
632
  * Handle stdout data from the Python process.
408
633
  */
409
634
  handleStdoutData(chunk) {
410
635
  this.stdoutBuffer += chunk.toString();
411
- // Check for excessive line length without newline
412
- if (this.stdoutBuffer.length > this.maxLineLength && !this.stdoutBuffer.includes('\n')) {
636
+ const ceiling = this.effectiveLineCeiling();
637
+ // Scan only bytes newly appended since the last incomplete line.
638
+ let newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
639
+ if (this.stdoutBuffer.length > ceiling && newlineIndex === -1) {
413
640
  const snippet = this.stdoutBuffer.slice(0, 500);
414
641
  this.stdoutBuffer = '';
415
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
642
+ this.stdoutScanOffset = 0;
643
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
416
644
  return;
417
645
  }
418
646
  // Process complete lines
419
- let newlineIndex;
420
- while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
647
+ while (newlineIndex !== -1) {
421
648
  const line = this.stdoutBuffer.slice(0, newlineIndex);
422
649
  this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
650
+ this.stdoutScanOffset = 0;
423
651
  // Skip empty lines
424
652
  if (!line.trim()) {
653
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
425
654
  continue;
426
655
  }
427
656
  // Check line length
428
- if (line.length > this.maxLineLength) {
657
+ if (line.length > ceiling) {
429
658
  const snippet = line.slice(0, 500);
430
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
659
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
431
660
  return;
432
661
  }
433
662
  this.handleResponseLine(line);
663
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
434
664
  }
665
+ this.stdoutScanOffset = this.stdoutBuffer.length;
435
666
  }
436
667
  /**
437
668
  * Handle a complete response line from stdout.
669
+ *
670
+ * A line may be a `tywrap-frame/1` envelope: it is
671
+ * routed into the per-id {@link Reassembler}, which returns the reassembled
672
+ * logical response only once the stream is complete and valid. Single-line
673
+ * (non-frame) responses keep the original fast path unchanged.
438
674
  */
439
675
  handleResponseLine(line) {
676
+ if (this.responseReassembler) {
677
+ const probe = probeFrameLine(line);
678
+ if (probe.kind === 'frame') {
679
+ this.handleResponseFrame(probe.value, line);
680
+ return;
681
+ }
682
+ // probe.kind 'plain'/'invalid' falls through to the legacy single-line
683
+ // path below, which extracts the id and validates JSON as before.
684
+ }
440
685
  // Extract ID to find pending request
441
686
  const messageId = extractMessageId(line);
442
687
  if (messageId === null) {
@@ -462,6 +707,70 @@ export class SubprocessTransport extends DisposableBase {
462
707
  // Resolve with raw response
463
708
  pending.resolve(line);
464
709
  }
710
+ /**
711
+ * Route one `tywrap-frame/1` response frame into the reassembler.
712
+ *
713
+ * On completion the reassembled logical line resolves the pending request. The
714
+ * reassembler validates structure, ordering, byte count, and UTF-8 internally
715
+ * and throws on any framing violation (malformed/duplicate/byte-mismatch/
716
+ * unknown-protocol) — those reject the pending id and mark the subprocess for
717
+ * restart, since stdout can no longer be trusted to be frame-aligned. Frames
718
+ * for a timed-out/aborted id are silently discarded by the reassembler (it
719
+ * returns `null` and tracks the discard set) so late multi-frame responses
720
+ * cannot desync the stream.
721
+ */
722
+ handleResponseFrame(rawFrame, line) {
723
+ const reassembler = this.responseReassembler;
724
+ if (!reassembler) {
725
+ // Unreachable: the always-on framing path constructs the reassembler.
726
+ this.handleProtocolError('Received frame with no reassembler', line);
727
+ return;
728
+ }
729
+ const frameId = rawFrame.id;
730
+ let reassembled;
731
+ try {
732
+ reassembled = reassembler.accept(rawFrame);
733
+ }
734
+ catch (err) {
735
+ // Framing corruption: stdout is no longer frame-aligned. Reject the
736
+ // correlated pending request (if any) and force a restart.
737
+ const message = err instanceof Error ? err.message : String(err);
738
+ this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
739
+ this.markForRestart();
740
+ return;
741
+ }
742
+ if (reassembled === null) {
743
+ // More frames needed, or this frame belonged to a discarded (timed-out)
744
+ // id and was dropped. Either way: nothing to resolve yet.
745
+ return;
746
+ }
747
+ // Stream complete: resolve the correlated pending request with the single
748
+ // logical response line. extractMessageId is reused so the resolution path
749
+ // matches the non-chunked case exactly.
750
+ this.handleResponseLine(reassembled);
751
+ }
752
+ /**
753
+ * Reject the pending request correlated to a frame id, if one exists.
754
+ *
755
+ * Used when frame reassembly throws. If the id is unknown (e.g. it already
756
+ * timed out and was dropped from `pending`), the error still surfaces as a
757
+ * protocol error so the desync is not swallowed silently.
758
+ */
759
+ rejectFrameId(frameId, details, line) {
760
+ if (typeof frameId === 'number' && Number.isInteger(frameId)) {
761
+ const pending = this.pending.get(frameId);
762
+ if (pending) {
763
+ this.pending.delete(frameId);
764
+ if (pending.timer) {
765
+ clearTimeout(pending.timer);
766
+ }
767
+ pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
768
+ return;
769
+ }
770
+ }
771
+ // No correlated pending request: still a protocol-level desync.
772
+ this.handleProtocolError(details, line);
773
+ }
465
774
  /**
466
775
  * Handle stderr data from the Python process.
467
776
  */
@@ -523,11 +832,7 @@ export class SubprocessTransport extends DisposableBase {
523
832
  // EPIPE means process died
524
833
  const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
525
834
  // Reject all pending writes
526
- for (const queued of this.writeQueue) {
527
- this.clearQueuedWriteTimeout(queued);
528
- queued.reject(error);
529
- }
530
- this.writeQueue.length = 0;
835
+ this.rejectAllQueuedWrites(error);
531
836
  // Reject all pending requests
532
837
  this.rejectAllPending(error);
533
838
  // Mark for restart on next send
@@ -559,15 +864,13 @@ export class SubprocessTransport extends DisposableBase {
559
864
  * Create a queued write entry with a timeout timer.
560
865
  * The timer fires if the drain event never comes.
561
866
  */
562
- createQueuedWrite(data, resolve, reject) {
867
+ createQueuedWrite(data, resolve, reject, isLive) {
563
868
  const queuedAt = Date.now();
564
- const entry = { data, resolve, reject, queuedAt };
869
+ const entry = { data, resolve, reject, queuedAt, isLive, isQueued: true };
565
870
  // Set up timeout timer that fires if drain never happens
566
871
  entry.timeoutHandle = setTimeout(() => {
567
- // Remove this entry from the queue
568
- const index = this.writeQueue.indexOf(entry);
569
- if (index !== -1) {
570
- this.writeQueue.splice(index, 1);
872
+ if (entry.isQueued) {
873
+ entry.isQueued = false;
571
874
  reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`));
572
875
  }
573
876
  }, this.writeQueueTimeoutMs);
@@ -589,15 +892,22 @@ export class SubprocessTransport extends DisposableBase {
589
892
  /**
590
893
  * Write data to stdin with backpressure handling.
591
894
  */
592
- writeToStdin(data) {
895
+ writeToStdin(data, isLive) {
593
896
  return new Promise((resolve, reject) => {
594
897
  if (!this.process?.stdin || this.processExited) {
595
898
  reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
596
899
  return;
597
900
  }
598
- if (this.draining || this.writeQueue.length > 0) {
599
- // Queue the write with timestamp and timeout timer
600
- this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
901
+ if (this.draining || this.writeQueue.length > this.writeQueueHead) {
902
+ // Queue the write with timestamp, timeout timer, and liveness predicate
903
+ // (checked again at flush — see processQueuedWrite).
904
+ this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
905
+ return;
906
+ }
907
+ // Skip a write whose request was abandoned (timed out/aborted) before it
908
+ // reached stdin — never execute an operation the caller gave up on.
909
+ if (isLive && !isLive()) {
910
+ resolve();
601
911
  return;
602
912
  }
603
913
  // Try direct write (wrap in try-catch for synchronous EPIPE errors)
@@ -621,16 +931,106 @@ export class SubprocessTransport extends DisposableBase {
621
931
  }
622
932
  });
623
933
  }
934
+ /**
935
+ * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
936
+ * request frames when the encoded request exceeds
937
+ * the per-frame ceiling (W5 — the mirror of W4's response chunking).
938
+ *
939
+ * Both the chunked and single-line paths run under {@link writeMutex} so a
940
+ * logical request's bytes (one line, or a burst of frames) reach stdin
941
+ * contiguously: a small request issued concurrently can never interleave
942
+ * between another request's frames, which would desync the Python
943
+ * reassembler (it correlates frames by id, but the JSONL stream itself must
944
+ * stay frame-aligned). The mutex tail is advanced regardless of success so a
945
+ * failed write never wedges every subsequent request.
946
+ *
947
+ * @param message - the encoded logical JSON request (no trailing newline)
948
+ * @param messageId - the request's correlation id (already validated integer)
949
+ * @param signal - optional abort signal; an abort observed between frames
950
+ * stops further frames and rejects this send (the pending entry is rejected
951
+ * by the abort handler / the caller's `.catch`).
952
+ */
953
+ writeRequest(message, messageId, signal, pendingEntry) {
954
+ // The request is "live" only while THIS send's exact pending entry is still
955
+ // registered (the timeout and abort handlers delete it) and the signal is not
956
+ // aborted. Binding to the entry IDENTITY — not just the id — closes the
957
+ // id-reuse hole: if the id is recycled by a later send while this write is
958
+ // still queued, the stale write sees a different entry and is skipped.
959
+ // (RpcClient ids are monotonic, but Transport.send is public and does not
960
+ // enforce id uniqueness.) Gating EVERY write point on this — the run closure,
961
+ // the direct stdin write, the backpressure-queue flush, and each chunked
962
+ // frame — prevents an abandoned request from executing on Python, even one
963
+ // whose write sat queued under backpressure past the cancellation.
964
+ const isLive = () => !signal?.aborted &&
965
+ (pendingEntry !== undefined
966
+ ? this.pending.get(messageId) === pendingEntry
967
+ : this.pending.has(messageId));
968
+ const run = () => {
969
+ if (!isLive()) {
970
+ return Promise.resolve();
971
+ }
972
+ // Chunk when the encoded request exceeds the fixed per-frame ceiling
973
+ // supplied to the packaged Python peer. Otherwise use one JSONL line.
974
+ if (utf8ByteLength(message) > this.frameBytes) {
975
+ return this.writeChunkedRequest(message, messageId, signal, isLive);
976
+ }
977
+ return this.writeToStdin(`${message}\n`, isLive);
978
+ };
979
+ // Serialize the whole logical write onto the mutex tail. We chain the next
980
+ // tail off the settled (caught) result so one failed/aborted write does not
981
+ // poison the chain for later requests.
982
+ const result = this.writeMutex.then(run);
983
+ this.writeMutex = result.then(() => undefined, () => undefined);
984
+ return result;
985
+ }
986
+ /**
987
+ * Fragment a logical request into `tywrap-frame/1` request frames and write
988
+ * them contiguously (one JSONL line per frame). Runs while holding the write
989
+ * mutex (see {@link writeRequest}), so no other write interleaves.
990
+ *
991
+ * Each frame is awaited in turn so backpressure on stdin is respected. Before
992
+ * every frame the abort signal and process liveness are re-checked: an abort
993
+ * mid-burst stops the remaining frames and rejects the send LOUD (the Python
994
+ * reassembler drops the now-incomplete id when the next request's frames /
995
+ * timeout arrive, exactly as the response side handles a discarded id).
996
+ */
997
+ async writeChunkedRequest(message, messageId, signal, isLive) {
998
+ const frames = encodeFrames(message, {
999
+ id: messageId,
1000
+ stream: 'request',
1001
+ maxFrameBytes: this.frameBytes,
1002
+ });
1003
+ for (const frame of frames) {
1004
+ // Stop the burst if the caller aborted, the request was abandoned (timed
1005
+ // out -> pending deleted, caught by isLive), or the process died between
1006
+ // frames. A partial request stream is dropped by the Python reassembler.
1007
+ if (signal?.aborted || (isLive && !isLive())) {
1008
+ throw new BridgeTimeoutError('Operation aborted');
1009
+ }
1010
+ if (this.processExited || !this.process) {
1011
+ throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
1012
+ }
1013
+ // One frame per JSONL line; await each so stdin backpressure is honored.
1014
+ // isLive gates a frame that ends up queued under backpressure past a late
1015
+ // cancellation, so an abandoned chunked request never completes on Python.
1016
+ await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
1017
+ }
1018
+ }
624
1019
  /**
625
1020
  * Reject and clear every entry currently in the write queue.
626
1021
  * Clears each entry's timeout before rejecting so no late timer fires.
627
1022
  */
628
1023
  rejectAllQueuedWrites(error) {
629
- for (const q of this.writeQueue) {
630
- this.clearQueuedWriteTimeout(q);
631
- q.reject(error);
1024
+ for (let index = this.writeQueueHead; index < this.writeQueue.length; index += 1) {
1025
+ const queued = this.writeQueue[index];
1026
+ if (queued?.isQueued) {
1027
+ queued.isQueued = false;
1028
+ this.clearQueuedWriteTimeout(queued);
1029
+ queued.reject(error);
1030
+ }
632
1031
  }
633
1032
  this.writeQueue.length = 0;
1033
+ this.writeQueueHead = 0;
634
1034
  }
635
1035
  /**
636
1036
  * Process a single dequeued write entry: enforce the fallback queue timeout,
@@ -648,6 +1048,13 @@ export class SubprocessTransport extends DisposableBase {
648
1048
  queued.reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`));
649
1049
  return 'continue';
650
1050
  }
1051
+ // Skip the write if the request was abandoned (timed out/aborted) while it
1052
+ // sat in the backpressure queue — never execute an operation the caller gave
1053
+ // up on. Resolve as a no-op so the mutex chain stays healthy.
1054
+ if (queued.isLive && !queued.isLive()) {
1055
+ queued.resolve();
1056
+ return 'continue';
1057
+ }
651
1058
  try {
652
1059
  const canWrite = stdin.write(queued.data);
653
1060
  if (canWrite) {
@@ -675,7 +1082,7 @@ export class SubprocessTransport extends DisposableBase {
675
1082
  */
676
1083
  flushWriteQueue() {
677
1084
  const now = Date.now();
678
- while (this.writeQueue.length > 0 && !this.draining) {
1085
+ while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
679
1086
  const stdin = this.process?.stdin;
680
1087
  if (!stdin || this.processExited) {
681
1088
  // Process died - reject all queued writes
@@ -683,10 +1090,15 @@ export class SubprocessTransport extends DisposableBase {
683
1090
  this.markForRestart();
684
1091
  return;
685
1092
  }
686
- const queued = this.writeQueue.shift();
1093
+ const queued = this.writeQueue[this.writeQueueHead];
1094
+ this.writeQueueHead += 1;
687
1095
  if (!queued) {
688
1096
  return;
689
1097
  }
1098
+ if (!queued.isQueued) {
1099
+ continue;
1100
+ }
1101
+ queued.isQueued = false;
690
1102
  // Clear the timeout since we're processing this entry now
691
1103
  this.clearQueuedWriteTimeout(queued);
692
1104
  const status = this.processQueuedWrite(queued, stdin, now);
@@ -694,6 +1106,10 @@ export class SubprocessTransport extends DisposableBase {
694
1106
  return;
695
1107
  }
696
1108
  }
1109
+ if (this.writeQueueHead === this.writeQueue.length) {
1110
+ this.writeQueue.length = 0;
1111
+ this.writeQueueHead = 0;
1112
+ }
697
1113
  }
698
1114
  // ===========================================================================
699
1115
  // HELPERS
@@ -712,7 +1128,15 @@ export class SubprocessTransport extends DisposableBase {
712
1128
  return stderrTail ? `${message}. Stderr:\n${stderrTail}` : message;
713
1129
  }
714
1130
  /**
715
- * Handle a protocol error by rejecting all pending requests.
1131
+ * Handle a protocol error by rejecting all pending requests and marking the
1132
+ * subprocess for restart.
1133
+ *
1134
+ * Every caller represents genuine stdout-stream corruption (a too-long line, a
1135
+ * response with no `id`, a frame with no reassembler, or a truly unexpected id
1136
+ * — benign late responses from timed-out requests are already filtered upstream
1137
+ * via {@link timedOutRequests}). After such an error stdout can no longer be
1138
+ * trusted to be line/frame-aligned, so the process is marked for restart —
1139
+ * matching the frame-reassembly-corruption path and the framing spec.
716
1140
  */
717
1141
  handleProtocolError(details, line) {
718
1142
  const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
@@ -722,6 +1146,7 @@ export class SubprocessTransport extends DisposableBase {
722
1146
  : `Protocol error: ${details}\n${hint}`;
723
1147
  const error = new BridgeProtocolError(msg);
724
1148
  this.rejectAllPending(error);
1149
+ this.markForRestart();
725
1150
  }
726
1151
  /**
727
1152
  * Reject all pending requests with an error.