tywrap 0.6.1 → 0.8.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 (110) hide show
  1. package/README.md +15 -5
  2. package/dist/core/annotation-parser.d.ts.map +1 -1
  3. package/dist/core/annotation-parser.js.map +1 -1
  4. package/dist/core/emit-call.d.ts.map +1 -1
  5. package/dist/core/emit-call.js +1 -1
  6. package/dist/core/emit-call.js.map +1 -1
  7. package/dist/core/generator.d.ts.map +1 -1
  8. package/dist/core/generator.js +40 -9
  9. package/dist/core/generator.js.map +1 -1
  10. package/dist/dev.d.ts.map +1 -1
  11. package/dist/dev.js +1 -3
  12. package/dist/dev.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/runtime/base-bridge.d.ts +57 -0
  17. package/dist/runtime/base-bridge.d.ts.map +1 -0
  18. package/dist/runtime/base-bridge.js +72 -0
  19. package/dist/runtime/base-bridge.js.map +1 -0
  20. package/dist/runtime/frame-codec.d.ts +111 -0
  21. package/dist/runtime/frame-codec.d.ts.map +1 -0
  22. package/dist/runtime/frame-codec.js +352 -0
  23. package/dist/runtime/frame-codec.js.map +1 -0
  24. package/dist/runtime/http-transport.d.ts +11 -1
  25. package/dist/runtime/http-transport.d.ts.map +1 -1
  26. package/dist/runtime/http-transport.js +19 -0
  27. package/dist/runtime/http-transport.js.map +1 -1
  28. package/dist/runtime/http.d.ts +5 -12
  29. package/dist/runtime/http.d.ts.map +1 -1
  30. package/dist/runtime/http.js +6 -29
  31. package/dist/runtime/http.js.map +1 -1
  32. package/dist/runtime/index.d.ts +2 -2
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/node.d.ts +25 -19
  37. package/dist/runtime/node.d.ts.map +1 -1
  38. package/dist/runtime/node.js +19 -34
  39. package/dist/runtime/node.js.map +1 -1
  40. package/dist/runtime/pooled-transport.d.ts +21 -2
  41. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  42. package/dist/runtime/pooled-transport.js +16 -0
  43. package/dist/runtime/pooled-transport.js.map +1 -1
  44. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  45. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  46. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  47. package/dist/runtime/pyodide-transport.d.ts +12 -1
  48. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  49. package/dist/runtime/pyodide-transport.js +20 -0
  50. package/dist/runtime/pyodide-transport.js.map +1 -1
  51. package/dist/runtime/pyodide.d.ts +5 -12
  52. package/dist/runtime/pyodide.d.ts.map +1 -1
  53. package/dist/runtime/pyodide.js +6 -29
  54. package/dist/runtime/pyodide.js.map +1 -1
  55. package/dist/runtime/rpc-client.d.ts +14 -1
  56. package/dist/runtime/rpc-client.d.ts.map +1 -1
  57. package/dist/runtime/rpc-client.js +68 -6
  58. package/dist/runtime/rpc-client.js.map +1 -1
  59. package/dist/runtime/subprocess-transport.d.ts +177 -3
  60. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  61. package/dist/runtime/subprocess-transport.js +526 -26
  62. package/dist/runtime/subprocess-transport.js.map +1 -1
  63. package/dist/runtime/transport.d.ts +141 -0
  64. package/dist/runtime/transport.d.ts.map +1 -1
  65. package/dist/runtime/transport.js +21 -0
  66. package/dist/runtime/transport.js.map +1 -1
  67. package/dist/types/index.d.ts +59 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/dist/tywrap.d.ts.map +1 -1
  70. package/dist/tywrap.js +204 -149
  71. package/dist/tywrap.js.map +1 -1
  72. package/dist/utils/codec.d.ts +2 -0
  73. package/dist/utils/codec.d.ts.map +1 -1
  74. package/dist/utils/codec.js +205 -6
  75. package/dist/utils/codec.js.map +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +7 -1
  78. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  79. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  80. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  81. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  82. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  83. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  84. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  85. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  86. package/runtime/frame_codec.py +424 -0
  87. package/runtime/python_bridge.py +241 -42
  88. package/runtime/tywrap_bridge_core.py +152 -13
  89. package/src/core/annotation-parser.ts +2 -1
  90. package/src/core/emit-call.ts +1 -7
  91. package/src/core/generator.ts +50 -11
  92. package/src/dev.ts +1 -3
  93. package/src/index.ts +1 -0
  94. package/src/runtime/base-bridge.ts +106 -0
  95. package/src/runtime/frame-codec.ts +469 -0
  96. package/src/runtime/http-transport.ts +21 -1
  97. package/src/runtime/http.ts +7 -51
  98. package/src/runtime/index.ts +2 -6
  99. package/src/runtime/node.ts +42 -53
  100. package/src/runtime/pooled-transport.ts +25 -2
  101. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  102. package/src/runtime/pyodide-transport.ts +22 -0
  103. package/src/runtime/pyodide.ts +7 -52
  104. package/src/runtime/rpc-client.ts +91 -7
  105. package/src/runtime/subprocess-transport.ts +629 -30
  106. package/src/runtime/transport.ts +169 -0
  107. package/src/types/index.ts +62 -0
  108. package/src/tywrap.ts +265 -162
  109. package/src/utils/codec.ts +245 -7
  110. package/src/version.ts +1 -1
@@ -17,8 +17,14 @@ 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 } from './transport.js';
22
+ import {
23
+ FRAME_PROTOCOL_ID,
24
+ PROTOCOL_ID,
25
+ type Transport,
26
+ type TransportCapabilities,
27
+ } from './transport.js';
22
28
 
