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