tywrap 0.8.0 → 0.10.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 (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -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/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. package/src/runtime/transport-pool.ts +0 -538
@@ -19,12 +19,7 @@ import { DisposableBase } from './bounded-context.js';
19
19
  import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
20
20
  import { Reassembler, encodeFrames, utf8ByteLength } from './frame-codec.js';
21
21
  import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
22
- import {
23
- FRAME_PROTOCOL_ID,
24
- PROTOCOL_ID,
25
- type Transport,
26
- type TransportCapabilities,
27
- } from './transport.js';
22
+ import { type Transport, type TransportCapabilities } from './transport.js';
28
23
 
29
24
  // =============================================================================
30
25
  // CONSTANTS
@@ -62,16 +57,6 @@ const FRAME_ENVELOPE_HEADROOM = 1024;
62
57
  */
63
58
  const FRAME_DATA_ESCAPE_FACTOR = 2;
64
59
 
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
60
  /**
76
61
  * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
77
62
  * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
@@ -115,19 +100,6 @@ export interface SubprocessTransportOptions {
115
100
  /** Write queue timeout in milliseconds. Default: 30000ms */
116
101
  writeQueueTimeoutMs?: number;
117
102
 
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
103
  /**
132
104
  * Cap (UTF-8 bytes) on a single chunked RESPONSE reassembled in memory. A
133
105
  * frame stream whose declared or accumulated payload exceeds this fails loud
@@ -165,6 +137,8 @@ interface QueuedWrite {
165
137
  * (and so never executes on) Python.
166
138
  */
167
139
  isLive?: () => boolean;
140
+ /** True while this entry is waiting to be flushed. */
141
+ isQueued?: boolean;
168
142
  }
169
143
 
170
144
  // =============================================================================
@@ -272,28 +246,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
272
246
  private readonly restartAfterRequests: number;
273
247
  private readonly writeQueueTimeoutMs: number;
274
248
 
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
249
  /**
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).
250
+ * Per-frame UTF-8 data ceiling. The packaged Python peer receives this
251
+ * transport's `maxLineLength` when it is spawned, so request and response
252
+ * framing use the same fixed ceiling without runtime negotiation.
291
253
  */
292
- private negotiatedFrameBytes: number;
254
+ private readonly frameBytes: number;
293
255
 
294
256
  /**
295
257
  * Reassembles `tywrap-frame/1` response frames into single logical response
296
- * lines. Constructed lazily once chunking is negotiated; per-id discard tracks
258
+ * lines. Per-id discard tracks
297
259
  * timed-out/aborted streams so late frames cannot desync stdout.
298
260
  */
299
261
  private responseReassembler: Reassembler | null = null;
@@ -305,6 +267,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
305
267
 
306
268
  // Stream buffers
307
269
  private stdoutBuffer = '';
270
+ private stdoutScanOffset = 0;
308
271
  private stderrBuffer = '';
309
272
 
310
273
  // Request tracking
@@ -314,9 +277,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
314
277
  });
315
278
  private requestCount = 0;
316
279
  private needsRestart = false;
280
+ /** Serializes restart decisions with dispatch reservations. */
281
+ private dispatchMutex: Promise<void> = Promise.resolve();
282
+ private activeDispatches = 0;
283
+ private readonly dispatchDrainWaiters: Array<() => void> = [];
317
284
 
318
285
  // Write queue for backpressure
319
286
  private readonly writeQueue: QueuedWrite[] = [];
287
+ private writeQueueHead = 0;
320
288
  private draining = false;
321
289
 
322
290
  /**
@@ -345,9 +313,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
345
313
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
346
314
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
347
315
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
348
- this.enableChunking = options.enableChunking ?? false;
349
316
  this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
350
- this.negotiatedFrameBytes = this.maxLineLength;
317
+ this.frameBytes = this.maxLineLength;
318
+ this.responseReassembler = new Reassembler({
319
+ maxReassemblyBytes: this.maxReassemblyBytes,
320
+ expectedStream: 'response',
321
+ });
351
322
  }
352
323
 
353
324
  // ===========================================================================
@@ -362,7 +333,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
362
333
  * @param signal - Optional AbortSignal for cancellation
363
334
  * @returns The raw JSON response string
364
335
  */
