tywrap 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -7
- package/SECURITY.md +39 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/core/annotation-parser.d.ts +2 -1
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js +6 -3
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/generator.d.ts +23 -4
- package/dist/core/generator.d.ts.map +1 -1
- package/dist/core/generator.js +243 -170
- package/dist/core/generator.js.map +1 -1
- package/dist/core/mapper.d.ts +3 -2
- package/dist/core/mapper.d.ts.map +1 -1
- package/dist/core/mapper.js +5 -5
- package/dist/core/mapper.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/base-bridge.d.ts +3 -7
- package/dist/runtime/base-bridge.d.ts.map +1 -1
- package/dist/runtime/base-bridge.js +4 -17
- package/dist/runtime/base-bridge.js.map +1 -1
- package/dist/runtime/bounded-context.d.ts +3 -22
- package/dist/runtime/bounded-context.d.ts.map +1 -1
- package/dist/runtime/bounded-context.js +13 -53
- package/dist/runtime/bounded-context.js.map +1 -1
- package/dist/runtime/bridge-codec.d.ts +1 -1
- package/dist/runtime/bridge-codec.d.ts.map +1 -1
- package/dist/runtime/bridge-codec.js +78 -55
- package/dist/runtime/bridge-codec.js.map +1 -1
- package/dist/runtime/errors.d.ts +16 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/errors.js +17 -0
- package/dist/runtime/errors.js.map +1 -1
- package/dist/runtime/http-transport.d.ts +1 -1
- package/dist/runtime/http-transport.d.ts.map +1 -1
- package/dist/runtime/http-transport.js +1 -1
- package/dist/runtime/http-transport.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +7 -36
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +2 -80
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pooled-transport.d.ts +120 -59
- package/dist/runtime/pooled-transport.d.ts.map +1 -1
- package/dist/runtime/pooled-transport.js +345 -78
- package/dist/runtime/pooled-transport.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/pyodide-transport.d.ts +1 -1
- package/dist/runtime/pyodide-transport.d.ts.map +1 -1
- package/dist/runtime/pyodide-transport.js +2 -3
- package/dist/runtime/pyodide-transport.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts +7 -36
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +20 -102
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +30 -69
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +179 -236
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
- package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
- package/dist/runtime/transport.d.ts +9 -19
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +1 -1
- package/dist/runtime/transport.js.map +1 -1
- package/dist/runtime/validators.d.ts +49 -0
- package/dist/runtime/validators.d.ts.map +1 -1
- package/dist/runtime/validators.js +152 -0
- package/dist/runtime/validators.js.map +1 -1
- package/dist/types/index.d.ts +14 -39
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.d.ts +3 -2
- package/dist/tywrap.d.ts.map +1 -1
- package/dist/tywrap.js +140 -13
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/cache.d.ts +3 -16
- package/dist/utils/cache.d.ts.map +1 -1
- package/dist/utils/codec.d.ts +1 -0
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +13 -13
- package/dist/utils/codec.js.map +1 -1
- package/dist/utils/ir-cache.d.ts +2 -1
- package/dist/utils/ir-cache.d.ts.map +1 -1
- package/dist/utils/runtime.d.ts +0 -29
- package/dist/utils/runtime.d.ts.map +1 -1
- package/dist/utils/runtime.js +16 -107
- package/dist/utils/runtime.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -6
- package/runtime/frame_codec.py +7 -1
- package/runtime/python_bridge.py +85 -125
- package/runtime/tywrap_bridge_core.py +133 -150
- package/src/config/index.ts +11 -0
- package/src/core/annotation-parser.ts +7 -4
- package/src/core/generator.ts +315 -205
- package/src/core/mapper.ts +8 -8
- package/src/index.ts +7 -4
- package/src/runtime/base-bridge.ts +5 -30
- package/src/runtime/bounded-context.ts +12 -67
- package/src/runtime/bridge-codec.ts +94 -65
- package/src/runtime/errors.ts +21 -0
- package/src/runtime/http-transport.ts +6 -1
- package/src/runtime/index.ts +6 -0
- package/src/runtime/node.ts +9 -120
- package/src/runtime/pooled-transport.ts +424 -90
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/pyodide-transport.ts +7 -3
- package/src/runtime/rpc-client.ts +37 -139
- package/src/runtime/subprocess-transport.ts +210 -285
- package/src/runtime/timed-out-request-tracker.ts +1 -1
- package/src/runtime/transport.ts +15 -23
- package/src/runtime/validators.ts +204 -0
- package/src/types/index.ts +21 -60
- package/src/tywrap.ts +157 -22
- package/src/utils/cache.ts +3 -3
- package/src/utils/codec.ts +21 -13
- package/src/utils/ir-cache.ts +1 -1
- package/src/utils/runtime.ts +17 -128
- package/src/version.ts +1 -1
- package/dist/core/discovery.d.ts +0 -103
- package/dist/core/discovery.d.ts.map +0 -1
- package/dist/core/discovery.js +0 -380
- package/dist/core/discovery.js.map +0 -1
- package/dist/core/validation.d.ts +0 -102
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js +0 -490
- package/dist/core/validation.js.map +0 -1
- package/dist/runtime/base.d.ts +0 -22
- package/dist/runtime/base.d.ts.map +0 -1
- package/dist/runtime/base.js +0 -23
- package/dist/runtime/base.js.map +0 -1
- package/dist/runtime/transport-pool.d.ts +0 -196
- package/dist/runtime/transport-pool.d.ts.map +0 -1
- package/dist/runtime/transport-pool.js +0 -418
- package/dist/runtime/transport-pool.js.map +0 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/safe_codec.py +0 -352
- package/src/core/discovery.ts +0 -477
- package/src/core/validation.ts +0 -729
- package/src/runtime/base.ts +0 -24
- 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
|
-
*
|
|
287
|
-
* `
|
|
288
|
-
*
|
|
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
|
|
254
|
+
private readonly frameBytes: number;
|
|
293
255
|
|
|
294
256
|
/**
|
|
295
257
|
* Reassembles `tywrap-frame/1` response frames into single logical response
|
|
296
|
-
* lines.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
(
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
|
471
|
-
//
|
|
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.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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`
|
|
496
|
-
*
|
|
497
|
-
* `
|
|
498
|
-
*
|
|
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:
|
|
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
|
|
522
|
-
*
|
|
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
|
-
|
|
678
|
-
this.clearQueuedWriteTimeout(queued);
|
|
679
|
-
queued.reject(error);
|
|
680
|
-
}
|
|
681
|
-
this.writeQueue.length = 0;
|
|
597
|
+
this.rejectAllQueuedWrites(error);
|
|
682
598
|
|
|
683
|
-
//
|
|
684
|
-
|
|
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 =
|
|
692
|
-
|
|
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(
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 =
|
|
850
|
-
|
|
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
|
-
//
|
|
856
|
-
|
|
857
|
-
if (this.
|
|
858
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
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 >
|
|
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
|
|
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
|
-
//
|
|
1291
|
-
// the
|
|
1292
|
-
|
|
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.
|
|
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 (
|
|
1356
|
-
this.
|
|
1357
|
-
|
|
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
|
|
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.
|
|
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
|
// ===========================================================================
|