23
29
  // =============================================================================
24
30
  // CONSTANTS
@@ -36,6 +42,44 @@ const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
36
42
  /** Track timed-out/cancelled request IDs long enough to ignore late responses. */
37
43
  const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
38
44
 
45
+ /**
46
+ * Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
47
+ * when sizing the frame-aware stdout line ceiling.
48
+ *
49
+ * A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
50
+ * "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
51
+ * "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
52
+ * plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
53
+ * upper bound that never under-allocates.
54
+ */
55
+ const FRAME_ENVELOPE_HEADROOM = 1024;
56
+
57
+ /**
58
+ * Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
59
+ * fragment of a JSON response (already-escaped, printable content), so realistic
60
+ * expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
61
+ * ceiling sound without over-allocating buffer headroom.
62
+ */
63
+ const FRAME_DATA_ESCAPE_FACTOR = 2;
64
+
65
+ /** Negotiation env var: `1` enables `tywrap-frame/1` chunked transport. */
66
+ const ENV_CHUNKING = 'TYWRAP_TRANSPORT_CHUNKING';
67
+ /** Negotiation env var: the framing protocol id the bridge must implement. */
68
+ const ENV_FRAME_PROTOCOL = 'TYWRAP_TRANSPORT_FRAME_PROTOCOL';
69
+ /** Negotiation env var: per-frame UTF-8 byte ceiling for the data slice. */
70
+ const ENV_MAX_FRAME_BYTES = 'TYWRAP_TRANSPORT_MAX_FRAME_BYTES';
71
+
72
+ /** Timeout (ms) for the in-init meta negotiation probe. */
73
+ const NEGOTIATION_PROBE_TIMEOUT_MS = 30_000;
74
+
75
+ /**
76
+ * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
77
+ * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
78
+ * buffers more than the codec would ultimately accept; `NodeBridge` overrides it
79
+ * with the configured `codec.maxPayloadBytes`.
80
+ */
81
+ const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
82
+
39
83
  /** Regex for ANSI escape sequences */
40
84
  const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
41
85
 
@@ -70,6 +114,28 @@ export interface SubprocessTransportOptions {
70
114
 
71
115
  /** Write queue timeout in milliseconds. Default: 30000ms */
72
116
  writeQueueTimeoutMs?: number;
117
+
118
+ /**
119
+ * Enable `tywrap-frame/1` chunked transport negotiation. When `true`, the
120
+ * transport spawns the bridge with the three `TYWRAP_TRANSPORT_*` env vars and,
121
+ * during {@link SubprocessTransport.init}, probes the bridge's `meta` for a
122
+ * `transport.supportsChunking` block. If the bridge advertises chunking,
123
+ * oversize responses are transparently reassembled from frames; otherwise
124
+ * behavior is unchanged and an oversize response still fails loud (no silent
125
+ * single-frame fallback). Default: `false` (no negotiation, legacy behavior).
126
+ *
127
+ * Subprocess-only (0.8.0). See docs/transport-framing.md.
128
+ */
129
+ enableChunking?: boolean;
130
+
131
+ /**
132
+ * Cap (UTF-8 bytes) on a single chunked RESPONSE reassembled in memory. A
133
+ * frame stream whose declared or accumulated payload exceeds this fails loud
134
+ * instead of buffering, so chunking cannot OOM the process before the codec's
135
+ * payload cap rejects the result. Should track the codec's `maxPayloadBytes`.
136
+ * Default: 10 MiB (matches the codec default).
137
+ */
138
+ maxReassemblyBytes?: number;
73
139
  }
74
140
 
75
141
  /**
@@ -92,6 +158,13 @@ interface QueuedWrite {
92
158
  queuedAt: number;
93
159
  /** Timeout handle for write queue timeout */
94
160
  timeoutHandle?: NodeJS.Timeout;
161
+ /**
162
+ * Liveness predicate. If present and `false` at flush time, the write is
163
+ * SKIPPED (resolved as a no-op) instead of sent — so a request that timed out
164
+ * or aborted while its write sat in the stdin backpressure queue never reaches
165
+ * (and so never executes on) Python.
166
+ */
167
+ isLive?: () => boolean;
95
168
  }
96
169
 
97
170
  // =============================================================================
@@ -129,6 +202,36 @@ function extractMessageId(json: string): number | null {
129
202
  return id;
130
203
  }
131
204
 
205
+ /**
206
+ * Result of inspecting a stdout line for `tywrap-frame/1` framing.
207
+ * - `{ kind: 'frame', value }`: the line parsed as JSON carrying a
208
+ * `__tywrap_frame__` marker (handed to the reassembler, which validates it).
209
+ * - `{ kind: 'plain' }`: valid JSON without a framing marker (a normal,
210
+ * single-line response).
211
+ * - `{ kind: 'invalid' }`: not parseable as JSON.
212
+ */
213
+ type FrameLineProbe = { kind: 'frame'; value: unknown } | { kind: 'plain' } | { kind: 'invalid' };
214
+
215
+ /**
216
+ * Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
217
+ *
218
+ * A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
219
+ * envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
220
+ * by the {@link Reassembler}, not here — this only routes the line.
221
+ */
222
+ function probeFrameLine(line: string): FrameLineProbe {
223
+ let parsed: unknown;
224
+ try {
225
+ parsed = JSON.parse(line);
226
+ } catch {
227
+ return { kind: 'invalid' };
228
+ }
229
+ if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
230
+ return { kind: 'frame', value: parsed };
231
+ }
232
+ return { kind: 'plain' };
233
+ }
234
+
132
235
  // =============================================================================
