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
@@ -17,7 +17,6 @@ import { DisposableBase } from './bounded-context.js';
17
17
  import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
18
18
  import { Reassembler, encodeFrames, utf8ByteLength } from './frame-codec.js';
19
19
  import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
20
- import { FRAME_PROTOCOL_ID, PROTOCOL_ID, } from './transport.js';
21
20
  // =============================================================================
22
21
  // CONSTANTS
23
22
  // =============================================================================
@@ -47,14 +46,6 @@ const FRAME_ENVELOPE_HEADROOM = 1024;
47
46
  * ceiling sound without over-allocating buffer headroom.
48
47
  */
49
48
  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
49
  /**
59
50
  * Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
60
51
  * Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
@@ -154,25 +145,15 @@ export class SubprocessTransport extends DisposableBase {
154
145
  maxReassemblyBytes;
155
146
  restartAfterRequests;
156
147
  writeQueueTimeoutMs;
157
- /** Whether `tywrap-frame/1` negotiation was requested by the caller. */
158
- enableChunking;
159
148
  /**
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.
149
+ * Per-frame UTF-8 data ceiling. The packaged Python peer receives this
150
+ * transport's `maxLineLength` when it is spawned, so request and response
151
+ * framing use the same fixed ceiling without runtime negotiation.
163
152
  */
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;
153
+ frameBytes;
173
154
  /**
174
155
  * Reassembles `tywrap-frame/1` response frames into single logical response
175
- * lines. Constructed lazily once chunking is negotiated; per-id discard tracks
156
+ * lines. Per-id discard tracks
176
157
  * timed-out/aborted streams so late frames cannot desync stdout.
177
158
  */
178
159
  responseReassembler = null;
@@ -182,6 +163,7 @@ export class SubprocessTransport extends DisposableBase {
182
163
  processError = null;
183
164
  // Stream buffers
184
165
  stdoutBuffer = '';
166
+ stdoutScanOffset = 0;
185
167
  stderrBuffer = '';
186
168
  // Request tracking
187
169
  pending = new Map();
@@ -190,8 +172,13 @@ export class SubprocessTransport extends DisposableBase {
190
172
  });
191
173
  requestCount = 0;
192
174
  needsRestart = false;
175
+ /** Serializes restart decisions with dispatch reservations. */
176
+ dispatchMutex = Promise.resolve();
177
+ activeDispatches = 0;
178
+ dispatchDrainWaiters = [];
193
179
  // Write queue for backpressure
194
180
  writeQueue = [];
181
+ writeQueueHead = 0;
195
182
  draining = false;
196
183
  /**
197
184
  * Per-logical-request write mutex (W5). When a request is chunked into
@@ -217,9 +204,12 @@ export class SubprocessTransport extends DisposableBase {
217
204
  this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
218
205
  this.restartAfterRequests = options.restartAfterRequests ?? 0;
219
206
  this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
220
- this.enableChunking = options.enableChunking ?? false;
221
207
  this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
222
- this.negotiatedFrameBytes = this.maxLineLength;
208
+ this.frameBytes = this.maxLineLength;
209
+ this.responseReassembler = new Reassembler({
210
+ maxReassemblyBytes: this.maxReassemblyBytes,
211
+ expectedStream: 'response',
212
+ });
223
213
  }
224
214
  // ===========================================================================
225
215
  // TRANSPORT INTERFACE
@@ -232,7 +222,7 @@ export class SubprocessTransport extends DisposableBase {
232
222
  * @param signal - Optional AbortSignal for cancellation
233
223
  * @returns The raw JSON response string
234
224
  */
