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
@@ -15,7 +15,9 @@
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';
20
+ import { FRAME_PROTOCOL_ID, PROTOCOL_ID, } from './transport.js';
19
21
  // =============================================================================
20
22
  // CONSTANTS
21
23
  // =============================================================================
@@ -27,6 +29,39 @@ const MAX_STDERR_BYTES = 8 * 1024;
27
29
  const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
28
30
  /** Track timed-out/cancelled request IDs long enough to ignore late responses. */
29
31
  const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
32
+ /**
33
+ * Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
34
+ * when sizing the frame-aware stdout line ceiling.
35
+ *
36
+ * A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
37
+ * "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
38
+ * "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
39
+ * plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
40
+ * upper bound that never under-allocates.
41
+ */
42
+ const FRAME_ENVELOPE_HEADROOM = 1024;
43
+ /**
44
+ * Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
45
+ * fragment of a JSON response (already-escaped, printable content), so realistic
46
+ * expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
47
+ * ceiling sound without over-allocating buffer headroom.
48
+ */
49
+ const FRAME_DATA_ESCAPE_FACTOR = 2;
50
+ /** Negotiation env var: `1` enables `tywrap-frame/1` chunked transport. */
51
+ const ENV_CHUNKING = 'TYWRAP_TRANSPORT_CHUNKING';
52
+ /** Negotiation env var: the framing protocol id the bridge must implement. */
53
+ const ENV_FRAME_PROTOCOL = 'TYWRAP_TRANSPORT_FRAME_PROTOCOL';
54
+ /** Negotiation env var: per-frame UTF-8 byte ceiling for the data slice. */
55
+ const ENV_MAX_FRAME_BYTES = 'TYWRAP_TRANSPORT_MAX_FRAME_BYTES';
56
+ /** Timeout (ms) for the in-init meta negotiation probe. */
57
+ const NEGOTIATION_PROBE_TIMEOUT_MS = 30_000;
58
+ /**
59
+ * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
60
+ * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
61
+ * buffers more than the codec would ultimately accept; `NodeBridge` overrides it
62
+ * with the configured `codec.maxPayloadBytes`.
63
+ */
64
+ const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
30
65
  /** Regex for ANSI escape sequences */
31
66
  const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
32
67
  /** Regex for control characters */
@@ -61,6 +96,26 @@ function extractMessageId(json) {
61
96
  }
62
97
  return id;
63
98
  }
99
+ /**
100
+ * Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
101
+ *
102
+ * A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
103
+ * envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
104
+ * by the {@link Reassembler}, not here — this only routes the line.
105
+ */
106
+ function probeFrameLine(line) {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(line);
110
+ }
111
+ catch {
112
+ return { kind: 'invalid' };
113
+ }
114
+ if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
115
+ return { kind: 'frame', value: parsed };
116
+ }
117
+ return { kind: 'plain' };
118
+ }
64
119
  // =============================================================================
65
120
  // PROCESS IO TRANSPORT
66
121
  // =============================================================================