133
236
  // PROCESS IO TRANSPORT
134
237
  // =============================================================================
@@ -165,9 +268,36 @@ export class SubprocessTransport extends DisposableBase implements Transport {
165
268
  private readonly envOverrides: Record<string, string>;
166
269
  private readonly cwd: string | undefined;
167
270
  private readonly maxLineLength: number;
271
+ private readonly maxReassemblyBytes: number;
168
272
  private readonly restartAfterRequests: number;
169
273
  private readonly writeQueueTimeoutMs: number;
170
274
 
275
+ /** Whether `tywrap-frame/1` negotiation was requested by the caller. */
276
+ private readonly enableChunking: boolean;
277
+
278
+ /**
279
+ * Whether the bridge advertised chunking during the init meta probe. Only
280
+ * `true` after a successful negotiation; drives {@link capabilities} and the
281
+ * frame-aware stdout line ceiling.
282
+ */
283
+ private negotiatedChunking = false;
284
+
285
+ /**
286
+ * The per-frame UTF-8 byte ceiling actually agreed with the bridge:
287
+ * `min(maxLineLength, the bridge's advertised maxFrameBytes)`. Defaults to
288
+ * `maxLineLength` and is narrowed during negotiation, so REQUEST frames are
289
+ * never sized larger than the bridge said it will accept (the reference bridge
290
+ * echoes the requested value, but a custom bridge may advertise a smaller one).
291
+ */
292
+ private negotiatedFrameBytes: number;
293
+
294
+ /**
295
+ * Reassembles `tywrap-frame/1` response frames into single logical response
296
+ * lines. Constructed lazily once chunking is negotiated; per-id discard tracks
297
+ * timed-out/aborted streams so late frames cannot desync stdout.
298
+ */
299
+ private responseReassembler: Reassembler | null = null;
300
+
171
301
  // Process state
172
302
  private process: ChildProcess | null = null;
173
303
  private processExited = false;
@@ -189,6 +319,17 @@ export class SubprocessTransport extends DisposableBase implements Transport {
189
319
  private readonly writeQueue: QueuedWrite[] = [];
190
320
  private draining = false;
191
321
 
322
+ /**
323
+ * Per-logical-request write mutex (W5). When a request is chunked into
324
+ * `tywrap-frame/1` frames, all of that request's frames must reach stdin
325
+ * contiguously — no other request's frame (or single line) may interleave —
326
+ * or the Python reassembler would see frames from two ids mixed on one stream.
327
+ * `writeChunkedRequest` chains the whole frame burst onto this tail; the
328
+ * single-line write path also serializes behind it so a small request issued
329
+ * concurrently never slips between another request's frames.
330
+ */
331
+ private writeMutex: Promise<void> = Promise.resolve();
332
+
192
333
  /**
193
334
  * Create a new SubprocessTransport.
194
335
  *
@@ -204,6 +345,9 @@ export class SubprocessTransport extends DisposableBase implements Transport {
204
345
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
205
346
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
206
347
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
348
+ this.enableChunking = options.enableChunking ?? false;
349
+ this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
350
+ this.negotiatedFrameBytes = this.maxLineLength;
207
351
  }
208
352
 
209
353
  // ===========================================================================
@@ -260,10 +404,32 @@ export class SubprocessTransport extends DisposableBase implements Transport {
260
404
  return new Promise<string>((resolve, reject) => {
261
405
  // Set up timeout if specified
262
406
  let timer: NodeJS.Timeout | undefined;
407
+
408
+ // Defined before the timer so the timeout path can also detach it.
409
+ const abortHandler = (): void => {
410
+ if (timer) {
411
+ clearTimeout(timer);
412
+ }
413
+ this.pending.delete(messageId);
414
+ this.timedOutRequests.mark(messageId);
415
+ // Same late-frame discard as the timeout path (see below).
416
+ this.responseReassembler?.discard(messageId);
417
+ reject(new BridgeTimeoutError('Operation aborted'));
418
+ };
419
+
263
420
  if (timeoutMs > 0) {
264
421
  timer = setTimeout(() => {
265
422
  this.pending.delete(messageId);
266
423
  this.timedOutRequests.mark(messageId);
424
+ // Discard any in-flight chunked response stream for this id so late
425
+ // frames are dropped rather than desyncing stdout (the single-line
426
+ // timedOutRequests.consume above is one-shot and insufficient for a
427
+ // multi-frame stream).
428
+ this.responseReassembler?.discard(messageId);
429
+ // Detach the abort listener: on timeout the abort never fires, so
430
+ // without this it would leak on a long-lived AbortSignal and could
431
+ // re-enter abortHandler after this promise has already settled.
432
+ signal?.removeEventListener('abort', abortHandler);
267
433
  const stderrTail = this.getStderrTail();
268
434
  const baseMsg = `Operation timed out after ${timeoutMs}ms`;
269
435
  const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
@@ -271,16 +437,6 @@ export class SubprocessTransport extends DisposableBase implements Transport {
271
437
  }, timeoutMs);
272
438
  }
273
439
 
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
440
  if (signal) {
285
441
  signal.addEventListener('abort', abortHandler, { once: true });
286
442
  }
@@ -302,15 +458,22 @@ export class SubprocessTransport extends DisposableBase implements Transport {
302
458
  reject(error);
303
459
  };
304
460
 
305
- // Register pending request
306
- this.pending.set(messageId, {
461
+ // Register pending request. Captured by reference so the write path can
462
+ // bind liveness to THIS exact entry (not just the id) — see writeRequest.
463
+ const pendingEntry: PendingRequest = {
307
464
  resolve: wrappedResolve,
308
465
  reject: wrappedReject,
309
466
  timer,
310
- });
311
-
312
- // Write message to stdin
313
- this.writeToStdin(`${message}\n`).catch(err => {
467
+ };
468
+ this.pending.set(messageId, pendingEntry);
469
+
470
+ // Write message to stdin. When chunking is negotiated and the encoded
471
+ // request exceeds the negotiated per-frame ceiling, it is fragmented into
472
+ // `tywrap-frame/1` request frames written contiguously under the write
473
+ // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
474
+ // serialize through the same mutex so a small request can never slip
475
+ // between another request's frames.
476
+ this.writeRequest(message, messageId, signal, pendingEntry).catch(err => {
314
477
  this.pending.delete(messageId);
315
478
  if (timer) {
316
479
  clearTimeout(timer);
@@ -323,15 +486,174 @@ export class SubprocessTransport extends DisposableBase implements Transport {
323
486
  });
324
487
  }
325
488
 
489
+ /**
490
+ * Static capability descriptor for the subprocess backend.
491
+ *
492
+ * Per the {@link Transport.capabilities} contract this is lifecycle-independent
493
+ * (safe before `init()` / after `dispose()`) and never makes a Python round
494
+ * trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
495
+ * over the JSONL stream. `supportsChunking` reports the *configured* capability
496
+ * — `this.enableChunking`, i.e. whether this transport is set up to use the
497
+ * `tywrap-frame/1` framing path — exactly as `supportsArrow` reports a static
498
+ * channel capability rather than a runtime fact. Whether the *connected* bridge
499
+ * actually advertised framing is the negotiated fact, surfaced separately on
500
+ * `BridgeInfo.transport.supportsChunking`; "will chunking actually happen"
501
+ * needs both `true`. `supportsStreaming` stays `false` (0.8.0). `maxFrameBytes`
502
+ * is the configured JSONL line-length limit — the largest single (unchunked)
503
+ * response line this transport accepts.
504
+ */
505
+ capabilities(): TransportCapabilities {
506
+ return {
507
+ backend: 'subprocess',
508
+ supportsArrow: true,
509
+ supportsBinary: true,
510
+ supportsChunking: this.enableChunking,
511
+ supportsStreaming: false,
512
+ maxFrameBytes: this.maxLineLength,
513
+ };
514
+ }
515
+
326
516
  // ===========================================================================
327
517
  // BOUNDED CONTEXT LIFECYCLE
328
518
  // ===========================================================================
329
519
 
330
520
  /**
331
- * Initialize the transport by spawning the Python process.
521
+ * Initialize the transport by spawning the Python process and, when chunking
522
+ * is enabled, negotiating `tywrap-frame/1` via a small unchunked `meta` probe.
332
523
  */
333
524
  protected async doInit(): Promise<void> {
334
525
  await this.spawnProcess();
526
+ if (this.enableChunking) {
527
+ await this.negotiateChunking();
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Probe the freshly-spawned bridge for `tywrap-frame/1` support.
533
+ *
534
+ * Sends a small unchunked `meta` request directly over stdin (NOT via the
535
+ * public {@link send}, which would re-enter init while we are mid-init) and
536
+ * reads the single-line response. If the bridge reports
537
+ * `transport.supportsChunking: true`, response reassembly is enabled. If the
538
+ * bridge does not advertise chunking (old bridge, or it disabled framing), the
539
+ * transport stays single-frame and an oversize response still fails loud — no
540
+ * silent fallback. A probe failure leaves chunking disabled but does not fail
541
+ * init (small calls must keep working); the loud failure is deferred to the
542
+ * first oversize response.
543
+ */
544
+ private async negotiateChunking(): Promise<void> {
545
+ const probeId = -1;
546
+ const probeMessage = JSON.stringify({
547
+ id: probeId,
548
+ protocol: PROTOCOL_ID,
549
+ method: 'meta',
550
+ params: {},
551
+ });
552
+
553
+ let responseLine: string;
554
+ try {
555
+ responseLine = await this.sendProbe(probeId, probeMessage, NEGOTIATION_PROBE_TIMEOUT_MS);
556
+ } catch {
557
+ // Probe failed (slow/old bridge, transient): leave chunking off. Oversize
558
+ // responses will fail loud at the line ceiling; small calls keep working.
559
+ this.negotiatedChunking = false;
560
+ return;
561
+ }
562
+
563
+ let parsed: unknown;
564
+ try {
565
+ parsed = JSON.parse(responseLine);
566
+ } catch {
567
+ this.negotiatedChunking = false;
568
+ return;
569
+ }
570
+
571
+ const supports = this.bridgeAdvertisesChunking(parsed);
572
+ this.negotiatedChunking = supports;
573
+ if (supports) {
574
+ // Honor the bridge's advertised ceiling so request frames are never sized
575
+ // larger than it will accept. bridgeAdvertisesChunking already validated
576
+ // maxFrameBytes is a positive integer, so this read is safe.
577
+ const advertised = (parsed as { result?: { transport?: { maxFrameBytes?: number } } }).result
578
+ ?.transport?.maxFrameBytes;
579
+ if (typeof advertised === 'number') {
580
+ this.negotiatedFrameBytes = Math.min(this.maxLineLength, advertised);
581
+ }
582
+ this.responseReassembler = new Reassembler({
583
+ maxReassemblyBytes: this.maxReassemblyBytes,
584
+ expectedStream: 'response',
585
+ });
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Whether a parsed `meta` response advertises `tywrap-frame/1` chunking with a
591
+ * matching frame protocol. Defensive: any missing/mismatched field => `false`.
592
+ */
593
+ private bridgeAdvertisesChunking(parsed: unknown): boolean {
594
+ if (parsed === null || typeof parsed !== 'object') {
595
+ return false;
596
+ }
597
+ const result = (parsed as { result?: unknown }).result;
598
+ if (result === null || typeof result !== 'object') {
599
+ return false;
600
+ }
601
+ const transport = (result as { transport?: unknown }).transport;
602
+ if (transport === null || typeof transport !== 'object') {
603
+ return false;
604
+ }
605
+ const t = transport as {
606
+ frameProtocol?: unknown;
607
+ supportsChunking?: unknown;
608
+ maxFrameBytes?: unknown;
609
+ };
610
+ // Match the BridgeInfo validator (rpc-client.ts): a valid framing block needs
611
+ // the matching protocol AND a positive-integer maxFrameBytes. A bridge that
612
+ // advertises chunking with a bogus frame ceiling is not a contract we honor.
613
+ return (
614
+ t.supportsChunking === true &&
615
+ t.frameProtocol === FRAME_PROTOCOL_ID &&
616
+ typeof t.maxFrameBytes === 'number' &&
617
+ Number.isInteger(t.maxFrameBytes) &&
618
+ t.maxFrameBytes > 0
619
+ );
620
+ }
621
+
622
+ /**
623
+ * Send a single in-init probe message over stdin and resolve with its raw
624
+ * response line. Registers a pending entry keyed by `probeId` exactly like the
625
+ * normal send path so {@link handleResponseLine} resolves it, but bypasses the
626
+ * `isReady`/auto-init guard (we are deliberately running during `doInit`).
627
+ */
628
+ private sendProbe(probeId: number, message: string, timeoutMs: number): Promise<string> {
629
+ return new Promise<string>((resolve, reject) => {
630
+ const timer = setTimeout(() => {
631
+ this.pending.delete(probeId);
632
+ this.timedOutRequests.mark(probeId);
633
+ reject(new BridgeTimeoutError(`Negotiation probe timed out after ${timeoutMs}ms`));
634
+ }, timeoutMs);
635
+ if (typeof timer.unref === 'function') {
636
+ timer.unref();
637
+ }
638
+
639
+ this.pending.set(probeId, {
640
+ resolve: (value: string): void => {
641
+ clearTimeout(timer);
642
+ resolve(value);
643
+ },
644
+ reject: (error: Error): void => {
645
+ clearTimeout(timer);
646
+ reject(error);
647
+ },
648
+ timer,
649
+ });
650
+
651
+ this.writeToStdin(`${message}\n`).catch(err => {
652
+ this.pending.delete(probeId);
653
+ clearTimeout(timer);
654
+ reject(this.classifyError(err));
655
+ });
656
+ });
335
657
  }
336
658
 
337
659
  /**
@@ -366,6 +688,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
366
688
  this.stderrBuffer = '';
367
689
  this.timedOutRequests.clear();
368
690
  this.requestCount = 0;
691
+ this.responseReassembler = null;
692
+ this.negotiatedChunking = false;
693
+ // Reset the per-request write mutex so a disposed transport starts from a
694
+ // clean (resolved) tail if reused.
695
+ this.writeMutex = Promise.resolve();
369
696
  }
370
697
 
371
698
  // ===========================================================================
@@ -380,8 +707,25 @@ export class SubprocessTransport extends DisposableBase implements Transport {
380
707
  // If env is provided, it should be the complete environment (already filtered by NodeBridge)
381
708
  // We only add Python-specific variables on top
382
709
  const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
710
+
711
+ // Advertise `tywrap-frame/1` chunked transport so the bridge fragments
712
+ // oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
713
+ // caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
714
+ // (see the frame-aware line ceiling) allows for the JSON envelope + escaping
715
+ // on top of it. Spread (rather than dynamic index assignment) keeps the
716
+ // computed keys off ESLint's object-injection sink. See
717
+ // docs/transport-framing.md.
718
+ const chunkingEnv: NodeJS.ProcessEnv = this.enableChunking
719
+ ? {
720
+ [ENV_CHUNKING]: '1',
721
+ [ENV_FRAME_PROTOCOL]: FRAME_PROTOCOL_ID,
722
+ [ENV_MAX_FRAME_BYTES]: String(this.maxLineLength),
723
+ }
724
+ : {};
725
+
383
726
  const env: NodeJS.ProcessEnv = {
384
727
  ...baseEnv,
728
+ ...chunkingEnv,
385
729
  // Ensure Python uses UTF-8
386
730
  PYTHONUTF8: '1',
387
731
  PYTHONIOENCODING: 'UTF-8',
@@ -389,6 +733,17 @@ export class SubprocessTransport extends DisposableBase implements Transport {
389
733
  PYTHONUNBUFFERED: '1',
390
734
  };
391
735
 
736
+ // This transport's enableChunking is authoritative: when chunking is OFF,
737
+ // strip any inherited TYWRAP_TRANSPORT_* from the parent environment so a
738
+ // host-level value can't make the bridge negotiate framing the TS side won't
739
+ // reassemble (or a different frame ceiling). When ON, chunkingEnv above
740
+ // already set all three to this transport's values.
741
+ if (!this.enableChunking) {
742
+ delete env[ENV_CHUNKING];
743
+ delete env[ENV_FRAME_PROTOCOL];
744
+ delete env[ENV_MAX_FRAME_BYTES];
745
+ }
746
+
392
747
  // Spawn process
393
748
  this.process = spawn(this.pythonPath, [this.bridgeScript], {
394
749
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -488,9 +843,20 @@ export class SubprocessTransport extends DisposableBase implements Transport {
488
843
  this.stderrBuffer = '';
489
844
  this.requestCount = 0;
490
845
  this.needsRestart = false;
491
-
492
- // Spawn new process
846
+ // Drop any partial reassembly + discard tracking: the new process owns a
847
+ // fresh stdout stream, so stale per-id state from the dead process must not
848
+ // leak across the restart boundary.
849
+ this.responseReassembler = null;
850
+ this.negotiatedChunking = false;
851
+ // The new process owns a fresh stdin stream; reset the write mutex so a
852
+ // pending frame burst against the dead process cannot serialize behind it.
853
+ this.writeMutex = Promise.resolve();
854
+
855
+ // Spawn new process and re-negotiate framing against the fresh bridge.
493
856
  await this.spawnProcess();
857
+ if (this.enableChunking) {
858
+ await this.negotiateChunking();
859
+ }
494
860
  }
495
861
 
496
862
  /**
@@ -506,17 +872,36 @@ export class SubprocessTransport extends DisposableBase implements Transport {
506
872
  // STREAM HANDLERS
507
873
  // ===========================================================================
508
874
 
875
+ /**
876
+ * Effective stdout line ceiling.
877
+ *
878
+ * Without chunking it is exactly {@link maxLineLength} (legacy behavior). With
879
+ * chunking negotiated, a single wire line is a `tywrap-frame/1` envelope whose
880
+ * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
881
+ * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
882
+ * to bound that overhead so a legitimate frame line is never rejected while a
883
+ * runaway/garbage line still is.
884
+ */
885
+ private effectiveLineCeiling(): number {
886
+ if (!this.negotiatedChunking) {
887
+ return this.maxLineLength;
888
+ }
889
+ return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
890
+ }
891
+
509
892
  /**
510
893
  * Handle stdout data from the Python process.
511
894
  */
512
895
  private handleStdoutData(chunk: Buffer | string): void {
513
896
  this.stdoutBuffer += chunk.toString();
514
897
 
898
+ const ceiling = this.effectiveLineCeiling();
899
+
515
900
  // Check for excessive line length without newline
516
- if (this.stdoutBuffer.length > this.maxLineLength && !this.stdoutBuffer.includes('\n')) {
901
+ if (this.stdoutBuffer.length > ceiling && !this.stdoutBuffer.includes('\n')) {
517
902
  const snippet = this.stdoutBuffer.slice(0, 500);
518
903
  this.stdoutBuffer = '';
519
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
904
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
520
905
  return;
521
906
  }
522
907
 
@@ -532,9 +917,9 @@ export class SubprocessTransport extends DisposableBase implements Transport {
532
917
  }
533
918
 
534
919
  // Check line length
535
- if (line.length > this.maxLineLength) {
920
+ if (line.length > ceiling) {
536
921
  const snippet = line.slice(0, 500);
537
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
922
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
538
923
  return;
539
924
  }
540
925
 
@@ -544,8 +929,23 @@ export class SubprocessTransport extends DisposableBase implements Transport {
544
929
 
545
930
  /**
546
931
  * Handle a complete response line from stdout.
932
+ *
933
+ * When chunking is negotiated, a line may be a `tywrap-frame/1` envelope: it is
934
+ * routed into the per-id {@link Reassembler}, which returns the reassembled
935
+ * logical response only once the stream is complete and valid. Single-line
936
+ * (non-frame) responses keep the original fast path unchanged.
547
937
  */
548
938
  private handleResponseLine(line: string): void {
939
+ if (this.negotiatedChunking && this.responseReassembler) {
940
+ const probe = probeFrameLine(line);
941
+ if (probe.kind === 'frame') {
942
+ this.handleResponseFrame(probe.value, line);
943
+ return;
944
+ }
945
+ // probe.kind 'plain'/'invalid' falls through to the legacy single-line
946
+ // path below, which extracts the id and validates JSON as before.
947
+ }
948
+
549
949
  // Extract ID to find pending request
550
950
  const messageId = extractMessageId(line);
551
951
  if (messageId === null) {
@@ -576,6 +976,75 @@ export class SubprocessTransport extends DisposableBase implements Transport {
576
976
  pending.resolve(line);
577
977
  }
578
978
 
979
+ /**
980
+ * Route one `tywrap-frame/1` response frame into the reassembler.
981
+ *
982
+ * On completion the reassembled logical line resolves the pending request. The
983
+ * reassembler validates structure, ordering, byte count, and UTF-8 internally
984
+ * and throws on any framing violation (malformed/duplicate/byte-mismatch/
985
+ * unknown-protocol) — those reject the pending id and mark the subprocess for
986
+ * restart, since stdout can no longer be trusted to be frame-aligned. Frames
987
+ * for a timed-out/aborted id are silently discarded by the reassembler (it
988
+ * returns `null` and tracks the discard set) so late multi-frame responses
989
+ * cannot desync the stream.
990
+ */
991
+ private handleResponseFrame(rawFrame: unknown, line: string): void {
992
+ const reassembler = this.responseReassembler;
993
+ if (!reassembler) {
994
+ // Unreachable: only called when negotiatedChunking && reassembler set.
995
+ this.handleProtocolError('Received frame with no reassembler', line);
996
+ return;
997
+ }
998
+
999
+ const frameId = (rawFrame as { id?: unknown }).id;
1000
+
1001
+ let reassembled: string | null;
1002
+ try {
1003
+ reassembled = reassembler.accept(rawFrame);
1004
+ } catch (err) {
1005
+ // Framing corruption: stdout is no longer frame-aligned. Reject the
1006
+ // correlated pending request (if any) and force a restart.
1007
+ const message = err instanceof Error ? err.message : String(err);
1008
+ this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
1009
+ this.markForRestart();
1010
+ return;
1011
+ }
1012
+
1013
+ if (reassembled === null) {
1014
+ // More frames needed, or this frame belonged to a discarded (timed-out)
1015
+ // id and was dropped. Either way: nothing to resolve yet.
1016
+ return;
1017
+ }
1018
+
1019
+ // Stream complete: resolve the correlated pending request with the single
1020
+ // logical response line. extractMessageId is reused so the resolution path
1021
+ // matches the non-chunked case exactly.
1022
+ this.handleResponseLine(reassembled);
1023
+ }
1024
+
1025
+ /**
1026
+ * Reject the pending request correlated to a frame id, if one exists.
1027
+ *
1028
+ * Used when frame reassembly throws. If the id is unknown (e.g. it already
1029
+ * timed out and was dropped from `pending`), the error still surfaces as a
1030
+ * protocol error so the desync is not swallowed silently.
1031
+ */
1032
+ private rejectFrameId(frameId: unknown, details: string, line: string): void {
1033
+ if (typeof frameId === 'number' && Number.isInteger(frameId)) {
1034
+ const pending = this.pending.get(frameId);
1035
+ if (pending) {
1036
+ this.pending.delete(frameId);
1037
+ if (pending.timer) {
1038
+ clearTimeout(pending.timer);
1039
+ }
1040
+ pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
1041
+ return;
1042
+ }
1043
+ }
1044
+ // No correlated pending request: still a protocol-level desync.
1045
+ this.handleProtocolError(details, line);
1046
+ }
1047
+
579
1048
  /**
580
1049
  * Handle stderr data from the Python process.
581
1050
  */
@@ -691,10 +1160,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
691
1160
  private createQueuedWrite(
692
1161
  data: string,
693
1162
  resolve: () => void,
694
- reject: (error: Error) => void
1163
+ reject: (error: Error) => void,
1164
+ isLive?: () => boolean
695
1165
  ): QueuedWrite {
696
1166
  const queuedAt = Date.now();
697
- const entry: QueuedWrite = { data, resolve, reject, queuedAt };
1167
+ const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive };
698
1168
 
699
1169
  // Set up timeout timer that fires if drain never happens
700
1170
  entry.timeoutHandle = setTimeout(() => {
@@ -731,7 +1201,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
731
1201
  /**
732
1202
  * Write data to stdin with backpressure handling.
733
1203
  */
734
- private writeToStdin(data: string): Promise<void> {
1204
+ private writeToStdin(data: string, isLive?: () => boolean): Promise<void> {
735
1205
  return new Promise<void>((resolve, reject) => {
736
1206
  if (!this.process?.stdin || this.processExited) {
737
1207
  reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
@@ -739,8 +1209,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
739
1209
  }
740
1210
 
741
1211
  if (this.draining || this.writeQueue.length > 0) {
742
- // Queue the write with timestamp and timeout timer
743
- this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
1212
+ // Queue the write with timestamp, timeout timer, and liveness predicate
1213
+ // (checked again at flush — see processQueuedWrite).
1214
+ this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
1215
+ return;
1216
+ }
1217
+
1218
+ // Skip a write whose request was abandoned (timed out/aborted) before it
1219
+ // reached stdin — never execute an operation the caller gave up on.
1220
+ if (isLive && !isLive()) {
1221
+ resolve();
744
1222
  return;
745
1223
  }
746
1224
 
@@ -765,6 +1243,110 @@ export class SubprocessTransport extends DisposableBase implements Transport {
765
1243
  });
766
1244
  }
767
1245
 
1246
+ /**
1247
+ * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
1248
+ * request frames when chunking is negotiated and the encoded request exceeds
1249
+ * the per-frame ceiling (W5 — the mirror of W4's response chunking).
1250
+ *
1251
+ * Both the chunked and single-line paths run under {@link writeMutex} so a
1252
+ * logical request's bytes (one line, or a burst of frames) reach stdin
1253
+ * contiguously: a small request issued concurrently can never interleave
1254
+ * between another request's frames, which would desync the Python
1255
+ * reassembler (it correlates frames by id, but the JSONL stream itself must
1256
+ * stay frame-aligned). The mutex tail is advanced regardless of success so a
1257
+ * failed write never wedges every subsequent request.
1258
+ *
1259
+ * @param message - the encoded logical JSON request (no trailing newline)
1260
+ * @param messageId - the request's correlation id (already validated integer)
1261
+ * @param signal - optional abort signal; an abort observed between frames
1262
+ * stops further frames and rejects this send (the pending entry is rejected
1263
+ * by the abort handler / the caller's `.catch`).
1264
+ */
1265
+ private writeRequest(
1266
+ message: string,
1267
+ messageId: number,
1268
+ signal?: AbortSignal,
1269
+ pendingEntry?: PendingRequest
1270
+ ): Promise<void> {
1271
+ // The request is "live" only while THIS send's exact pending entry is still
1272
+ // registered (the timeout and abort handlers delete it) and the signal is not
1273
+ // aborted. Binding to the entry IDENTITY — not just the id — closes the
1274
+ // id-reuse hole: if the id is recycled by a later send while this write is
1275
+ // still queued, the stale write sees a different entry and is skipped.
1276
+ // (RpcClient ids are monotonic, but Transport.send is public and does not
1277
+ // enforce id uniqueness.) Gating EVERY write point on this — the run closure,
1278
+ // the direct stdin write, the backpressure-queue flush, and each chunked
1279
+ // frame — prevents an abandoned request from executing on Python, even one
1280
+ // whose write sat queued under backpressure past the cancellation.
1281
+ const isLive = (): boolean =>
1282
+ !signal?.aborted &&
1283
+ (pendingEntry !== undefined
1284
+ ? this.pending.get(messageId) === pendingEntry
1285
+ : this.pending.has(messageId));
1286
+ const run = (): Promise<void> => {
1287
+ if (!isLive()) {
1288
+ return Promise.resolve();
1289
+ }
1290
+ // Only chunk when chunking was negotiated AND the encoded request exceeds
1291
+ // the NEGOTIATED per-frame ceiling (which honors the bridge's advertised
1292
+ // maxFrameBytes). Otherwise: one JSONL line, unchanged.
1293
+ if (this.negotiatedChunking && utf8ByteLength(message) > this.negotiatedFrameBytes) {
1294
+ return this.writeChunkedRequest(message, messageId, signal, isLive);
1295
+ }
1296
+ return this.writeToStdin(`${message}\n`, isLive);
1297
+ };
1298
+
1299
+ // Serialize the whole logical write onto the mutex tail. We chain the next
1300
+ // tail off the settled (caught) result so one failed/aborted write does not
1301
+ // poison the chain for later requests.
1302
+ const result = this.writeMutex.then(run);
1303
+ this.writeMutex = result.then(
1304
+ () => undefined,
1305
+ () => undefined
1306
+ );
1307
+ return result;
1308
+ }
1309
+
1310
+ /**
1311
+ * Fragment a logical request into `tywrap-frame/1` request frames and write
1312
+ * them contiguously (one JSONL line per frame). Runs while holding the write
1313
+ * mutex (see {@link writeRequest}), so no other write interleaves.
1314
+ *
1315
+ * Each frame is awaited in turn so backpressure on stdin is respected. Before
1316
+ * every frame the abort signal and process liveness are re-checked: an abort
1317
+ * mid-burst stops the remaining frames and rejects the send LOUD (the Python
1318
+ * reassembler drops the now-incomplete id when the next request's frames /
1319
+ * timeout arrive, exactly as the response side handles a discarded id).
1320
+ */
1321
+ private async writeChunkedRequest(
1322
+ message: string,
1323
+ messageId: number,
1324
+ signal?: AbortSignal,
1325
+ isLive?: () => boolean
1326
+ ): Promise<void> {
1327
+ const frames = encodeFrames(message, {
1328
+ id: messageId,
1329
+ stream: 'request',
1330
+ maxFrameBytes: this.negotiatedFrameBytes,
1331
+ });
1332
+
1333
+ for (const frame of frames) {
1334
+ // Stop the burst if the caller aborted, the request was abandoned (timed
1335
+ // out -> pending deleted, caught by isLive), or the process died between
1336
+ // frames. A partial request stream is dropped by the Python reassembler.
1337
+ if (signal?.aborted || (isLive && !isLive())) {
1338
+ throw new BridgeTimeoutError('Operation aborted');
1339
+ }
1340
+ if (this.processExited || !this.process) {
1341
+ throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
1342
+ }
1343
+ // One frame per JSONL line; await each so stdin backpressure is honored.
1344
+ // isLive gates a frame that ends up queued under backpressure past a late
1345
+ // cancellation, so an abandoned chunked request never completes on Python.
1346
+ await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
1347
+ }
1348
+ }
1349
+
768
1350
  /**
769
1351
  * Reject and clear every entry currently in the write queue.
770
1352
  * Clears each entry's timeout before rejecting so no late timer fires.
@@ -802,6 +1384,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
802
1384
  return 'continue';
803
1385
  }
804
1386
 
1387
+ // Skip the write if the request was abandoned (timed out/aborted) while it
1388
+ // sat in the backpressure queue — never execute an operation the caller gave
1389
+ // up on. Resolve as a no-op so the mutex chain stays healthy.
1390
+ if (queued.isLive && !queued.isLive()) {
1391
+ queued.resolve();
1392
+ return 'continue';
1393
+ }
1394
+
805
1395
  try {
806
1396
  const canWrite = stdin.write(queued.data);
807
1397
 
@@ -878,7 +1468,15 @@ export class SubprocessTransport extends DisposableBase implements Transport {
878
1468
  }
879
1469
 
880
1470
  /**
881
- * Handle a protocol error by rejecting all pending requests.
1471
+ * Handle a protocol error by rejecting all pending requests and marking the
1472
+ * subprocess for restart.
1473
+ *
1474
+ * Every caller represents genuine stdout-stream corruption (a too-long line, a
1475
+ * response with no `id`, a frame with no reassembler, or a truly unexpected id
1476
+ * — benign late responses from timed-out requests are already filtered upstream
1477
+ * via {@link timedOutRequests}). After such an error stdout can no longer be
1478
+ * trusted to be line/frame-aligned, so the process is marked for restart —
1479
+ * matching the frame-reassembly-corruption path and the framing spec.
882
1480
  */
883
1481
  private handleProtocolError(details: string, line?: string): void {
884
1482
  const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
@@ -891,6 +1489,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
891
1489
 
892
1490
  const error = new BridgeProtocolError(msg);
893
1491
  this.rejectAllPending(error);
1492
+ this.markForRestart();
894
1493
  }
895
1494
 
896
1495
  /**