235
- async send(message, timeoutMs, signal) {
225
+ async send(message, timeoutMs, signal, requestId) {
236
226
  // Check disposed state
237
227
  if (this.isDisposed) {
238
228
  throw new BridgeDisposedError('Transport has been disposed');
@@ -253,17 +243,20 @@ export class SubprocessTransport extends DisposableBase {
253
243
  throw new BridgeTimeoutError('Operation aborted');
254
244
  }
255
245
  // Extract message ID for response correlation
256
- const messageId = extractMessageId(message);
246
+ const messageId = requestId ?? extractMessageId(message);
257
247
  if (messageId === null) {
258
248
  throw new BridgeProtocolError('Message must contain an "id" field');
259
249
  }
260
- // Check for restart condition (either scheduled restart or forced by stream error)
261
- if (this.needsRestart ||
262
- (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)) {
263
- await this.restartProcess();
264
- }
250
+ let ownsDispatchReservation = false;
251
+ let reservationReleased = false;
252
+ const releaseOwnedReservation = () => {
253
+ if (ownsDispatchReservation && !reservationReleased) {
254
+ reservationReleased = true;
255
+ this.releaseDispatch();
256
+ }
257
+ };
265
258
  // Create promise for response
266
- return new Promise((resolve, reject) => {
259
+ const response = new Promise((resolve, reject) => {
267
260
  // Set up timeout if specified
268
261
  let timer;
269
262
  // Defined before the timer so the timeout path can also detach it.
@@ -322,13 +315,28 @@ export class SubprocessTransport extends DisposableBase {
322
315
  timer,
323
316
  };
324
317
  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
318
+ // Write message to stdin. When the encoded request exceeds the per-frame
319
+ // ceiling, it is fragmented into
327
320
  // `tywrap-frame/1` request frames written contiguously under the write
328
321
  // mutex (W5); otherwise it goes out as a single JSONL line. Both paths
329
322
  // serialize through the same mutex so a small request can never slip
330
323
  // between another request's frames.
331
- this.writeRequest(message, messageId, signal, pendingEntry).catch(err => {
324
+ this.reserveDispatch(() => this.pending.get(messageId) === pendingEntry && !signal?.aborted)
325
+ .then(reserved => {
326
+ if (!reserved) {
327
+ return;
328
+ }
329
+ ownsDispatchReservation = true;
330
+ // Timeout/abort may have settled while this reservation waited behind
331
+ // a generation restart. Do not write the abandoned request, and release
332
+ // only the reservation this send actually acquired.
333
+ if (this.pending.get(messageId) !== pendingEntry) {
334
+ releaseOwnedReservation();
335
+ return;
336
+ }
337
+ return this.writeRequest(message, messageId, signal, pendingEntry);
338
+ })
339
+ .catch(err => {
332
340
  this.pending.delete(messageId);
333
341
  if (timer) {
334
342
  clearTimeout(timer);
@@ -336,31 +344,86 @@ export class SubprocessTransport extends DisposableBase {
336
344
  signal?.removeEventListener('abort', abortHandler);
337
345
  reject(this.classifyError(err));
338
346
  });
347
+ });
348
+ return response.finally(releaseOwnedReservation);
349
+ }
350
+ /**
351
+ * Atomically reserve the current process generation for one request. A
352
+ * threshold restart waits for every request reserved on the old generation
353
+ * to settle before killing it; later callers remain queued behind this lock.
354
+ */
355
+ async withDispatchMutex(operation) {
356
+ let unlock;
357
+ const previous = this.dispatchMutex;
358
+ this.dispatchMutex = new Promise(resolve => {
359
+ unlock = resolve;
360
+ });
361
+ await previous;
362
+ try {
363
+ return await operation();
364
+ }
365
+ finally {
366
+ unlock();
367
+ }
368
+ }
369
+ async reserveDispatch(isLive) {
370
+ return this.withDispatchMutex(async () => {
371
+ // Cancellation may win while this request waits behind an earlier
372
+ // generation. It must consume neither a request slot nor a restart.
373
+ if (!isLive()) {
374
+ return false;
375
+ }
376
+ if (this.isLifecycleEnding()) {
377
+ throw new BridgeDisposedError('Transport has been disposed');
378
+ }
379
+ if (this.needsRestart ||
380
+ (this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)) {
381
+ if (this.activeDispatches > 0) {
382
+ await new Promise(resolve => this.dispatchDrainWaiters.push(resolve));
383
+ }
384
+ // The owner or transport can settle while waiting for the old
385
+ // generation to drain. Do not restart for work that no longer exists.
386
+ if (!isLive()) {
387
+ return false;
388
+ }
389
+ if (this.isLifecycleEnding()) {
390
+ throw new BridgeDisposedError('Transport has been disposed');
391
+ }
392
+ await this.restartProcess();
393
+ }
394
+ this.activeDispatches++;
339
395
  this.requestCount++;
396
+ return true;
340
397
  });
341
398
  }
399
+ releaseDispatch() {
400
+ this.activeDispatches = Math.max(0, this.activeDispatches - 1);
401
+ if (this.activeDispatches === 0) {
402
+ for (const resolve of this.dispatchDrainWaiters.splice(0)) {
403
+ resolve();
404
+ }
405
+ }
406
+ }
407
+ isLifecycleEnding() {
408
+ return this.state === 'disposing' || this.isDisposed;
409
+ }
342
410
  /**
343
411
  * Static capability descriptor for the subprocess backend.
344
412
  *
345
413
  * Per the {@link Transport.capabilities} contract this is lifecycle-independent
346
414
  * (safe before `init()` / after `dispose()`) and never makes a Python round
347
415
  * 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.
416
+ * over the JSONL stream. `supportsChunking` is always `true` because the npm
417
+ * package ships both `tywrap-frame/1` peers. `supportsStreaming` stays `false`.
418
+ * `maxFrameBytes` is the fixed per-frame data ceiling supplied to the Python
419
+ * bridge at spawn time.
357
420
  */
358
421
  capabilities() {
359
422
  return {
360
423
  backend: 'subprocess',
361
424
  supportsArrow: true,
362
425
  supportsBinary: true,
363
- supportsChunking: this.enableChunking,
426
+ supportsChunking: true,
364
427
  supportsStreaming: false,
365
428
  maxFrameBytes: this.maxLineLength,
366
429
  };
@@ -369,130 +432,11 @@ export class SubprocessTransport extends DisposableBase {
369
432
  // BOUNDED CONTEXT LIFECYCLE
370
433
  // ===========================================================================
371
434
  /**
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.
435
+ * Initialize the transport by spawning the Python process. The packaged
436
+ * bridge always speaks `tywrap-frame/1`.
374
437
  */
375
438
  async doInit() {
376
439
  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
- });
496
440
  }
497
441
  /**
498
442
  * Dispose the transport by killing the Python process.
@@ -510,20 +454,21 @@ export class SubprocessTransport extends DisposableBase {
510
454
  }
511
455
  this.pending.clear();
512
456
  // Clear write queue
513
- for (const queued of this.writeQueue) {
514
- this.clearQueuedWriteTimeout(queued);
515
- queued.reject(error);
516
- }
517
- this.writeQueue.length = 0;
518
- // Kill process
519
- await this.killProcess();
457
+ this.rejectAllQueuedWrites(error);
458
+ // Fence queued restart work before killing the process. A restart already
459
+ // holding the mutex observes the disposing state before it can spawn; if it
460
+ // spawned just before disposal began, this barrier kills that child too.
461
+ await this.withDispatchMutex(() => this.killProcess());
520
462
  // Clear buffers
521
463
  this.stdoutBuffer = '';
464
+ this.stdoutScanOffset = 0;
522
465
  this.stderrBuffer = '';
523
466
  this.timedOutRequests.clear();
524
467
  this.requestCount = 0;
525
- this.responseReassembler = null;
526
- this.negotiatedChunking = false;
468
+ this.responseReassembler = new Reassembler({
469
+ maxReassemblyBytes: this.maxReassemblyBytes,
470
+ expectedStream: 'response',
471
+ });
527
472
  // Reset the per-request write mutex so a disposed transport starts from a
528
473
  // clean (resolved) tail if reused.
529
474
  this.writeMutex = Promise.resolve();
@@ -546,34 +491,16 @@ export class SubprocessTransport extends DisposableBase {
546
491
  // on top of it. Spread (rather than dynamic index assignment) keeps the
547
492
  // computed keys off ESLint's object-injection sink. See
548
493
  // 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
- : {};
556
494
  const env = {
557
495
  ...baseEnv,
558
- ...chunkingEnv,
559
496
  // Ensure Python uses UTF-8
560
497
  PYTHONUTF8: '1',
561
498
  PYTHONIOENCODING: 'UTF-8',
562
499
  // Disable Python buffering
563
500
  PYTHONUNBUFFERED: '1',
564
501
  };
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
- }
575
502
  // Spawn process
576
- this.process = spawn(this.pythonPath, [this.bridgeScript], {
503
+ this.process = spawn(this.pythonPath, [this.bridgeScript, '--tywrap-max-frame-bytes', String(this.frameBytes)], {
577
504
  stdio: ['pipe', 'pipe', 'pipe'],
578
505
  env,
579
506
  cwd: this.cwd,
@@ -649,26 +576,34 @@ export class SubprocessTransport extends DisposableBase {
649
576
  * Restart the Python process.
650
577
  */
651
578
  async restartProcess() {
579
+ if (this.isLifecycleEnding()) {
580
+ throw new BridgeDisposedError('Transport has been disposed');
581
+ }
652
582
  // Kill existing process
653
583
  await this.killProcess();
654
584
  // Clear buffers and restart flags
655
585
  this.stdoutBuffer = '';
586
+ this.stdoutScanOffset = 0;
656
587
  this.stderrBuffer = '';
657
588
  this.requestCount = 0;
658
589
  this.needsRestart = false;
659
590
  // Drop any partial reassembly + discard tracking: the new process owns a
660
591
  // fresh stdout stream, so stale per-id state from the dead process must not
661
592
  // leak across the restart boundary.
662
- this.responseReassembler = null;
663
- this.negotiatedChunking = false;
593
+ this.responseReassembler = new Reassembler({
594
+ maxReassemblyBytes: this.maxReassemblyBytes,
595
+ expectedStream: 'response',
596
+ });
664
597
  // The new process owns a fresh stdin stream; reset the write mutex so a
665
598
  // pending frame burst against the dead process cannot serialize behind it.
666
599
  this.writeMutex = Promise.resolve();
667
- // Spawn new process and re-negotiate framing against the fresh bridge.
668
- await this.spawnProcess();
669
- if (this.enableChunking) {
670
- await this.negotiateChunking();
600
+ // Disposal may begin while the old child is being killed. Never publish a
601
+ // replacement after lifecycle teardown has started.
602
+ if (this.isLifecycleEnding()) {
603
+ throw new BridgeDisposedError('Transport has been disposed');
671
604
  }
605
+ // Spawn a fresh process with the packaged always-on framing protocol.
606
+ await this.spawnProcess();
672
607
  }
673
608
  /**
674
609
  * Mark the process for restart on the next send.
@@ -684,17 +619,13 @@ export class SubprocessTransport extends DisposableBase {
684
619
  /**
685
620
  * Effective stdout line ceiling.
686
621
  *
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
622
+ * A single wire line may be a `tywrap-frame/1` envelope whose
689
623
  * `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
690
624
  * JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
691
625
  * to bound that overhead so a legitimate frame line is never rejected while a
692
626
  * runaway/garbage line still is.
693
627
  */
694
628
  effectiveLineCeiling() {
695
- if (!this.negotiatedChunking) {
696
- return this.maxLineLength;
697
- }
698
629
  return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
699
630
  }
700
631
  /**
@@ -703,20 +634,23 @@ export class SubprocessTransport extends DisposableBase {
703
634
  handleStdoutData(chunk) {
704
635
  this.stdoutBuffer += chunk.toString();
705
636
  const ceiling = this.effectiveLineCeiling();
706
- // Check for excessive line length without newline
707
- if (this.stdoutBuffer.length > ceiling && !this.stdoutBuffer.includes('\n')) {
637
+ // Scan only bytes newly appended since the last incomplete line.
638
+ let newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
639
+ if (this.stdoutBuffer.length > ceiling && newlineIndex === -1) {
708
640
  const snippet = this.stdoutBuffer.slice(0, 500);
709
641
  this.stdoutBuffer = '';
642
+ this.stdoutScanOffset = 0;
710
643
  this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
711
644
  return;
712
645
  }
713
646
  // Process complete lines
714
- let newlineIndex;
715
- while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
647
+ while (newlineIndex !== -1) {
716
648
  const line = this.stdoutBuffer.slice(0, newlineIndex);
717
649
  this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
650
+ this.stdoutScanOffset = 0;
718
651
  // Skip empty lines
719
652
  if (!line.trim()) {
653
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
720
654
  continue;
721
655
  }
722
656
  // Check line length
@@ -726,18 +660,20 @@ export class SubprocessTransport extends DisposableBase {
726
660
  return;
727
661
  }
728
662
  this.handleResponseLine(line);
663
+ newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
729
664
  }
665
+ this.stdoutScanOffset = this.stdoutBuffer.length;
730
666
  }
731
667
  /**
732
668
  * Handle a complete response line from stdout.
733
669
  *
734
- * When chunking is negotiated, a line may be a `tywrap-frame/1` envelope: it is
670
+ * A line may be a `tywrap-frame/1` envelope: it is
735
671
  * routed into the per-id {@link Reassembler}, which returns the reassembled
736
672
  * logical response only once the stream is complete and valid. Single-line
737
673
  * (non-frame) responses keep the original fast path unchanged.
738
674
  */
739
675
  handleResponseLine(line) {
740
- if (this.negotiatedChunking && this.responseReassembler) {
676
+ if (this.responseReassembler) {
741
677
  const probe = probeFrameLine(line);
742
678
  if (probe.kind === 'frame') {
743
679
  this.handleResponseFrame(probe.value, line);
@@ -786,7 +722,7 @@ export class SubprocessTransport extends DisposableBase {
786
722
  handleResponseFrame(rawFrame, line) {
787
723
  const reassembler = this.responseReassembler;
788
724
  if (!reassembler) {
789
- // Unreachable: only called when negotiatedChunking && reassembler set.
725
+ // Unreachable: the always-on framing path constructs the reassembler.
790
726
  this.handleProtocolError('Received frame with no reassembler', line);
791
727
  return;
792
728
  }
@@ -896,11 +832,7 @@ export class SubprocessTransport extends DisposableBase {
896
832
  // EPIPE means process died
897
833
  const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
898
834
  // Reject all pending writes
899
- for (const queued of this.writeQueue) {
900
- this.clearQueuedWriteTimeout(queued);
901
- queued.reject(error);
902
- }
903
- this.writeQueue.length = 0;
835
+ this.rejectAllQueuedWrites(error);
904
836
  // Reject all pending requests
905
837
  this.rejectAllPending(error);
906
838
  // Mark for restart on next send
@@ -934,13 +866,11 @@ export class SubprocessTransport extends DisposableBase {
934
866
  */
935
867
  createQueuedWrite(data, resolve, reject, isLive) {
936
868
  const queuedAt = Date.now();
937
- const entry = { data, resolve, reject, queuedAt, isLive };
869
+ const entry = { data, resolve, reject, queuedAt, isLive, isQueued: true };
938
870
  // Set up timeout timer that fires if drain never happens
939
871
  entry.timeoutHandle = setTimeout(() => {
940
- // Remove this entry from the queue
941
- const index = this.writeQueue.indexOf(entry);
942
- if (index !== -1) {
943
- this.writeQueue.splice(index, 1);
872
+ if (entry.isQueued) {
873
+ entry.isQueued = false;
944
874
  reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`));
945
875
  }
946
876
  }, this.writeQueueTimeoutMs);
@@ -968,7 +898,7 @@ export class SubprocessTransport extends DisposableBase {
968
898
  reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
969
899
  return;
970
900
  }
971
- if (this.draining || this.writeQueue.length > 0) {
901
+ if (this.draining || this.writeQueue.length > this.writeQueueHead) {
972
902
  // Queue the write with timestamp, timeout timer, and liveness predicate
973
903
  // (checked again at flush — see processQueuedWrite).
974
904
  this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
@@ -1003,7 +933,7 @@ export class SubprocessTransport extends DisposableBase {
1003
933
  }
1004
934
  /**
1005
935
  * Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
1006
- * request frames when chunking is negotiated and the encoded request exceeds
936
+ * request frames when the encoded request exceeds
1007
937
  * the per-frame ceiling (W5 — the mirror of W4's response chunking).
1008
938
  *
1009
939
  * Both the chunked and single-line paths run under {@link writeMutex} so a
@@ -1039,10 +969,9 @@ export class SubprocessTransport extends DisposableBase {
1039
969
  if (!isLive()) {
1040
970
  return Promise.resolve();
1041
971
  }
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) {
972
+ // Chunk when the encoded request exceeds the fixed per-frame ceiling
973
+ // supplied to the packaged Python peer. Otherwise use one JSONL line.
974
+ if (utf8ByteLength(message) > this.frameBytes) {
1046
975
  return this.writeChunkedRequest(message, messageId, signal, isLive);
1047
976
  }
1048
977
  return this.writeToStdin(`${message}\n`, isLive);
@@ -1069,7 +998,7 @@ export class SubprocessTransport extends DisposableBase {
1069
998
  const frames = encodeFrames(message, {
1070
999
  id: messageId,
1071
1000
  stream: 'request',
1072
- maxFrameBytes: this.negotiatedFrameBytes,
1001
+ maxFrameBytes: this.frameBytes,
1073
1002
  });
1074
1003
  for (const frame of frames) {
1075
1004
  // Stop the burst if the caller aborted, the request was abandoned (timed
@@ -1092,11 +1021,16 @@ export class SubprocessTransport extends DisposableBase {
1092
1021
  * Clears each entry's timeout before rejecting so no late timer fires.
1093
1022
  */
1094
1023
  rejectAllQueuedWrites(error) {
1095
- for (const q of this.writeQueue) {
1096
- this.clearQueuedWriteTimeout(q);
1097
- q.reject(error);
1024
+ for (let index = this.writeQueueHead; index < this.writeQueue.length; index += 1) {
1025
+ const queued = this.writeQueue[index];
1026
+ if (queued?.isQueued) {
1027
+ queued.isQueued = false;
1028
+ this.clearQueuedWriteTimeout(queued);
1029
+ queued.reject(error);
1030
+ }
1098
1031
  }
1099
1032
  this.writeQueue.length = 0;
1033
+ this.writeQueueHead = 0;
1100
1034
  }
1101
1035
  /**
1102
1036
  * Process a single dequeued write entry: enforce the fallback queue timeout,
@@ -1148,7 +1082,7 @@ export class SubprocessTransport extends DisposableBase {
1148
1082
  */
1149
1083
  flushWriteQueue() {
1150
1084
  const now = Date.now();
1151
- while (this.writeQueue.length > 0 && !this.draining) {
1085
+ while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
1152
1086
  const stdin = this.process?.stdin;
1153
1087
  if (!stdin || this.processExited) {
1154
1088
  // Process died - reject all queued writes
@@ -1156,10 +1090,15 @@ export class SubprocessTransport extends DisposableBase {
1156
1090
  this.markForRestart();
1157
1091
  return;
1158
1092
  }
1159
- const queued = this.writeQueue.shift();
1093
+ const queued = this.writeQueue[this.writeQueueHead];
1094
+ this.writeQueueHead += 1;
1160
1095
  if (!queued) {
1161
1096
  return;
1162
1097
  }
1098
+ if (!queued.isQueued) {
1099
+ continue;
1100
+ }
1101
+ queued.isQueued = false;
1163
1102
  // Clear the timeout since we're processing this entry now
1164
1103
  this.clearQueuedWriteTimeout(queued);
1165
1104
  const status = this.processQueuedWrite(queued, stdin, now);
@@ -1167,6 +1106,10 @@ export class SubprocessTransport extends DisposableBase {
1167
1106
  return;
1168
1107
  }
1169
1108
  }
1109
+ if (this.writeQueueHead === this.writeQueue.length) {
1110
+ this.writeQueue.length = 0;
1111
+ this.writeQueueHead = 0;
1112
+ }
1170
1113
  }
1171
1114
  // ===========================================================================
1172
1115
  // HELPERS