@@ -96,8 +151,31 @@ export class SubprocessTransport extends DisposableBase {
96
151
  envOverrides;
97
152
  cwd;
98
153
  maxLineLength;
154
+ maxReassemblyBytes;
99
155
  restartAfterRequests;
100
156
  writeQueueTimeoutMs;
157
+ /** Whether `tywrap-frame/1` negotiation was requested by the caller. */
158
+ enableChunking;
159
+ /**
160
+ * Whether the bridge advertised chunking during the init meta probe. Only
161
+ * `true` after a successful negotiation; drives {@link capabilities} and the
162
+ * frame-aware stdout line ceiling.
163
+ */
164
+ negotiatedChunking = false;
165
+ /**
166
+ * The per-frame UTF-8 byte ceiling actually agreed with the bridge:
167
+ * `min(maxLineLength, the bridge's advertised maxFrameBytes)`. Defaults to
168
+ * `maxLineLength` and is narrowed during negotiation, so REQUEST frames are
169
+ * never sized larger than the bridge said it will accept (the reference bridge
170
+ * echoes the requested value, but a custom bridge may advertise a smaller one).
171
+ */
172
+ negotiatedFrameBytes;
173
+ /**
174
+ * Reassembles `tywrap-frame/1` response frames into single logical response
175
+ * lines. Constructed lazily once chunking is negotiated; per-id discard tracks
176
+ * timed-out/aborted streams so late frames cannot desync stdout.
177
+ */
178
+ responseReassembler = null;
101
179
  // Process state
102
180
  process = null;
103
181
  processExited = false;
@@ -115,6 +193,16 @@ export class SubprocessTransport extends DisposableBase {
115
193
  // Write queue for backpressure
116
194
  writeQueue = [];
117
195
  draining = false;
196
+ /**
197
+ * Per-logical-request write mutex (W5). When a request is chunked into
198
+ * `tywrap-frame/1` frames, all of that request's frames must reach stdin
199
+ * contiguously — no other request's frame (or single line) may interleave —
200
+ * or the Python reassembler would see frames from two ids mixed on one stream.
201
+ * `writeChunkedRequest` chains the whole frame burst onto this tail; the
202
+ * single-line write path also serializes behind it so a small request issued
203
+ * concurrently never slips between another request's frames.
204
+ */
205
+ writeMutex = Promise.resolve();
118
206
  /**
119
207
  * Create a new SubprocessTransport.
120
208
  *
@@ -129,6 +217,9 @@ export class SubprocessTransport extends DisposableBase {
129
217
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
130
218
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
131
219
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
220
+ this.enableChunking = options.enableChunking ?? false;
221
+ this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
222
+ this.negotiatedFrameBytes = this.maxLineLength;
132
223
  }
133
224
  // ===========================================================================
134
225
  // TRANSPORT INTERFACE
@@ -175,25 +266,36 @@ export class SubprocessTransport extends DisposableBase {
175
266
  return new Promise((resolve, reject) => {
176
267
  // Set up timeout if specified
177
268
  let timer;
269
+ // Defined before the timer so the timeout path can also detach it.
270
+ const abortHandler = () => {
271
+ if (timer) {
272
+ clearTimeout(timer);
273
+ }
274
+ this.pending.delete(messageId);
275
+ this.timedOutRequests.mark(messageId);
276
+ // Same late-frame discard as the timeout path (see below).
277
+ this.responseReassembler?.discard(messageId);
278
+ reject(new BridgeTimeoutError('Operation aborted'));
279
+ };
178
280
  if (timeoutMs > 0) {
179
281
  timer = setTimeout(() => {
180
282
  this.pending.delete(messageId);
181
283
  this.timedOutRequests.mark(messageId);
284
+ // Discard any in-flight chunked response stream for this id so late
285
+ // frames are dropped rather than desyncing stdout (the single-line
286
+ // timedOutRequests.consume above is one-shot and insufficient for a
287
+ // multi-frame stream).
288
+ this.responseReassembler?.discard(messageId);
289
+ // Detach the abort listener: on timeout the abort never fires, so
290
+ // without this it would leak on a long-lived AbortSignal and could
291
+ // re-enter abortHandler after this promise has already settled.
292
+ signal?.removeEventListener('abort', abortHandler);
182
293
  const stderrTail = this.getStderrTail();
183
294
  const baseMsg = `Operation timed out after ${timeoutMs}ms`;
184
295
  const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
185
296
  reject(new BridgeTimeoutError(msg));
186
297
  }, timeoutMs);
187
298
  }
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
299
  if (signal) {
198
300
  signal.addEventListener('abort', abortHandler, { once: true });
199
301
  }
@@ -212,14 +314,21 @@ export class SubprocessTransport extends DisposableBase {
212
314
  signal?.removeEventListener('abort', abortHandler);
213
315
  reject(error);
214
316
  };
215
- // Register pending request
216
- this.pending.set(messageId, {
317
+ // Register pending request. Captured by reference so the write path can
318
+ // bind liveness to THIS exact entry (not just the id) — see writeRequest.
319
+ const pendingEntry = {
217
320
  resolve: wrappedResolve,
218
321
  reject: wrappedReject,
219
322
  timer,
220
- });
221
- // Write message to stdin
222
- this.writeToStdin(`${message}\n`).catch(err => {
323
+ };
324
+ this.pending.set(messageId, pendingEntry);
325
+ // Write message to stdin. When chunking is negotiated and the encoded
326
+ // request exceeds the negotiated per-frame ceiling, it is fragmented into
327
+ // `tywrap-frame/1` request frames written contiguously under the write
328
+ // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
329
+ // serialize through the same mutex so a small request can never slip
330
+ // between another request's frames.
331
+ this.writeRequest(message, messageId, signal, pendingEntry).catch(err => {
223
332
  this.pending.delete(messageId);
224
333
  if (timer) {
225
334
  clearTimeout(timer);
@@ -230,14 +339,160 @@ export class SubprocessTransport extends DisposableBase {
230
339
  this.requestCount++;
231
340
  });
232
341
  }
342
+ /**
343
+ * Static capability descriptor for the subprocess backend.
344
+ *
345
+ * Per the {@link Transport.capabilities} contract this is lifecycle-independent
346
+ * (safe before `init()` / after `dispose()`) and never makes a Python round
347
+ * trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
348
+ * over the JSONL stream. `supportsChunking` reports the *configured* capability
349
+ * — `this.enableChunking`, i.e. whether this transport is set up to use the
350
+ * `tywrap-frame/1` framing path — exactly as `supportsArrow` reports a static
351
+ * channel capability rather than a runtime fact. Whether the *connected* bridge
352
+ * actually advertised framing is the negotiated fact, surfaced separately on
353
+ * `BridgeInfo.transport.supportsChunking`; "will chunking actually happen"
354
+ * needs both `true`. `supportsStreaming` stays `false` (0.8.0). `maxFrameBytes`
355
+ * is the configured JSONL line-length limit — the largest single (unchunked)
356
+ * response line this transport accepts.
357
+ */
358
+ capabilities() {
359
+ return {
360
+ backend: 'subprocess',
361
+ supportsArrow: true,
362
+ supportsBinary: true,
363
+ supportsChunking: this.enableChunking,
364
+ supportsStreaming: false,
365
+ maxFrameBytes: this.maxLineLength,
366
+ };
367
+ }
233
368
  // ===========================================================================
234
369
  // BOUNDED CONTEXT LIFECYCLE
235
370
  // ===========================================================================
236
371
  /**
237
- * Initialize the transport by spawning the Python process.
372
+ * Initialize the transport by spawning the Python process and, when chunking
373
+ * is enabled, negotiating `tywrap-frame/1` via a small unchunked `meta` probe.
238
374
  */
239
375
  async doInit() {
240
376
  await this.spawnProcess();
377
+ if (this.enableChunking) {
378
+ await this.negotiateChunking();
379
+ }
380
+ }
381
+ /**
382
+ * Probe the freshly-spawned bridge for `tywrap-frame/1` support.
383
+ *
384
+ * Sends a small unchunked `meta` request directly over stdin (NOT via the
385
+ * public {@link send}, which would re-enter init while we are mid-init) and
386
+ * reads the single-line response. If the bridge reports
387
+ * `transport.supportsChunking: true`, response reassembly is enabled. If the
388
+ * bridge does not advertise chunking (old bridge, or it disabled framing), the
389
+ * transport stays single-frame and an oversize response still fails loud — no
390
+ * silent fallback. A probe failure leaves chunking disabled but does not fail
391
+ * init (small calls must keep working); the loud failure is deferred to the
392
+ * first oversize response.
393
+ */
394
+ async negotiateChunking() {
395
+ const probeId = -1;
396
+ const probeMessage = JSON.stringify({
397
+ id: probeId,
398
+ protocol: PROTOCOL_ID,
399
+ method: 'meta',
400
+ params: {},
401
+ });
402
+ let responseLine;
403
+ try {
404
+ responseLine = await this.sendProbe(probeId, probeMessage, NEGOTIATION_PROBE_TIMEOUT_MS);
405
+ }
406
+ catch {
407
+ // Probe failed (slow/old bridge, transient): leave chunking off. Oversize
408
+ // responses will fail loud at the line ceiling; small calls keep working.
409
+ this.negotiatedChunking = false;
410
+ return;
411
+ }
412
+ let parsed;
413
+ try {
414
+ parsed = JSON.parse(responseLine);
415
+ }
416
+ catch {
417
+ this.negotiatedChunking = false;
418
+ return;
419
+ }
420
+ const supports = this.bridgeAdvertisesChunking(parsed);
421
+ this.negotiatedChunking = supports;
422
+ if (supports) {
423
+ // Honor the bridge's advertised ceiling so request frames are never sized
424
+ // larger than it will accept. bridgeAdvertisesChunking already validated
425
+ // maxFrameBytes is a positive integer, so this read is safe.
426
+ const advertised = parsed.result
427
+ ?.transport?.maxFrameBytes;
428
+ if (typeof advertised === 'number') {
429
+ this.negotiatedFrameBytes = Math.min(this.maxLineLength, advertised);
430
+ }
431
+ this.responseReassembler = new Reassembler({
432
+ maxReassemblyBytes: this.maxReassemblyBytes,
433
+ expectedStream: 'response',
434
+ });
435
+ }
436
+ }
437
+ /**
438
+ * Whether a parsed `meta` response advertises `tywrap-frame/1` chunking with a
439
+ * matching frame protocol. Defensive: any missing/mismatched field => `false`.
440
+ */
441
+ bridgeAdvertisesChunking(parsed) {
442
+ if (parsed === null || typeof parsed !== 'object') {
443
+ return false;
444
+ }
445
+ const result = parsed.result;
446
+ if (result === null || typeof result !== 'object') {
447
+ return false;
448
+ }
449
+ const transport = result.transport;
450
+ if (transport === null || typeof transport !== 'object') {
451
+ return false;
452
+ }
453
+ const t = transport;
454
+ // Match the BridgeInfo validator (rpc-client.ts): a valid framing block needs
455
+ // the matching protocol AND a positive-integer maxFrameBytes. A bridge that
456
+ // advertises chunking with a bogus frame ceiling is not a contract we honor.
457
+ return (t.supportsChunking === true &&
458
+ t.frameProtocol === FRAME_PROTOCOL_ID &&
459
+ typeof t.maxFrameBytes === 'number' &&
460
+ Number.isInteger(t.maxFrameBytes) &&
461
+ t.maxFrameBytes > 0);
462
+ }
463
+ /**
464
+ * Send a single in-init probe message over stdin and resolve with its raw
465
+ * response line. Registers a pending entry keyed by `probeId` exactly like the
466
+ * normal send path so {@link handleResponseLine} resolves it, but bypasses the
467
+ * `isReady`/auto-init guard (we are deliberately running during `doInit`).
468
+ */
469
+ sendProbe(probeId, message, timeoutMs) {
470
+ return new Promise((resolve, reject) => {
471
+ const timer = setTimeout(() => {
472
+ this.pending.delete(probeId);
473
+ this.timedOutRequests.mark(probeId);
474
+ reject(new BridgeTimeoutError(`Negotiation probe timed out after ${timeoutMs}ms`));
475
+ }, timeoutMs);
476
+ if (typeof timer.unref === 'function') {
477
+ timer.unref();
478
+ }
479
+ this.pending.set(probeId, {
480
+ resolve: (value) => {
481
+ clearTimeout(timer);
482
+ resolve(value);
483
+ },
484
+ reject: (error) => {
485
+ clearTimeout(timer);
486
+ reject(error);
487
+ },
488
+ timer,
489
+ });
490
+ this.writeToStdin(`${message}\n`).catch(err => {
491
+ this.pending.delete(probeId);
492
+ clearTimeout(timer);
493
+ reject(this.classifyError(err));
494
+ });
495
+ });
241
496
  }
242
497
  /**
243
498
  * Dispose the transport by killing the Python process.
@@ -267,6 +522,11 @@ export class SubprocessTransport extends DisposableBase {
267
522
  this.stderrBuffer = '';
268
523
  this.timedOutRequests.clear();
269
524
  this.requestCount = 0;
525
+ this.responseReassembler = null;
526
+ this.negotiatedChunking = false;
527
+ // Reset the per-request write mutex so a disposed transport starts from a
528
+ // clean (resolved) tail if reused.
529
+ this.writeMutex = Promise.resolve();
270
530
  }
271
531
  // ===========================================================================
272
532
  // PROCESS MANAGEMENT
@@ -279,14 +539,39 @@ export class SubprocessTransport extends DisposableBase {
279
539
  // If env is provided, it should be the complete environment (already filtered by NodeBridge)
280
540
  // We only add Python-specific variables on top
281
541
  const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
542
+ // Advertise `tywrap-frame/1` chunked transport so the bridge fragments
543
+ // oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
544
+ // caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
545
+ // (see the frame-aware line ceiling) allows for the JSON envelope + escaping
546
+ // on top of it. Spread (rather than dynamic index assignment) keeps the
547
+ // computed keys off ESLint's object-injection sink. See
548
+ // docs/transport-framing.md.
549
+ const chunkingEnv = this.enableChunking
550
+ ? {
551
+ [ENV_CHUNKING]: '1',
552
+ [ENV_FRAME_PROTOCOL]: FRAME_PROTOCOL_ID,
553
+ [ENV_MAX_FRAME_BYTES]: String(this.maxLineLength),
554
+ }
555
+ : {};
282
556
  const env = {
283
557
  ...baseEnv,
558
+ ...chunkingEnv,
284
559
  // Ensure Python uses UTF-8
285
560
  PYTHONUTF8: '1',
286
561
  PYTHONIOENCODING: 'UTF-8',
287
562
  // Disable Python buffering
288
563
  PYTHONUNBUFFERED: '1',
289
564
  };
565
+ // This transport's enableChunking is authoritative: when chunking is OFF,
566
+ // strip any inherited TYWRAP_TRANSPORT_* from the parent environment so a
567
+ // host-level value can't make the bridge negotiate framing the TS side won't
568
+ // reassemble (or a different frame ceiling). When ON, chunkingEnv above
569
+ // already set all three to this transport's values.
570
+ if (!this.enableChunking) {
571
+ delete env[ENV_CHUNKING];
572
+ delete env[ENV_FRAME_PROTOCOL];
573
+ delete env[ENV_MAX_FRAME_BYTES];
574
+ }
290
575
  // Spawn process
291
576
  this.process = spawn(this.pythonPath, [this.bridgeScript], {
292
577
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -371,8 +656,19 @@ export class SubprocessTransport extends DisposableBase {
371
656
  this.stderrBuffer = '';
372
657
  this.requestCount = 0;
373
658
  this.needsRestart = false;
374
- // Spawn new process
659
+ // Drop any partial reassembly + discard tracking: the new process owns a
660
+ // fresh stdout stream, so stale per-id state from the dead process must not
661
+ // leak across the restart boundary.
662
+ this.responseReassembler = null;
663
+ this.negotiatedChunking = false;
664
+ // The new process owns a fresh stdin stream; reset the write mutex so a
665
+ // pending frame burst against the dead process cannot serialize behind it.
666
+ this.writeMutex = Promise.resolve();
667
+ // Spawn new process and re-negotiate framing against the fresh bridge.
375
668
  await this.spawnProcess();
669
+ if (this.enableChunking) {
670
+ await this.negotiateChunking();
671
+ }
376
672
  }
377
673
  /**
378
674
  * Mark the process for restart on the next send.
@@ -385,16 +681,33 @@ export class SubprocessTransport extends DisposableBase {
385
681
  // ===========================================================================
386
682
  // STREAM HANDLERS
387
683
  // ===========================================================================
684
+ /**
685
+ * Effective stdout line ceiling.
686
+ *
687
+ * Without chunking it is exactly {@link maxLineLength} (legacy behavior). With
688
+ * chunking negotiated, a single wire line is a `tywrap-frame/1` envelope whose
689
+ * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
690
+ * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
691
+ * to bound that overhead so a legitimate frame line is never rejected while a
692
+ * runaway/garbage line still is.
693
+ */
694
+ effectiveLineCeiling() {
695
+ if (!this.negotiatedChunking) {
696
+ return this.maxLineLength;
697
+ }
698
+ return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
699
+ }
388
700
  /**
389
701
  * Handle stdout data from the Python process.
390
702
  */
391
703
  handleStdoutData(chunk) {
392
704
  this.stdoutBuffer += chunk.toString();
705
+ const ceiling = this.effectiveLineCeiling();
393
706
  // Check for excessive line length without newline
394
- if (this.stdoutBuffer.length > this.maxLineLength && !this.stdoutBuffer.includes('\n')) {
707
+ if (this.stdoutBuffer.length > ceiling && !this.stdoutBuffer.includes('\n')) {
395
708
  const snippet = this.stdoutBuffer.slice(0, 500);
396
709
  this.stdoutBuffer = '';
397
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
710
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
398
711
  return;
399
712
  }
400
713
  // Process complete lines
@@ -407,9 +720,9 @@ export class SubprocessTransport extends DisposableBase {
407
720
  continue;
408
721
  }
409
722
  // Check line length
410
- if (line.length > this.maxLineLength) {
723
+ if (line.length > ceiling) {
411
724
  const snippet = line.slice(0, 500);
412
- this.handleProtocolError(`Response line exceeded ${this.maxLineLength} bytes`, snippet);
725
+ this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
413
726
  return;
414
727
  }
415
728
  this.handleResponseLine(line);
@@ -417,8 +730,22 @@ export class SubprocessTransport extends DisposableBase {
417
730
  }
418
731
  /**
419
732
  * Handle a complete response line from stdout.
733
+ *
734
+ * When chunking is negotiated, a line may be a `tywrap-frame/1` envelope: it is
735
+ * routed into the per-id {@link Reassembler}, which returns the reassembled
736
+ * logical response only once the stream is complete and valid. Single-line
737
+ * (non-frame) responses keep the original fast path unchanged.
420
738
  */
421
739
  handleResponseLine(line) {
740
+ if (this.negotiatedChunking && this.responseReassembler) {
741
+ const probe = probeFrameLine(line);
742
+ if (probe.kind === 'frame') {
743
+ this.handleResponseFrame(probe.value, line);
744
+ return;
745
+ }
746
+ // probe.kind 'plain'/'invalid' falls through to the legacy single-line
747
+ // path below, which extracts the id and validates JSON as before.
748
+ }
422
749
  // Extract ID to find pending request
423
750
  const messageId = extractMessageId(line);
424
751
  if (messageId === null) {
@@ -444,6 +771,70 @@ export class SubprocessTransport extends DisposableBase {
444
771
  // Resolve with raw response
445
772
  pending.resolve(line);
446
773
  }
774
+ /**
775
+ * Route one `tywrap-frame/1` response frame into the reassembler.
776
+ *
777
+ * On completion the reassembled logical line resolves the pending request. The
778
+ * reassembler validates structure, ordering, byte count, and UTF-8 internally
779
+ * and throws on any framing violation (malformed/duplicate/byte-mismatch/
780
+ * unknown-protocol) — those reject the pending id and mark the subprocess for
781
+ * restart, since stdout can no longer be trusted to be frame-aligned. Frames
782
+ * for a timed-out/aborted id are silently discarded by the reassembler (it
783
+ * returns `null` and tracks the discard set) so late multi-frame responses
784
+ * cannot desync the stream.
785
+ */
786
+ handleResponseFrame(rawFrame, line) {
787
+ const reassembler = this.responseReassembler;
788
+ if (!reassembler) {
789
+ // Unreachable: only called when negotiatedChunking && reassembler set.
790
+ this.handleProtocolError('Received frame with no reassembler', line);
791
+ return;
792
+ }
793
+ const frameId = rawFrame.id;
794
+ let reassembled;
795
+ try {
796
+ reassembled = reassembler.accept(rawFrame);
797
+ }
798
+ catch (err) {
799
+ // Framing corruption: stdout is no longer frame-aligned. Reject the
800
+ // correlated pending request (if any) and force a restart.
801
+ const message = err instanceof Error ? err.message : String(err);
802
+ this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
803
+ this.markForRestart();
804
+ return;
805
+ }
806
+ if (reassembled === null) {
807
+ // More frames needed, or this frame belonged to a discarded (timed-out)
808
+ // id and was dropped. Either way: nothing to resolve yet.
809
+ return;
810
+ }
811
+ // Stream complete: resolve the correlated pending request with the single
812
+ // logical response line. extractMessageId is reused so the resolution path
813
+ // matches the non-chunked case exactly.
814
+ this.handleResponseLine(reassembled);
815
+ }
816
+ /**
817
+ * Reject the pending request correlated to a frame id, if one exists.
818
+ *
819
+ * Used when frame reassembly throws. If the id is unknown (e.g. it already
820
+ * timed out and was dropped from `pending`), the error still surfaces as a
821
+ * protocol error so the desync is not swallowed silently.
822
+ */
823
+ rejectFrameId(frameId, details, line) {
824
+ if (typeof frameId === 'number' && Number.isInteger(frameId)) {
825
+ const pending = this.pending.get(frameId);
826
+ if (pending) {
827
+ this.pending.delete(frameId);
828
+ if (pending.timer) {
829
+ clearTimeout(pending.timer);
830
+ }
831
+ pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
832
+ return;
833
+ }
834
+ }
835
+ // No correlated pending request: still a protocol-level desync.
836
+ this.handleProtocolError(details, line);
837
+ }
447
838
  /**
448
839
  * Handle stderr data from the Python process.
449
840
  */
@@ -541,9 +932,9 @@ export class SubprocessTransport extends DisposableBase {
541
932
  * Create a queued write entry with a timeout timer.
542
933
  * The timer fires if the drain event never comes.
543
934
  */
544
- createQueuedWrite(data, resolve, reject) {
935
+ createQueuedWrite(data, resolve, reject, isLive) {
545
936
  const queuedAt = Date.now();
546
- const entry = { data, resolve, reject, queuedAt };
937
+ const entry = { data, resolve, reject, queuedAt, isLive };
547
938
  // Set up timeout timer that fires if drain never happens
548
939
  entry.timeoutHandle = setTimeout(() => {
549
940
  // Remove this entry from the queue
@@ -571,15 +962,22 @@ export class SubprocessTransport extends DisposableBase {
571
962
  /**
572
963
  * Write data to stdin with backpressure handling.
573
964
  */
574
- writeToStdin(data) {
965
+ writeToStdin(data, isLive) {
575
966
  return new Promise((resolve, reject) => {
576
967
  if (!this.process?.stdin || this.processExited) {
577
968
  reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
578
969
  return;
579
970
  }
580
971
  if (this.draining || this.writeQueue.length > 0) {
581
- // Queue the write with timestamp and timeout timer
582
- this.writeQueue.push(this.createQueuedWrite(data, resolve, reject));
972
+ // Queue the write with timestamp, timeout timer, and liveness predicate
973
+ // (checked again at flush — see processQueuedWrite).
974
+ this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
975
+ return;
976
+ }
977
+ // Skip a write whose request was abandoned (timed out/aborted) before it
978
+ // reached stdin — never execute an operation the caller gave up on.
979
+ if (isLive && !isLive()) {
980
+ resolve();
583
981
  return;
584
982
  }
585
983
  // Try direct write (wrap in try-catch for synchronous EPIPE errors)
@@ -603,6 +1001,92 @@ export class SubprocessTransport extends DisposableBase {
603
1001
  }
604
1002
  });
605
1003
  }
1004
+ /**
1005
+ * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
1006
+ * request frames when chunking is negotiated and the encoded request exceeds
1007
+ * the per-frame ceiling (W5 — the mirror of W4's response chunking).
1008
+ *
1009
+ * Both the chunked and single-line paths run under {@link writeMutex} so a
1010
+ * logical request's bytes (one line, or a burst of frames) reach stdin
1011
+ * contiguously: a small request issued concurrently can never interleave
1012
+ * between another request's frames, which would desync the Python
1013
+ * reassembler (it correlates frames by id, but the JSONL stream itself must
1014
+ * stay frame-aligned). The mutex tail is advanced regardless of success so a
1015
+ * failed write never wedges every subsequent request.
1016
+ *
1017
+ * @param message - the encoded logical JSON request (no trailing newline)
1018
+ * @param messageId - the request's correlation id (already validated integer)
1019
+ * @param signal - optional abort signal; an abort observed between frames
1020
+ * stops further frames and rejects this send (the pending entry is rejected
1021
+ * by the abort handler / the caller's `.catch`).
1022
+ */
1023
+ writeRequest(message, messageId, signal, pendingEntry) {
1024
+ // The request is "live" only while THIS send's exact pending entry is still
1025
+ // registered (the timeout and abort handlers delete it) and the signal is not
1026
+ // aborted. Binding to the entry IDENTITY — not just the id — closes the
1027
+ // id-reuse hole: if the id is recycled by a later send while this write is
1028
+ // still queued, the stale write sees a different entry and is skipped.
1029
+ // (RpcClient ids are monotonic, but Transport.send is public and does not
1030
+ // enforce id uniqueness.) Gating EVERY write point on this — the run closure,
1031
+ // the direct stdin write, the backpressure-queue flush, and each chunked
1032
+ // frame — prevents an abandoned request from executing on Python, even one
1033
+ // whose write sat queued under backpressure past the cancellation.
1034
+ const isLive = () => !signal?.aborted &&
1035
+ (pendingEntry !== undefined
1036
+ ? this.pending.get(messageId) === pendingEntry
1037
+ : this.pending.has(messageId));
1038
+ const run = () => {
1039
+ if (!isLive()) {
1040
+ return Promise.resolve();
1041
+ }
1042
+ // Only chunk when chunking was negotiated AND the encoded request exceeds
1043
+ // the NEGOTIATED per-frame ceiling (which honors the bridge's advertised
1044
+ // maxFrameBytes). Otherwise: one JSONL line, unchanged.
1045
+ if (this.negotiatedChunking && utf8ByteLength(message) > this.negotiatedFrameBytes) {
1046
+ return this.writeChunkedRequest(message, messageId, signal, isLive);
1047
+ }
1048
+ return this.writeToStdin(`${message}\n`, isLive);
1049
+ };
1050
+ // Serialize the whole logical write onto the mutex tail. We chain the next
1051
+ // tail off the settled (caught) result so one failed/aborted write does not
1052
+ // poison the chain for later requests.
1053
+ const result = this.writeMutex.then(run);
1054
+ this.writeMutex = result.then(() => undefined, () => undefined);
1055
+ return result;
1056
+ }
1057
+ /**
1058
+ * Fragment a logical request into `tywrap-frame/1` request frames and write
1059
+ * them contiguously (one JSONL line per frame). Runs while holding the write
1060
+ * mutex (see {@link writeRequest}), so no other write interleaves.
1061
+ *
1062
+ * Each frame is awaited in turn so backpressure on stdin is respected. Before
1063
+ * every frame the abort signal and process liveness are re-checked: an abort
1064
+ * mid-burst stops the remaining frames and rejects the send LOUD (the Python
1065
+ * reassembler drops the now-incomplete id when the next request's frames /
1066
+ * timeout arrive, exactly as the response side handles a discarded id).
1067
+ */
1068
+ async writeChunkedRequest(message, messageId, signal, isLive) {
1069
+ const frames = encodeFrames(message, {
1070
+ id: messageId,
1071
+ stream: 'request',
1072
+ maxFrameBytes: this.negotiatedFrameBytes,
1073
+ });
1074
+ for (const frame of frames) {
1075
+ // Stop the burst if the caller aborted, the request was abandoned (timed
1076
+ // out -> pending deleted, caught by isLive), or the process died between
1077
+ // frames. A partial request stream is dropped by the Python reassembler.
1078
+ if (signal?.aborted || (isLive && !isLive())) {
1079
+ throw new BridgeTimeoutError('Operation aborted');
1080
+ }
1081
+ if (this.processExited || !this.process) {
1082
+ throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
1083
+ }
1084
+ // One frame per JSONL line; await each so stdin backpressure is honored.
1085
+ // isLive gates a frame that ends up queued under backpressure past a late
1086
+ // cancellation, so an abandoned chunked request never completes on Python.
1087
+ await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
1088
+ }
1089
+ }
606
1090
  /**
607
1091
  * Reject and clear every entry currently in the write queue.
608
1092
  * Clears each entry's timeout before rejecting so no late timer fires.
@@ -630,6 +1114,13 @@ export class SubprocessTransport extends DisposableBase {
630
1114
  queued.reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`));
631
1115
  return 'continue';
632
1116
  }
1117
+ // Skip the write if the request was abandoned (timed out/aborted) while it
1118
+ // sat in the backpressure queue — never execute an operation the caller gave
1119
+ // up on. Resolve as a no-op so the mutex chain stays healthy.
1120
+ if (queued.isLive && !queued.isLive()) {
1121
+ queued.resolve();
1122
+ return 'continue';
1123
+ }
633
1124
  try {
634
1125
  const canWrite = stdin.write(queued.data);
635
1126
  if (canWrite) {
@@ -694,7 +1185,15 @@ export class SubprocessTransport extends DisposableBase {
694
1185
  return stderrTail ? `${message}. Stderr:\n${stderrTail}` : message;
695
1186
  }
696
1187
  /**
697
- * Handle a protocol error by rejecting all pending requests.
1188
+ * Handle a protocol error by rejecting all pending requests and marking the
1189
+ * subprocess for restart.
1190
+ *
1191
+ * Every caller represents genuine stdout-stream corruption (a too-long line, a
1192
+ * response with no `id`, a frame with no reassembler, or a truly unexpected id
1193
+ * — benign late responses from timed-out requests are already filtered upstream
1194
+ * via {@link timedOutRequests}). After such an error stdout can no longer be
1195
+ * trusted to be line/frame-aligned, so the process is marked for restart —
1196
+ * matching the frame-reassembly-corruption path and the framing spec.
698
1197
  */
699
1198
  handleProtocolError(details, line) {
700
1199
  const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
@@ -704,6 +1203,7 @@ export class SubprocessTransport extends DisposableBase {
704
1203
  : `Protocol error: ${details}\n${hint}`;
705
1204
  const error = new BridgeProtocolError(msg);
706
1205
  this.rejectAllPending(error);
1206
+ this.markForRestart();
707
1207
  }
708
1208
  /**
709
1209
  * Reject all pending requests with an error.