tywrap 0.7.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 +36 -11
- 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/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/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/dev.d.ts.map +1 -1
- package/dist/dev.js +1 -3
- package/dist/dev.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/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/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 +2 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +2 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +7 -23
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +5 -78
- 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 +37 -72
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +134 -8
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +490 -65
- 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 +90 -18
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +21 -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 -15
- 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 -21
- 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 +165 -17
- 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 +430 -0
- package/runtime/python_bridge.py +213 -54
- package/runtime/tywrap_bridge_core.py +217 -147
- package/src/config/index.ts +11 -0
- package/src/core/annotation-parser.ts +9 -5
- package/src/core/emit-call.ts +1 -7
- package/src/core/generator.ts +315 -205
- package/src/core/mapper.ts +8 -8
- package/src/dev.ts +1 -3
- package/src/index.ts +7 -3
- 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/frame-codec.ts +469 -0
- package/src/runtime/http-transport.ts +6 -1
- package/src/runtime/index.ts +7 -6
- package/src/runtime/node.ts +17 -104
- 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 +58 -93
- package/src/runtime/subprocess-transport.ts +585 -80
- package/src/runtime/timed-out-request-tracker.ts +1 -1
- package/src/runtime/transport.ts +112 -22
- package/src/runtime/validators.ts +204 -0
- package/src/types/index.ts +21 -35
- package/src/tywrap.ts +157 -30
- package/src/utils/cache.ts +3 -3
- package/src/utils/codec.ts +205 -16
- 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_member_fixtures.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
|
@@ -17,8 +17,9 @@ 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 type
|
|
22
|
+
import { type Transport, type TransportCapabilities } from './transport.js';
|
|
22
23
|
|
|
23
24
|
// =============================================================================
|
|
24
25
|
// CONSTANTS
|
|
@@ -36,6 +37,34 @@ const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
|
|
|
36
37
|
/** Track timed-out/cancelled request IDs long enough to ignore late responses. */
|
|
37
38
|
const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
|
|
42
|
+
* when sizing the frame-aware stdout line ceiling.
|
|
43
|
+
*
|
|
44
|
+
* A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
|
|
45
|
+
* "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
|
|
46
|
+
* "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
|
|
47
|
+
* plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
|
|
48
|
+
* upper bound that never under-allocates.
|
|
49
|
+
*/
|
|
50
|
+
const FRAME_ENVELOPE_HEADROOM = 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
|
|
54
|
+
* fragment of a JSON response (already-escaped, printable content), so realistic
|
|
55
|
+
* expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
|
|
56
|
+
* ceiling sound without over-allocating buffer headroom.
|
|
57
|
+
*/
|
|
58
|
+
const FRAME_DATA_ESCAPE_FACTOR = 2;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
|
|
62
|
+
* Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
|
|
63
|
+
* buffers more than the codec would ultimately accept; `NodeBridge` overrides it
|
|
64
|
+
* with the configured `codec.maxPayloadBytes`.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
|
|
67
|
+
|
|
39
68
|
/** Regex for ANSI escape sequences */
|
|
40
69
|
const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
41
70
|
|
|
@@ -70,6 +99,15 @@ export interface SubprocessTransportOptions {
|
|
|
70
99
|
|
|
71
100
|
/** Write queue timeout in milliseconds. Default: 30000ms */
|
|
72
101
|
writeQueueTimeoutMs?: number;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cap (UTF-8 bytes) on a single chunked RESPONSE reassembled in memory. A
|
|
105
|
+
* frame stream whose declared or accumulated payload exceeds this fails loud
|
|
106
|
+
* instead of buffering, so chunking cannot OOM the process before the codec's
|
|
107
|
+
* payload cap rejects the result. Should track the codec's `maxPayloadBytes`.
|
|
108
|
+
* Default: 10 MiB (matches the codec default).
|
|
109
|
+
*/
|
|
110
|
+
maxReassemblyBytes?: number;
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
/**
|
|
@@ -92,6 +130,15 @@ interface QueuedWrite {
|
|
|
92
130
|
queuedAt: number;
|
|
93
131
|
/** Timeout handle for write queue timeout */
|
|
94
132
|
timeoutHandle?: NodeJS.Timeout;
|
|
133
|
+
/**
|
|
134
|
+
* Liveness predicate. If present and `false` at flush time, the write is
|
|
135
|
+
* SKIPPED (resolved as a no-op) instead of sent — so a request that timed out
|
|
136
|
+
* or aborted while its write sat in the stdin backpressure queue never reaches
|
|
137
|
+
* (and so never executes on) Python.
|
|
138
|
+
*/
|
|
139
|
+
isLive?: () => boolean;
|
|
140
|
+
/** True while this entry is waiting to be flushed. */
|
|
141
|
+
isQueued?: boolean;
|
|
95
142
|
}
|
|
96
143
|
|
|
97
144
|
// =============================================================================
|
|
@@ -129,6 +176,36 @@ function extractMessageId(json: string): number | null {
|
|
|
129
176
|
return id;
|
|
130
177
|
}
|
|
131
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Result of inspecting a stdout line for `tywrap-frame/1` framing.
|
|
181
|
+
* - `{ kind: 'frame', value }`: the line parsed as JSON carrying a
|
|
182
|
+
* `__tywrap_frame__` marker (handed to the reassembler, which validates it).
|
|
183
|
+
* - `{ kind: 'plain' }`: valid JSON without a framing marker (a normal,
|
|
184
|
+
* single-line response).
|
|
185
|
+
* - `{ kind: 'invalid' }`: not parseable as JSON.
|
|
186
|
+
*/
|
|
187
|
+
type FrameLineProbe = { kind: 'frame'; value: unknown } | { kind: 'plain' } | { kind: 'invalid' };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
|
|
191
|
+
*
|
|
192
|
+
* A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
|
|
193
|
+
* envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
|
|
194
|
+
* by the {@link Reassembler}, not here — this only routes the line.
|
|
195
|
+
*/
|
|
196
|
+
function probeFrameLine(line: string): FrameLineProbe {
|
|
197
|
+
let parsed: unknown;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(line);
|
|
200
|
+
} catch {
|
|
201
|
+
return { kind: 'invalid' };
|
|
202
|
+
}
|
|
203
|
+
if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
|
|
204
|
+
return { kind: 'frame', value: parsed };
|
|
205
|
+
}
|
|
206
|
+
return { kind: 'plain' };
|
|
207
|
+
}
|
|
208
|
+
|
|
132
209
|
// =============================================================================
|
|
133
210
|
// PROCESS IO TRANSPORT
|
|
134
211
|
// =============================================================================
|
|
@@ -165,9 +242,24 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
165
242
|
private readonly envOverrides: Record<string, string>;
|
|
166
243
|
private readonly cwd: string | undefined;
|
|
167
244
|
private readonly maxLineLength: number;
|
|
245
|
+
private readonly maxReassemblyBytes: number;
|
|
168
246
|
private readonly restartAfterRequests: number;
|
|
169
247
|
private readonly writeQueueTimeoutMs: number;
|
|
170
248
|
|
|
249
|
+
/**
|
|
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.
|
|
253
|
+
*/
|
|
254
|
+
private readonly frameBytes: number;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Reassembles `tywrap-frame/1` response frames into single logical response
|
|
258
|
+
* lines. Per-id discard tracks
|
|
259
|
+
* timed-out/aborted streams so late frames cannot desync stdout.
|
|
260
|
+
*/
|
|
261
|
+
private responseReassembler: Reassembler | null = null;
|
|
262
|
+
|
|
171
263
|
// Process state
|
|
172
264
|
private process: ChildProcess | null = null;
|
|
173
265
|
private processExited = false;
|
|
@@ -175,6 +267,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
175
267
|
|
|
176
268
|
// Stream buffers
|
|
177
269
|
private stdoutBuffer = '';
|
|
270
|
+
private stdoutScanOffset = 0;
|
|
178
271
|
private stderrBuffer = '';
|
|
179
272
|
|
|
180
273
|
// Request tracking
|
|
@@ -184,11 +277,27 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
184
277
|
});
|
|
185
278
|
private requestCount = 0;
|
|
186
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> = [];
|
|
187
284
|
|
|
188
285
|
// Write queue for backpressure
|
|
189
286
|
private readonly writeQueue: QueuedWrite[] = [];
|
|
287
|
+
private writeQueueHead = 0;
|
|
190
288
|
private draining = false;
|
|
191
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Per-logical-request write mutex (W5). When a request is chunked into
|
|
292
|
+
* `tywrap-frame/1` frames, all of that request's frames must reach stdin
|
|
293
|
+
* contiguously — no other request's frame (or single line) may interleave —
|
|
294
|
+
* or the Python reassembler would see frames from two ids mixed on one stream.
|
|
295
|
+
* `writeChunkedRequest` chains the whole frame burst onto this tail; the
|
|
296
|
+
* single-line write path also serializes behind it so a small request issued
|
|
297
|
+
* concurrently never slips between another request's frames.
|
|
298
|
+
*/
|
|
299
|
+
private writeMutex: Promise<void> = Promise.resolve();
|
|
300
|
+
|
|
192
301
|
/**
|
|
193
302
|
* Create a new SubprocessTransport.
|
|
194
303
|
*
|
|
@@ -204,6 +313,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
204
313
|
this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
205
314
|
this.restartAfterRequests = options.restartAfterRequests ?? 0;
|
|
206
315
|
this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
|
|
316
|
+
this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
|
|
317
|
+
this.frameBytes = this.maxLineLength;
|
|
318
|
+
this.responseReassembler = new Reassembler({
|
|
319
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
320
|
+
expectedStream: 'response',
|
|
321
|
+
});
|
|
207
322
|
}
|
|
208
323
|
|
|
209
324
|
// ===========================================================================
|
|
@@ -218,7 +333,12 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
218
333
|
* @param signal - Optional AbortSignal for cancellation
|
|
219
334
|
* @returns The raw JSON response string
|
|
220
335
|
*/
|
|
221
|
-
async send(
|
|
336
|
+
async send(
|
|
337
|
+
message: string,
|
|
338
|
+
timeoutMs: number,
|
|
339
|
+
signal?: AbortSignal,
|
|
340
|
+
requestId?: number
|
|
341
|
+
): Promise<string> {
|
|
222
342
|
// Check disposed state
|
|
223
343
|
if (this.isDisposed) {
|
|
224
344
|
throw new BridgeDisposedError('Transport has been disposed');
|
|
@@ -243,27 +363,50 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
243
363
|
}
|
|
244
364
|
|
|
245
365
|
// Extract message ID for response correlation
|
|
246
|
-
const messageId = extractMessageId(message);
|
|
366
|
+
const messageId = requestId ?? extractMessageId(message);
|
|
247
367
|
if (messageId === null) {
|
|
248
368
|
throw new BridgeProtocolError('Message must contain an "id" field');
|
|
249
369
|
}
|
|
250
370
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
371
|
+
let ownsDispatchReservation = false;
|
|
372
|
+
let reservationReleased = false;
|
|
373
|
+
const releaseOwnedReservation = (): void => {
|
|
374
|
+
if (ownsDispatchReservation && !reservationReleased) {
|
|
375
|
+
reservationReleased = true;
|
|
376
|
+
this.releaseDispatch();
|
|
377
|
+
}
|
|
378
|
+
};
|
|
258
379
|
|
|
259
380
|
// Create promise for response
|
|
260
|
-
|
|
381
|
+
const response = new Promise<string>((resolve, reject) => {
|
|
261
382
|
// Set up timeout if specified
|
|
262
383
|
let timer: NodeJS.Timeout | undefined;
|
|
384
|
+
|
|
385
|
+
// Defined before the timer so the timeout path can also detach it.
|
|
386
|
+
const abortHandler = (): void => {
|
|
387
|
+
if (timer) {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
}
|
|
390
|
+
this.pending.delete(messageId);
|
|
391
|
+
this.timedOutRequests.mark(messageId);
|
|
392
|
+
// Same late-frame discard as the timeout path (see below).
|
|
393
|
+
this.responseReassembler?.discard(messageId);
|
|
394
|
+
reject(new BridgeTimeoutError('Operation aborted'));
|
|
395
|
+
};
|
|
396
|
+
|
|
263
397
|
if (timeoutMs > 0) {
|
|
264
398
|
timer = setTimeout(() => {
|
|
265
399
|
this.pending.delete(messageId);
|
|
266
400
|
this.timedOutRequests.mark(messageId);
|
|
401
|
+
// Discard any in-flight chunked response stream for this id so late
|
|
402
|
+
// frames are dropped rather than desyncing stdout (the single-line
|
|
403
|
+
// timedOutRequests.consume above is one-shot and insufficient for a
|
|
404
|
+
// multi-frame stream).
|
|
405
|
+
this.responseReassembler?.discard(messageId);
|
|
406
|
+
// Detach the abort listener: on timeout the abort never fires, so
|
|
407
|
+
// without this it would leak on a long-lived AbortSignal and could
|
|
408
|
+
// re-enter abortHandler after this promise has already settled.
|
|
409
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
267
410
|
const stderrTail = this.getStderrTail();
|
|
268
411
|
const baseMsg = `Operation timed out after ${timeoutMs}ms`;
|
|
269
412
|
const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
|
|
@@ -271,16 +414,6 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
271
414
|
}, timeoutMs);
|
|
272
415
|
}
|
|
273
416
|
|
|
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
417
|
if (signal) {
|
|
285
418
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
286
419
|
}
|
|
@@ -302,41 +435,130 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
302
435
|
reject(error);
|
|
303
436
|
};
|
|
304
437
|
|
|
305
|
-
// Register pending request
|
|
306
|
-
|
|
438
|
+
// Register pending request. Captured by reference so the write path can
|
|
439
|
+
// bind liveness to THIS exact entry (not just the id) — see writeRequest.
|
|
440
|
+
const pendingEntry: PendingRequest = {
|
|
307
441
|
resolve: wrappedResolve,
|
|
308
442
|
reject: wrappedReject,
|
|
309
443
|
timer,
|
|
310
|
-
}
|
|
444
|
+
};
|
|
445
|
+
this.pending.set(messageId, pendingEntry);
|
|
446
|
+
|
|
447
|
+
// Write message to stdin. When the encoded request exceeds the per-frame
|
|
448
|
+
// ceiling, it is fragmented into
|
|
449
|
+
// `tywrap-frame/1` request frames written contiguously under the write
|
|
450
|
+
// mutex (W5); otherwise it goes out as a single JSONL line. Both paths
|
|
451
|
+
// serialize through the same mutex so a small request can never slip
|
|
452
|
+
// between another request's frames.
|
|
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
|
+
}
|
|
311
479
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
+
}
|
|
321
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++;
|
|
322
527
|
this.requestCount++;
|
|
528
|
+
return true;
|
|
323
529
|
});
|
|
324
530
|
}
|
|
325
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
|
+
|
|
326
545
|
/**
|
|
327
546
|
* Static capability descriptor for the subprocess backend.
|
|
328
547
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
548
|
+
* Per the {@link Transport.capabilities} contract this is lifecycle-independent
|
|
549
|
+
* (safe before `init()` / after `dispose()`) and never makes a Python round
|
|
550
|
+
* trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
|
|
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.
|
|
333
555
|
*/
|
|
334
556
|
capabilities(): TransportCapabilities {
|
|
335
557
|
return {
|
|
336
558
|
backend: 'subprocess',
|
|
337
559
|
supportsArrow: true,
|
|
338
560
|
supportsBinary: true,
|
|
339
|
-
supportsChunking:
|
|
561
|
+
supportsChunking: true,
|
|
340
562
|
supportsStreaming: false,
|
|
341
563
|
maxFrameBytes: this.maxLineLength,
|
|
342
564
|
};
|
|
@@ -347,7 +569,8 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
347
569
|
// ===========================================================================
|
|
348
570
|
|
|
349
571
|
/**
|
|
350
|
-
* Initialize the transport by spawning the Python process.
|
|
572
|
+
* Initialize the transport by spawning the Python process. The packaged
|
|
573
|
+
* bridge always speaks `tywrap-frame/1`.
|
|
351
574
|
*/
|
|
352
575
|
protected async doInit(): Promise<void> {
|
|
353
576
|
await this.spawnProcess();
|
|
@@ -371,20 +594,26 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
371
594
|
this.pending.clear();
|
|
372
595
|
|
|
373
596
|
// Clear write queue
|
|
374
|
-
|
|
375
|
-
this.clearQueuedWriteTimeout(queued);
|
|
376
|
-
queued.reject(error);
|
|
377
|
-
}
|
|
378
|
-
this.writeQueue.length = 0;
|
|
597
|
+
this.rejectAllQueuedWrites(error);
|
|
379
598
|
|
|
380
|
-
//
|
|
381
|
-
|
|
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());
|
|
382
603
|
|
|
383
604
|
// Clear buffers
|
|
384
605
|
this.stdoutBuffer = '';
|
|
606
|
+
this.stdoutScanOffset = 0;
|
|
385
607
|
this.stderrBuffer = '';
|
|
386
608
|
this.timedOutRequests.clear();
|
|
387
609
|
this.requestCount = 0;
|
|
610
|
+
this.responseReassembler = new Reassembler({
|
|
611
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
612
|
+
expectedStream: 'response',
|
|
613
|
+
});
|
|
614
|
+
// Reset the per-request write mutex so a disposed transport starts from a
|
|
615
|
+
// clean (resolved) tail if reused.
|
|
616
|
+
this.writeMutex = Promise.resolve();
|
|
388
617
|
}
|
|
389
618
|
|
|
390
619
|
// ===========================================================================
|
|
@@ -399,6 +628,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
399
628
|
// If env is provided, it should be the complete environment (already filtered by NodeBridge)
|
|
400
629
|
// We only add Python-specific variables on top
|
|
401
630
|
const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
|
|
631
|
+
|
|
632
|
+
// Advertise `tywrap-frame/1` chunked transport so the bridge fragments
|
|
633
|
+
// oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
|
|
634
|
+
// caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
|
|
635
|
+
// (see the frame-aware line ceiling) allows for the JSON envelope + escaping
|
|
636
|
+
// on top of it. Spread (rather than dynamic index assignment) keeps the
|
|
637
|
+
// computed keys off ESLint's object-injection sink. See
|
|
638
|
+
// docs/transport-framing.md.
|
|
402
639
|
const env: NodeJS.ProcessEnv = {
|
|
403
640
|
...baseEnv,
|
|
404
641
|
// Ensure Python uses UTF-8
|
|
@@ -409,11 +646,15 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
409
646
|
};
|
|
410
647
|
|
|
411
648
|
// Spawn process
|
|
412
|
-
this.process = spawn(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
);
|
|
417
658
|
|
|
418
659
|
this.processExited = false;
|
|
419
660
|
this.processError = null;
|
|
@@ -499,16 +740,37 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
499
740
|
* Restart the Python process.
|
|
500
741
|
*/
|
|
501
742
|
private async restartProcess(): Promise<void> {
|
|
743
|
+
if (this.isLifecycleEnding()) {
|
|
744
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
745
|
+
}
|
|
746
|
+
|
|
502
747
|
// Kill existing process
|
|
503
748
|
await this.killProcess();
|
|
504
749
|
|
|
505
750
|
// Clear buffers and restart flags
|
|
506
751
|
this.stdoutBuffer = '';
|
|
752
|
+
this.stdoutScanOffset = 0;
|
|
507
753
|
this.stderrBuffer = '';
|
|
508
754
|
this.requestCount = 0;
|
|
509
755
|
this.needsRestart = false;
|
|
756
|
+
// Drop any partial reassembly + discard tracking: the new process owns a
|
|
757
|
+
// fresh stdout stream, so stale per-id state from the dead process must not
|
|
758
|
+
// leak across the restart boundary.
|
|
759
|
+
this.responseReassembler = new Reassembler({
|
|
760
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
761
|
+
expectedStream: 'response',
|
|
762
|
+
});
|
|
763
|
+
// The new process owns a fresh stdin stream; reset the write mutex so a
|
|
764
|
+
// pending frame burst against the dead process cannot serialize behind it.
|
|
765
|
+
this.writeMutex = Promise.resolve();
|
|
766
|
+
|
|
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');
|
|
771
|
+
}
|
|
510
772
|
|
|
511
|
-
// Spawn
|
|
773
|
+
// Spawn a fresh process with the packaged always-on framing protocol.
|
|
512
774
|
await this.spawnProcess();
|
|
513
775
|
}
|
|
514
776
|
|
|
@@ -525,46 +787,81 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
525
787
|
// STREAM HANDLERS
|
|
526
788
|
// ===========================================================================
|
|
527
789
|
|
|
790
|
+
/**
|
|
791
|
+
* Effective stdout line ceiling.
|
|
792
|
+
*
|
|
793
|
+
* A single wire line may be a `tywrap-frame/1` envelope whose
|
|
794
|
+
* `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
|
|
795
|
+
* JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
|
|
796
|
+
* to bound that overhead so a legitimate frame line is never rejected while a
|
|
797
|
+
* runaway/garbage line still is.
|
|
798
|
+
*/
|
|
799
|
+
private effectiveLineCeiling(): number {
|
|
800
|
+
return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
|
|
801
|
+
}
|
|
802
|
+
|
|
528
803
|
/**
|
|
529
804
|
* Handle stdout data from the Python process.
|
|
530
805
|
*/
|
|
531
806
|
private handleStdoutData(chunk: Buffer | string): void {
|
|
532
807
|
this.stdoutBuffer += chunk.toString();
|
|
533
808
|
|
|
534
|
-
|
|
535
|
-
|
|
809
|
+
const ceiling = this.effectiveLineCeiling();
|
|
810
|
+
|
|
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) {
|
|
536
814
|
const snippet = this.stdoutBuffer.slice(0, 500);
|
|
537
815
|
this.stdoutBuffer = '';
|
|
538
|
-
this.
|
|
816
|
+
this.stdoutScanOffset = 0;
|
|
817
|
+
this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
|
|
539
818
|
return;
|
|
540
819
|
}
|
|
541
820
|
|
|
542
821
|
// Process complete lines
|
|
543
|
-
|
|
544
|
-
while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
|
|
822
|
+
while (newlineIndex !== -1) {
|
|
545
823
|
const line = this.stdoutBuffer.slice(0, newlineIndex);
|
|
546
824
|
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
825
|
+
this.stdoutScanOffset = 0;
|
|
547
826
|
|
|
548
827
|
// Skip empty lines
|
|
549
828
|
if (!line.trim()) {
|
|
829
|
+
newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
|
|
550
830
|
continue;
|
|
551
831
|
}
|
|
552
832
|
|
|
553
833
|
// Check line length
|
|
554
|
-
if (line.length >
|
|
834
|
+
if (line.length > ceiling) {
|
|
555
835
|
const snippet = line.slice(0, 500);
|
|
556
|
-
this.handleProtocolError(`Response line exceeded ${
|
|
836
|
+
this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
|
|
557
837
|
return;
|
|
558
838
|
}
|
|
559
839
|
|
|
560
840
|
this.handleResponseLine(line);
|
|
841
|
+
newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
|
|
561
842
|
}
|
|
843
|
+
this.stdoutScanOffset = this.stdoutBuffer.length;
|
|
562
844
|
}
|
|
563
845
|
|
|
564
846
|
/**
|
|
565
847
|
* Handle a complete response line from stdout.
|
|
848
|
+
*
|
|
849
|
+
* A line may be a `tywrap-frame/1` envelope: it is
|
|
850
|
+
* routed into the per-id {@link Reassembler}, which returns the reassembled
|
|
851
|
+
* logical response only once the stream is complete and valid. Single-line
|
|
852
|
+
* (non-frame) responses keep the original fast path unchanged.
|
|
566
853
|
*/
|
|
567
854
|
private handleResponseLine(line: string): void {
|
|
855
|
+
if (this.responseReassembler) {
|
|
856
|
+
const probe = probeFrameLine(line);
|
|
857
|
+
if (probe.kind === 'frame') {
|
|
858
|
+
this.handleResponseFrame(probe.value, line);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// probe.kind 'plain'/'invalid' falls through to the legacy single-line
|
|
862
|
+
// path below, which extracts the id and validates JSON as before.
|
|
863
|
+
}
|
|
864
|
+
|
|
568
865
|
// Extract ID to find pending request
|
|
569
866
|
const messageId = extractMessageId(line);
|
|
570
867
|
if (messageId === null) {
|
|
@@ -595,6 +892,75 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
595
892
|
pending.resolve(line);
|
|
596
893
|
}
|
|
597
894
|
|
|
895
|
+
/**
|
|
896
|
+
* Route one `tywrap-frame/1` response frame into the reassembler.
|
|
897
|
+
*
|
|
898
|
+
* On completion the reassembled logical line resolves the pending request. The
|
|
899
|
+
* reassembler validates structure, ordering, byte count, and UTF-8 internally
|
|
900
|
+
* and throws on any framing violation (malformed/duplicate/byte-mismatch/
|
|
901
|
+
* unknown-protocol) — those reject the pending id and mark the subprocess for
|
|
902
|
+
* restart, since stdout can no longer be trusted to be frame-aligned. Frames
|
|
903
|
+
* for a timed-out/aborted id are silently discarded by the reassembler (it
|
|
904
|
+
* returns `null` and tracks the discard set) so late multi-frame responses
|
|
905
|
+
* cannot desync the stream.
|
|
906
|
+
*/
|
|
907
|
+
private handleResponseFrame(rawFrame: unknown, line: string): void {
|
|
908
|
+
const reassembler = this.responseReassembler;
|
|
909
|
+
if (!reassembler) {
|
|
910
|
+
// Unreachable: the always-on framing path constructs the reassembler.
|
|
911
|
+
this.handleProtocolError('Received frame with no reassembler', line);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const frameId = (rawFrame as { id?: unknown }).id;
|
|
916
|
+
|
|
917
|
+
let reassembled: string | null;
|
|
918
|
+
try {
|
|
919
|
+
reassembled = reassembler.accept(rawFrame);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
// Framing corruption: stdout is no longer frame-aligned. Reject the
|
|
922
|
+
// correlated pending request (if any) and force a restart.
|
|
923
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
924
|
+
this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
|
|
925
|
+
this.markForRestart();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (reassembled === null) {
|
|
930
|
+
// More frames needed, or this frame belonged to a discarded (timed-out)
|
|
931
|
+
// id and was dropped. Either way: nothing to resolve yet.
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Stream complete: resolve the correlated pending request with the single
|
|
936
|
+
// logical response line. extractMessageId is reused so the resolution path
|
|
937
|
+
// matches the non-chunked case exactly.
|
|
938
|
+
this.handleResponseLine(reassembled);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Reject the pending request correlated to a frame id, if one exists.
|
|
943
|
+
*
|
|
944
|
+
* Used when frame reassembly throws. If the id is unknown (e.g. it already
|
|
945
|
+
* timed out and was dropped from `pending`), the error still surfaces as a
|
|
946
|
+
* protocol error so the desync is not swallowed silently.
|
|
947
|
+
*/
|
|
948
|
+
private rejectFrameId(frameId: unknown, details: string, line: string): void {
|
|
949
|
+
if (typeof frameId === 'number' && Number.isInteger(frameId)) {
|
|
950
|
+
const pending = this.pending.get(frameId);
|
|
951
|
+
if (pending) {
|
|
952
|
+
this.pending.delete(frameId);
|
|
953
|
+
if (pending.timer) {
|
|
954
|
+
clearTimeout(pending.timer);
|
|
955
|
+
}
|
|
956
|
+
pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// No correlated pending request: still a protocol-level desync.
|
|
961
|
+
this.handleProtocolError(details, line);
|
|
962
|
+
}
|
|
963
|
+
|
|
598
964
|
/**
|
|
599
965
|
* Handle stderr data from the Python process.
|
|
600
966
|
*/
|
|
@@ -665,11 +1031,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
665
1031
|
const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
|
|
666
1032
|
|
|
667
1033
|
// Reject all pending writes
|
|
668
|
-
|
|
669
|
-
this.clearQueuedWriteTimeout(queued);
|
|
670
|
-
queued.reject(error);
|
|
671
|
-
}
|
|
672
|
-
this.writeQueue.length = 0;
|
|
1034
|
+
this.rejectAllQueuedWrites(error);
|
|
673
1035
|
|
|
674
1036
|
// Reject all pending requests
|
|
675
1037
|
this.rejectAllPending(error);
|
|
@@ -710,17 +1072,16 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
710
1072
|
private createQueuedWrite(
|
|
711
1073
|
data: string,
|
|
712
1074
|
resolve: () => void,
|
|
713
|
-
reject: (error: Error) => void
|
|
1075
|
+
reject: (error: Error) => void,
|
|
1076
|
+
isLive?: () => boolean
|
|
714
1077
|
): QueuedWrite {
|
|
715
1078
|
const queuedAt = Date.now();
|
|
716
|
-
const entry: QueuedWrite = { data, resolve, reject, queuedAt };
|
|
1079
|
+
const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive, isQueued: true };
|
|
717
1080
|
|
|
718
1081
|
// Set up timeout timer that fires if drain never happens
|
|
719
1082
|
entry.timeoutHandle = setTimeout(() => {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (index !== -1) {
|
|
723
|
-
this.writeQueue.splice(index, 1);
|
|
1083
|
+
if (entry.isQueued) {
|
|
1084
|
+
entry.isQueued = false;
|
|
724
1085
|
reject(
|
|
725
1086
|
new BridgeTimeoutError(
|
|
726
1087
|
`Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`
|
|
@@ -750,16 +1111,24 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
750
1111
|
/**
|
|
751
1112
|
* Write data to stdin with backpressure handling.
|
|
752
1113
|
*/
|
|
753
|
-
private writeToStdin(data: string): Promise<void> {
|
|
1114
|
+
private writeToStdin(data: string, isLive?: () => boolean): Promise<void> {
|
|
754
1115
|
return new Promise<void>((resolve, reject) => {
|
|
755
1116
|
if (!this.process?.stdin || this.processExited) {
|
|
756
1117
|
reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
|
|
757
1118
|
return;
|
|
758
1119
|
}
|
|
759
1120
|
|
|
760
|
-
if (this.draining || this.writeQueue.length >
|
|
761
|
-
// Queue the write with timestamp and
|
|
762
|
-
|
|
1121
|
+
if (this.draining || this.writeQueue.length > this.writeQueueHead) {
|
|
1122
|
+
// Queue the write with timestamp, timeout timer, and liveness predicate
|
|
1123
|
+
// (checked again at flush — see processQueuedWrite).
|
|
1124
|
+
this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Skip a write whose request was abandoned (timed out/aborted) before it
|
|
1129
|
+
// reached stdin — never execute an operation the caller gave up on.
|
|
1130
|
+
if (isLive && !isLive()) {
|
|
1131
|
+
resolve();
|
|
763
1132
|
return;
|
|
764
1133
|
}
|
|
765
1134
|
|
|
@@ -784,16 +1153,124 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
784
1153
|
});
|
|
785
1154
|
}
|
|
786
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
|
|
1158
|
+
* request frames when the encoded request exceeds
|
|
1159
|
+
* the per-frame ceiling (W5 — the mirror of W4's response chunking).
|
|
1160
|
+
*
|
|
1161
|
+
* Both the chunked and single-line paths run under {@link writeMutex} so a
|
|
1162
|
+
* logical request's bytes (one line, or a burst of frames) reach stdin
|
|
1163
|
+
* contiguously: a small request issued concurrently can never interleave
|
|
1164
|
+
* between another request's frames, which would desync the Python
|
|
1165
|
+
* reassembler (it correlates frames by id, but the JSONL stream itself must
|
|
1166
|
+
* stay frame-aligned). The mutex tail is advanced regardless of success so a
|
|
1167
|
+
* failed write never wedges every subsequent request.
|
|
1168
|
+
*
|
|
1169
|
+
* @param message - the encoded logical JSON request (no trailing newline)
|
|
1170
|
+
* @param messageId - the request's correlation id (already validated integer)
|
|
1171
|
+
* @param signal - optional abort signal; an abort observed between frames
|
|
1172
|
+
* stops further frames and rejects this send (the pending entry is rejected
|
|
1173
|
+
* by the abort handler / the caller's `.catch`).
|
|
1174
|
+
*/
|
|
1175
|
+
private writeRequest(
|
|
1176
|
+
message: string,
|
|
1177
|
+
messageId: number,
|
|
1178
|
+
signal?: AbortSignal,
|
|
1179
|
+
pendingEntry?: PendingRequest
|
|
1180
|
+
): Promise<void> {
|
|
1181
|
+
// The request is "live" only while THIS send's exact pending entry is still
|
|
1182
|
+
// registered (the timeout and abort handlers delete it) and the signal is not
|
|
1183
|
+
// aborted. Binding to the entry IDENTITY — not just the id — closes the
|
|
1184
|
+
// id-reuse hole: if the id is recycled by a later send while this write is
|
|
1185
|
+
// still queued, the stale write sees a different entry and is skipped.
|
|
1186
|
+
// (RpcClient ids are monotonic, but Transport.send is public and does not
|
|
1187
|
+
// enforce id uniqueness.) Gating EVERY write point on this — the run closure,
|
|
1188
|
+
// the direct stdin write, the backpressure-queue flush, and each chunked
|
|
1189
|
+
// frame — prevents an abandoned request from executing on Python, even one
|
|
1190
|
+
// whose write sat queued under backpressure past the cancellation.
|
|
1191
|
+
const isLive = (): boolean =>
|
|
1192
|
+
!signal?.aborted &&
|
|
1193
|
+
(pendingEntry !== undefined
|
|
1194
|
+
? this.pending.get(messageId) === pendingEntry
|
|
1195
|
+
: this.pending.has(messageId));
|
|
1196
|
+
const run = (): Promise<void> => {
|
|
1197
|
+
if (!isLive()) {
|
|
1198
|
+
return Promise.resolve();
|
|
1199
|
+
}
|
|
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) {
|
|
1203
|
+
return this.writeChunkedRequest(message, messageId, signal, isLive);
|
|
1204
|
+
}
|
|
1205
|
+
return this.writeToStdin(`${message}\n`, isLive);
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
// Serialize the whole logical write onto the mutex tail. We chain the next
|
|
1209
|
+
// tail off the settled (caught) result so one failed/aborted write does not
|
|
1210
|
+
// poison the chain for later requests.
|
|
1211
|
+
const result = this.writeMutex.then(run);
|
|
1212
|
+
this.writeMutex = result.then(
|
|
1213
|
+
() => undefined,
|
|
1214
|
+
() => undefined
|
|
1215
|
+
);
|
|
1216
|
+
return result;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Fragment a logical request into `tywrap-frame/1` request frames and write
|
|
1221
|
+
* them contiguously (one JSONL line per frame). Runs while holding the write
|
|
1222
|
+
* mutex (see {@link writeRequest}), so no other write interleaves.
|
|
1223
|
+
*
|
|
1224
|
+
* Each frame is awaited in turn so backpressure on stdin is respected. Before
|
|
1225
|
+
* every frame the abort signal and process liveness are re-checked: an abort
|
|
1226
|
+
* mid-burst stops the remaining frames and rejects the send LOUD (the Python
|
|
1227
|
+
* reassembler drops the now-incomplete id when the next request's frames /
|
|
1228
|
+
* timeout arrive, exactly as the response side handles a discarded id).
|
|
1229
|
+
*/
|
|
1230
|
+
private async writeChunkedRequest(
|
|
1231
|
+
message: string,
|
|
1232
|
+
messageId: number,
|
|
1233
|
+
signal?: AbortSignal,
|
|
1234
|
+
isLive?: () => boolean
|
|
1235
|
+
): Promise<void> {
|
|
1236
|
+
const frames = encodeFrames(message, {
|
|
1237
|
+
id: messageId,
|
|
1238
|
+
stream: 'request',
|
|
1239
|
+
maxFrameBytes: this.frameBytes,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
for (const frame of frames) {
|
|
1243
|
+
// Stop the burst if the caller aborted, the request was abandoned (timed
|
|
1244
|
+
// out -> pending deleted, caught by isLive), or the process died between
|
|
1245
|
+
// frames. A partial request stream is dropped by the Python reassembler.
|
|
1246
|
+
if (signal?.aborted || (isLive && !isLive())) {
|
|
1247
|
+
throw new BridgeTimeoutError('Operation aborted');
|
|
1248
|
+
}
|
|
1249
|
+
if (this.processExited || !this.process) {
|
|
1250
|
+
throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
|
|
1251
|
+
}
|
|
1252
|
+
// One frame per JSONL line; await each so stdin backpressure is honored.
|
|
1253
|
+
// isLive gates a frame that ends up queued under backpressure past a late
|
|
1254
|
+
// cancellation, so an abandoned chunked request never completes on Python.
|
|
1255
|
+
await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
787
1259
|
/**
|
|
788
1260
|
* Reject and clear every entry currently in the write queue.
|
|
789
1261
|
* Clears each entry's timeout before rejecting so no late timer fires.
|
|
790
1262
|
*/
|
|
791
1263
|
private rejectAllQueuedWrites(error: Error): void {
|
|
792
|
-
for (
|
|
793
|
-
this.
|
|
794
|
-
|
|
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
|
+
}
|
|
795
1271
|
}
|
|
796
1272
|
this.writeQueue.length = 0;
|
|
1273
|
+
this.writeQueueHead = 0;
|
|
797
1274
|
}
|
|
798
1275
|
|
|
799
1276
|
/**
|
|
@@ -821,6 +1298,14 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
821
1298
|
return 'continue';
|
|
822
1299
|
}
|
|
823
1300
|
|
|
1301
|
+
// Skip the write if the request was abandoned (timed out/aborted) while it
|
|
1302
|
+
// sat in the backpressure queue — never execute an operation the caller gave
|
|
1303
|
+
// up on. Resolve as a no-op so the mutex chain stays healthy.
|
|
1304
|
+
if (queued.isLive && !queued.isLive()) {
|
|
1305
|
+
queued.resolve();
|
|
1306
|
+
return 'continue';
|
|
1307
|
+
}
|
|
1308
|
+
|
|
824
1309
|
try {
|
|
825
1310
|
const canWrite = stdin.write(queued.data);
|
|
826
1311
|
|
|
@@ -851,7 +1336,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
851
1336
|
private flushWriteQueue(): void {
|
|
852
1337
|
const now = Date.now();
|
|
853
1338
|
|
|
854
|
-
while (this.writeQueue.length
|
|
1339
|
+
while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
|
|
855
1340
|
const stdin = this.process?.stdin;
|
|
856
1341
|
if (!stdin || this.processExited) {
|
|
857
1342
|
// Process died - reject all queued writes
|
|
@@ -862,11 +1347,17 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
862
1347
|
return;
|
|
863
1348
|
}
|
|
864
1349
|
|
|
865
|
-
const queued = this.writeQueue.
|
|
1350
|
+
const queued = this.writeQueue[this.writeQueueHead];
|
|
1351
|
+
this.writeQueueHead += 1;
|
|
866
1352
|
if (!queued) {
|
|
867
1353
|
return;
|
|
868
1354
|
}
|
|
869
1355
|
|
|
1356
|
+
if (!queued.isQueued) {
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
queued.isQueued = false;
|
|
1360
|
+
|
|
870
1361
|
// Clear the timeout since we're processing this entry now
|
|
871
1362
|
this.clearQueuedWriteTimeout(queued);
|
|
872
1363
|
|
|
@@ -875,6 +1366,11 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
875
1366
|
return;
|
|
876
1367
|
}
|
|
877
1368
|
}
|
|
1369
|
+
|
|
1370
|
+
if (this.writeQueueHead === this.writeQueue.length) {
|
|
1371
|
+
this.writeQueue.length = 0;
|
|
1372
|
+
this.writeQueueHead = 0;
|
|
1373
|
+
}
|
|
878
1374
|
}
|
|
879
1375
|
|
|
880
1376
|
// ===========================================================================
|
|
@@ -897,7 +1393,15 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
897
1393
|
}
|
|
898
1394
|
|
|
899
1395
|
/**
|
|
900
|
-
* Handle a protocol error by rejecting all pending requests
|
|
1396
|
+
* Handle a protocol error by rejecting all pending requests and marking the
|
|
1397
|
+
* subprocess for restart.
|
|
1398
|
+
*
|
|
1399
|
+
* Every caller represents genuine stdout-stream corruption (a too-long line, a
|
|
1400
|
+
* response with no `id`, a frame with no reassembler, or a truly unexpected id
|
|
1401
|
+
* — benign late responses from timed-out requests are already filtered upstream
|
|
1402
|
+
* via {@link timedOutRequests}). After such an error stdout can no longer be
|
|
1403
|
+
* trusted to be line/frame-aligned, so the process is marked for restart —
|
|
1404
|
+
* matching the frame-reassembly-corruption path and the framing spec.
|
|
901
1405
|
*/
|
|
902
1406
|
private handleProtocolError(details: string, line?: string): void {
|
|
903
1407
|
const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
|
|
@@ -910,6 +1414,7 @@ export class SubprocessTransport extends DisposableBase implements Transport {
|
|
|
910
1414
|
|
|
911
1415
|
const error = new BridgeProtocolError(msg);
|
|
912
1416
|
this.rejectAllPending(error);
|
|
1417
|
+
this.markForRestart();
|
|
913
1418
|
}
|
|
914
1419
|
|
|
915
1420
|
/**
|