365
- async send(message: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
336
+ async send(
337
+ message: string,
338
+ timeoutMs: number,
339
+ signal?: AbortSignal,
340
+ requestId?: number
341
+ ): Promise<string> {
366
342
  // Check disposed state
367
343
  if (this.isDisposed) {
368
344
  throw new BridgeDisposedError('Transport has been disposed');
@@ -387,21 +363,22 @@ export class SubprocessTransport extends DisposableBase implements Transport {
387
363
  }
388
364
 
389
365
  // Extract message ID for response correlation
390
- const messageId = extractMessageId(message);
366
+ const messageId = requestId ?? extractMessageId(message);
391
367
  if (messageId === null) {
392
368
  throw new BridgeProtocolError('Message must contain an "id" field');
393
369
  }
394
370
 
395
- // Check for restart condition (either scheduled restart or forced by stream error)
396
- if (
397
- this.needsRestart ||
398
- (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)
399
- ) {
400
- await this.restartProcess();
401
- }
371
+ let ownsDispatchReservation = false;
372
+ let reservationReleased = false;
373
+ const releaseOwnedReservation = (): void => {
374
+ if (ownsDispatchReservation && !reservationReleased) {
375
+ reservationReleased = true;
376
+ this.releaseDispatch();
377
+ }
378
+ };
402
379
 
403
380
  // Create promise for response
404
- return new Promise<string>((resolve, reject) => {
381
+ const response = new Promise<string>((resolve, reject) => {
405
382
  // Set up timeout if specified
406
383
  let timer: NodeJS.Timeout | undefined;
407
384
 
@@ -467,47 +444,121 @@ export class SubprocessTransport extends DisposableBase implements Transport {
467
444
  };
468
445
  this.pending.set(messageId, pendingEntry);
469
446
 
470
- // Write message to stdin. When chunking is negotiated and the encoded
471
- // request exceeds the negotiated per-frame ceiling, it is fragmented into
447
+ // Write message to stdin. When the encoded request exceeds the per-frame
448
+ // ceiling, it is fragmented into
472
449
  // `tywrap-frame/1` request frames written contiguously under the write
473
450
  // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
474
451
  // serialize through the same mutex so a small request can never slip
475
452
  // between another request's frames.
476
- this.writeRequest(message, messageId, signal, pendingEntry).catch(err => {
477
- this.pending.delete(messageId);
478
- if (timer) {
479
- clearTimeout(timer);
480
- }
481
- signal?.removeEventListener('abort', abortHandler);
482
- reject(this.classifyError(err));
483
- });
453
+ this.reserveDispatch(() => this.pending.get(messageId) === pendingEntry && !signal?.aborted)
454
+ .then(reserved => {
455
+ if (!reserved) {
456
+ return;
457
+ }
458
+ ownsDispatchReservation = true;
459
+ // Timeout/abort may have settled while this reservation waited behind
460
+ // a generation restart. Do not write the abandoned request, and release
461
+ // only the reservation this send actually acquired.
462
+ if (this.pending.get(messageId) !== pendingEntry) {
463
+ releaseOwnedReservation();
464
+ return;
465
+ }
466
+ return this.writeRequest(message, messageId, signal, pendingEntry);
467
+ })
468
+ .catch(err => {
469
+ this.pending.delete(messageId);
470
+ if (timer) {
471
+ clearTimeout(timer);
472
+ }
473
+ signal?.removeEventListener('abort', abortHandler);
474
+ reject(this.classifyError(err));
475
+ });
476
+ });
477
+ return response.finally(releaseOwnedReservation);
478
+ }
484
479
 
480
+ /**
481
+ * Atomically reserve the current process generation for one request. A
482
+ * threshold restart waits for every request reserved on the old generation
483
+ * to settle before killing it; later callers remain queued behind this lock.
484
+ */
485
+ private async withDispatchMutex<T>(operation: () => Promise<T>): Promise<T> {
486
+ let unlock!: () => void;
487
+ const previous = this.dispatchMutex;
488
+ this.dispatchMutex = new Promise<void>(resolve => {
489
+ unlock = resolve;
490
+ });
491
+ await previous;
492
+ try {
493
+ return await operation();
494
+ } finally {
495
+ unlock();
496
+ }
497
+ }
498
+
499
+ private async reserveDispatch(isLive: () => boolean): Promise<boolean> {
500
+ return this.withDispatchMutex(async () => {
501
+ // Cancellation may win while this request waits behind an earlier
502
+ // generation. It must consume neither a request slot nor a restart.
503
+ if (!isLive()) {
504
+ return false;
505
+ }
506
+ if (this.isLifecycleEnding()) {
507
+ throw new BridgeDisposedError('Transport has been disposed');
508
+ }
509
+ if (
510
+ this.needsRestart ||
511
+ (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)
512
+ ) {
513
+ if (this.activeDispatches > 0) {
514
+ await new Promise<void>(resolve => this.dispatchDrainWaiters.push(resolve));
515
+ }
516
+ // The owner or transport can settle while waiting for the old
517
+ // generation to drain. Do not restart for work that no longer exists.
518
+ if (!isLive()) {
519
+ return false;
520
+ }
521
+ if (this.isLifecycleEnding()) {
522
+ throw new BridgeDisposedError('Transport has been disposed');
523
+ }
524
+ await this.restartProcess();
525
+ }
526
+ this.activeDispatches++;
485
527
  this.requestCount++;
528
+ return true;
486
529
  });
487
530
  }
488
531
 
532
+ private releaseDispatch(): void {
533
+ this.activeDispatches = Math.max(0, this.activeDispatches - 1);
534
+ if (this.activeDispatches === 0) {
535
+ for (const resolve of this.dispatchDrainWaiters.splice(0)) {
536
+ resolve();
537
+ }
538
+ }
539
+ }
540
+
541
+ private isLifecycleEnding(): boolean {
542
+ return this.state === 'disposing' || this.isDisposed;
543
+ }
544
+
489
545
  /**
490
546
  * Static capability descriptor for the subprocess backend.
491
547
  *
492
548
  * Per the {@link Transport.capabilities} contract this is lifecycle-independent
493
549
  * (safe before `init()` / after `dispose()`) and never makes a Python round
494
550
  * 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.
551
+ * over the JSONL stream. `supportsChunking` is always `true` because the npm
552
+ * package ships both `tywrap-frame/1` peers. `supportsStreaming` stays `false`.
553
+ * `maxFrameBytes` is the fixed per-frame data ceiling supplied to the Python
554
+ * bridge at spawn time.
504
555
  */
505
556
  capabilities(): TransportCapabilities {
506
557
  return {
507
558
  backend: 'subprocess',
508
559
  supportsArrow: true,
509
560
  supportsBinary: true,
510
- supportsChunking: this.enableChunking,
561
+ supportsChunking: true,
511
562
  supportsStreaming: false,
512
563
  maxFrameBytes: this.maxLineLength,
513
564
  };
@@ -518,142 +569,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
518
569
  // ===========================================================================
519
570
 
520
571
  /**
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.
572
+ * Initialize the transport by spawning the Python process. The packaged
573
+ * bridge always speaks `tywrap-frame/1`.
523
574
  */
524
575
  protected async doInit(): Promise<void> {
525
576
  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
- });
657
577
  }
658
578
 
659
579
  /**
@@ -674,22 +594,23 @@ export class SubprocessTransport extends DisposableBase implements Transport {
674
594
  this.pending.clear();
675
595
 
676
596
  // Clear write queue
677
- for (const queued of this.writeQueue) {
678
- this.clearQueuedWriteTimeout(queued);
679
- queued.reject(error);
680
- }
681
- this.writeQueue.length = 0;
597
+ this.rejectAllQueuedWrites(error);
682
598
 
683
- // Kill process
684
- await this.killProcess();
599
+ // Fence queued restart work before killing the process. A restart already
600
+ // holding the mutex observes the disposing state before it can spawn; if it
601
+ // spawned just before disposal began, this barrier kills that child too.
602
+ await this.withDispatchMutex(() => this.killProcess());
685
603
 
686
604
  // Clear buffers
687
605
  this.stdoutBuffer = '';
606
+ this.stdoutScanOffset = 0;
688
607
  this.stderrBuffer = '';
689
608
  this.timedOutRequests.clear();
690
609
  this.requestCount = 0;
691
- this.responseReassembler = null;
692
- this.negotiatedChunking = false;
610
+ this.responseReassembler = new Reassembler({
611
+ maxReassemblyBytes: this.maxReassemblyBytes,
612
+ expectedStream: 'response',
613
+ });
693
614
  // Reset the per-request write mutex so a disposed transport starts from a
694
615
  // clean (resolved) tail if reused.
695
616
  this.writeMutex = Promise.resolve();
@@ -715,17 +636,8 @@ export class SubprocessTransport extends DisposableBase implements Transport {
715
636
  // on top of it. Spread (rather than dynamic index assignment) keeps the
716
637
  // computed keys off ESLint's object-injection sink. See
717
638
  // 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
-
726
639
  const env: NodeJS.ProcessEnv = {
727
640
  ...baseEnv,
728
- ...chunkingEnv,
729
641
  // Ensure Python uses UTF-8
730
642
  PYTHONUTF8: '1',
731
643
  PYTHONIOENCODING: 'UTF-8',
@@ -733,23 +645,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
733
645
  PYTHONUNBUFFERED: '1',
734
646
  };
735
647
 
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
-
747
648
  // Spawn process
748
- this.process = spawn(this.pythonPath, [this.bridgeScript], {
749
- stdio: ['pipe', 'pipe', 'pipe'],
750
- env,
751
- cwd: this.cwd,
752
- });
649
+ this.process = spawn(
650
+ this.pythonPath,
651
+ [this.bridgeScript, '--tywrap-max-frame-bytes', String(this.frameBytes)],
652
+ {
653
+ stdio: ['pipe', 'pipe', 'pipe'],
654
+ env,
655
+ cwd: this.cwd,
656
+ }
657
+ );
753
658
 
754
659
  this.processExited = false;
755
660
  this.processError = null;
@@ -835,28 +740,38 @@ export class SubprocessTransport extends DisposableBase implements Transport {
835
740
  * Restart the Python process.
836
741
  */
837
742
  private async restartProcess(): Promise<void> {
743
+ if (this.isLifecycleEnding()) {
744
+ throw new BridgeDisposedError('Transport has been disposed');
745
+ }
746
+
838
747
  // Kill existing process
839
748
  await this.killProcess();
840
749
 
841
750
  // Clear buffers and restart flags
842
751
  this.stdoutBuffer = '';
752
+ this.stdoutScanOffset = 0;
843
753
  this.stderrBuffer = '';
844
754
  this.requestCount = 0;
845
755
  this.needsRestart = false;
846
756
  // Drop any partial reassembly + discard tracking: the new process owns a
847
757
  // fresh stdout stream, so stale per-id state from the dead process must not
848
758
  // leak across the restart boundary.
849
- this.responseReassembler = null;
850
- this.negotiatedChunking = false;
759
+ this.responseReassembler = new Reassembler({
760
+ maxReassemblyBytes: this.maxReassemblyBytes,
761
+ expectedStream: 'response',
762
+ });
851
763
  // The new process owns a fresh stdin stream; reset the write mutex so a
852
764
  // pending frame burst against the dead process cannot serialize behind it.
853
765
  this.writeMutex = Promise.resolve();
854
766
 
855
- // Spawn new process and re-negotiate framing against the fresh bridge.
856
- await this.spawnProcess();
857
- if (this.enableChunking) {
858
- await this.negotiateChunking();
767
+ // Disposal may begin while the old child is being killed. Never publish a
768
+ // replacement after lifecycle teardown has started.
769
+ if (this.isLifecycleEnding()) {
770
+ throw new BridgeDisposedError('Transport has been disposed');
859
771
  }
772
+
773
+ // Spawn a fresh process with the packaged always-on framing protocol.
774
+ await this.spawnProcess();
860
775
  }
861
776
 
862
777
  /**
@@ -875,17 +790,13 @@ export class SubprocessTransport extends DisposableBase implements Transport {
875
790
  /**
876
791
  * Effective stdout line ceiling.
877
792
  *
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
793
+ * A single wire line may be a `tywrap-frame/1` envelope whose
880
794
  * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
881
795
  * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
882
796
  * to bound that overhead so a legitimate frame line is never rejected while a
883
797
  * runaway/garbage line still is.
884
798
  */
885
799
  private effectiveLineCeiling(): number {
886
- if (!this.negotiatedChunking) {
887
- return this.maxLineLength;
888
- }
889
800
  return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
890
801
  }
891
802
 
@@ -897,22 +808,25 @@ export class SubprocessTransport extends DisposableBase implements Transport {
897
808
 
898
809
  const ceiling = this.effectiveLineCeiling();
899
810
 
900
- // Check for excessive line length without newline
901
- if (this.stdoutBuffer.length > ceiling && !this.stdoutBuffer.includes('\n')) {
811
+ // Scan only bytes newly appended since the last incomplete line.
812
+ let newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
813
+ if (this.stdoutBuffer.length > ceiling && newlineIndex === -1) {
902
814
  const snippet = this.stdoutBuffer.slice(0, 500);
903
815
  this.stdoutBuffer = '';
816
+ this.stdoutScanOffset = 0;
904
817
  this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
905
818
  return;
906
819
  }
907
820
 
908
821
  // Process complete lines
909
- let newlineIndex: number;
910
- while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
822
+ while (newlineIndex !== -1) {
911
823
  const line = this.stdoutBuffer.slice(0, newlineIndex);
912
824
  this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
825
+ this.stdoutScanOffset = 0;
913
826
 
914
827
  // Skip empty lines
915
828
  if (!line.trim()) {
829
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
916
830
  continue;
917
831
  }
918
832
 
@@ -924,19 +838,21 @@ export class SubprocessTransport extends DisposableBase implements Transport {
924
838
  }
925
839
 
926
840
  this.handleResponseLine(line);
841
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
927
842
  }
843
+ this.stdoutScanOffset = this.stdoutBuffer.length;
928
844
  }
929
845
 
930
846
  /**
931
847
  * Handle a complete response line from stdout.
932
848
  *
933
- * When chunking is negotiated, a line may be a `tywrap-frame/1` envelope: it is
849
+ * A line may be a `tywrap-frame/1` envelope: it is
934
850
  * routed into the per-id {@link Reassembler}, which returns the reassembled
935
851
  * logical response only once the stream is complete and valid. Single-line
936
852
  * (non-frame) responses keep the original fast path unchanged.
937
853
  */
938
854
  private handleResponseLine(line: string): void {
939
- if (this.negotiatedChunking && this.responseReassembler) {
855
+ if (this.responseReassembler) {
940
856
  const probe = probeFrameLine(line);
941
857
  if (probe.kind === 'frame') {
942
858
  this.handleResponseFrame(probe.value, line);
@@ -991,7 +907,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
991
907
  private handleResponseFrame(rawFrame: unknown, line: string): void {
992
908
  const reassembler = this.responseReassembler;
993
909
  if (!reassembler) {
994
- // Unreachable: only called when negotiatedChunking && reassembler set.
910
+ // Unreachable: the always-on framing path constructs the reassembler.
995
911
  this.handleProtocolError('Received frame with no reassembler', line);
996
912
  return;
997
913
  }
@@ -1115,11 +1031,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1115
1031
  const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
1116
1032
 
1117
1033
  // Reject all pending writes
1118
- for (const queued of this.writeQueue) {
1119
- this.clearQueuedWriteTimeout(queued);
1120
- queued.reject(error);
1121
- }
1122
- this.writeQueue.length = 0;
1034
+ this.rejectAllQueuedWrites(error);
1123
1035
 
1124
1036
  // Reject all pending requests
1125
1037
  this.rejectAllPending(error);
@@ -1164,14 +1076,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1164
1076
  isLive?: () => boolean
1165
1077
  ): QueuedWrite {
1166
1078
  const queuedAt = Date.now();
1167
- const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive };
1079
+ const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive, isQueued: true };
1168
1080
 
1169
1081
  // Set up timeout timer that fires if drain never happens
1170
1082
  entry.timeoutHandle = setTimeout(() => {
1171
- // Remove this entry from the queue
1172
- const index = this.writeQueue.indexOf(entry);
1173
- if (index !== -1) {
1174
- this.writeQueue.splice(index, 1);
1083
+ if (entry.isQueued) {
1084
+ entry.isQueued = false;
1175
1085
  reject(
1176
1086
  new BridgeTimeoutError(
1177
1087
  `Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`
@@ -1208,7 +1118,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1208
1118
  return;
1209
1119
  }
1210
1120
 
1211
- if (this.draining || this.writeQueue.length > 0) {
1121
+ if (this.draining || this.writeQueue.length > this.writeQueueHead) {
1212
1122
  // Queue the write with timestamp, timeout timer, and liveness predicate
1213
1123
  // (checked again at flush — see processQueuedWrite).
1214
1124
  this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
@@ -1245,7 +1155,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1245
1155
 
1246
1156
  /**
1247
1157
  * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
1248
- * request frames when chunking is negotiated and the encoded request exceeds
1158
+ * request frames when the encoded request exceeds
1249
1159
  * the per-frame ceiling (W5 — the mirror of W4's response chunking).
1250
1160
  *
1251
1161
  * Both the chunked and single-line paths run under {@link writeMutex} so a
@@ -1287,10 +1197,9 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1287
1197
  if (!isLive()) {
1288
1198
  return Promise.resolve();
1289
1199
  }
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) {
1200
+ // Chunk when the encoded request exceeds the fixed per-frame ceiling
1201
+ // supplied to the packaged Python peer. Otherwise use one JSONL line.
1202
+ if (utf8ByteLength(message) > this.frameBytes) {
1294
1203
  return this.writeChunkedRequest(message, messageId, signal, isLive);
1295
1204
  }
1296
1205
  return this.writeToStdin(`${message}\n`, isLive);
@@ -1327,7 +1236,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1327
1236
  const frames = encodeFrames(message, {
1328
1237
  id: messageId,
1329
1238
  stream: 'request',
1330
- maxFrameBytes: this.negotiatedFrameBytes,
1239
+ maxFrameBytes: this.frameBytes,
1331
1240
  });
1332
1241
 
1333
1242
  for (const frame of frames) {
@@ -1352,11 +1261,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1352
1261
  * Clears each entry's timeout before rejecting so no late timer fires.
1353
1262
  */
1354
1263
  private rejectAllQueuedWrites(error: Error): void {
1355
- for (const q of this.writeQueue) {
1356
- this.clearQueuedWriteTimeout(q);
1357
- q.reject(error);
1264
+ for (let index = this.writeQueueHead; index < this.writeQueue.length; index += 1) {
1265
+ const queued = this.writeQueue[index];
1266
+ if (queued?.isQueued) {
1267
+ queued.isQueued = false;
1268
+ this.clearQueuedWriteTimeout(queued);
1269
+ queued.reject(error);
1270
+ }
1358
1271
  }
1359
1272
  this.writeQueue.length = 0;
1273
+ this.writeQueueHead = 0;
1360
1274
  }
1361
1275
 
1362
1276
  /**
@@ -1422,7 +1336,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1422
1336
  private flushWriteQueue(): void {
1423
1337
  const now = Date.now();
1424
1338
 
1425
- while (this.writeQueue.length > 0 && !this.draining) {
1339
+ while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
1426
1340
  const stdin = this.process?.stdin;
1427
1341
  if (!stdin || this.processExited) {
1428
1342
  // Process died - reject all queued writes
@@ -1433,11 +1347,17 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1433
1347
  return;
1434
1348
  }
1435
1349
 
1436
- const queued = this.writeQueue.shift();
1350
+ const queued = this.writeQueue[this.writeQueueHead];
1351
+ this.writeQueueHead += 1;
1437
1352
  if (!queued) {
1438
1353
  return;
1439
1354
  }
1440
1355
 
1356
+ if (!queued.isQueued) {
1357
+ continue;
1358
+ }
1359
+ queued.isQueued = false;
1360
+
1441
1361
  // Clear the timeout since we're processing this entry now
1442
1362
  this.clearQueuedWriteTimeout(queued);
1443
1363
 
@@ -1446,6 +1366,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
1446
1366
  return;
1447
1367
  }
1448
1368
  }
1369
+
1370
+ if (this.writeQueueHead === this.writeQueue.length) {
1371
+ this.writeQueue.length = 0;
1372
+ this.writeQueueHead = 0;
1373
+ }
1449
1374
  }
1450
1375
 
1451
1376
  // ===========================================================================