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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { spawn } from 'child_process';
|
|
16
16
|
import { DisposableBase } from './bounded-context.js';
|
|
17
17
|
import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js';
|
|
18
|
+
import { Reassembler, encodeFrames, utf8ByteLength } from './frame-codec.js';
|
|
18
19
|
import { TimedOutRequestTracker } from './timed-out-request-tracker.js';
|
|
19
20
|
// =============================================================================
|
|
20
21
|
// CONSTANTS
|
|
@@ -27,6 +28,31 @@ const MAX_STDERR_BYTES = 8 * 1024;
|
|
|
27
28
|
const DEFAULT_WRITE_QUEUE_TIMEOUT_MS = 30_000;
|
|
28
29
|
/** Track timed-out/cancelled request IDs long enough to ignore late responses. */
|
|
29
30
|
const TIMED_OUT_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
31
|
+
/**
|
|
32
|
+
* Per-frame envelope headroom (bytes) reserved on top of a frame's data slice
|
|
33
|
+
* when sizing the frame-aware stdout line ceiling.
|
|
34
|
+
*
|
|
35
|
+
* A `tywrap-frame/1` line is `{"__tywrap_frame__":"chunk","frameProtocol":...,
|
|
36
|
+
* "stream":"response","id":N,"seq":N,"total":N,"totalBytes":N,
|
|
37
|
+
* "encoding":"utf8-slice","data":"<slice>"}`. The fixed keys plus the largest
|
|
38
|
+
* plausible integer fields are well under 256 bytes; 1 KiB is a comfortable
|
|
39
|
+
* upper bound that never under-allocates.
|
|
40
|
+
*/
|
|
41
|
+
const FRAME_ENVELOPE_HEADROOM = 1024;
|
|
42
|
+
/**
|
|
43
|
+
* Worst-case JSON-escaping expansion of a frame's `data` slice. The slice is a
|
|
44
|
+
* fragment of a JSON response (already-escaped, printable content), so realistic
|
|
45
|
+
* expansion is `"`->`\"` / `\`->`\\` (2x). Using 2x keeps the frame-aware line
|
|
46
|
+
* ceiling sound without over-allocating buffer headroom.
|
|
47
|
+
*/
|
|
48
|
+
const FRAME_DATA_ESCAPE_FACTOR = 2;
|
|
49
|
+
/**
|
|
50
|
+
* Default cap (UTF-8 bytes) on a single chunked response reassembled in memory.
|
|
51
|
+
* Mirrors the codec's `DEFAULT_MAX_PAYLOAD_BYTES` (10 MiB) so chunking never
|
|
52
|
+
* buffers more than the codec would ultimately accept; `NodeBridge` overrides it
|
|
53
|
+
* with the configured `codec.maxPayloadBytes`.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_MAX_REASSEMBLY_BYTES = 10 * 1024 * 1024;
|
|
30
56
|
/** Regex for ANSI escape sequences */
|
|
31
57
|
const ANSI_ESCAPE_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
32
58
|
/** Regex for control characters */
|
|
@@ -61,6 +87,26 @@ function extractMessageId(json) {
|
|
|
61
87
|
}
|
|
62
88
|
return id;
|
|
63
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Classify a stdout line as a frame envelope, a plain response, or invalid JSON.
|
|
92
|
+
*
|
|
93
|
+
* A frame envelope is any JSON object carrying a `__tywrap_frame__` key; the
|
|
94
|
+
* envelope's structural validity (protocol, seq/total ranges, etc.) is enforced
|
|
95
|
+
* by the {@link Reassembler}, not here — this only routes the line.
|
|
96
|
+
*/
|
|
97
|
+
function probeFrameLine(line) {
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(line);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { kind: 'invalid' };
|
|
104
|
+
}
|
|
105
|
+
if (parsed !== null && typeof parsed === 'object' && '__tywrap_frame__' in parsed) {
|
|
106
|
+
return { kind: 'frame', value: parsed };
|
|
107
|
+
}
|
|
108
|
+
return { kind: 'plain' };
|
|
109
|
+
}
|
|
64
110
|
// =============================================================================
|
|
65
111
|
// PROCESS IO TRANSPORT
|
|
66
112
|
// =============================================================================
|
|
@@ -96,14 +142,28 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
96
142
|
envOverrides;
|
|
97
143
|
cwd;
|
|
98
144
|
maxLineLength;
|
|
145
|
+
maxReassemblyBytes;
|
|
99
146
|
restartAfterRequests;
|
|
100
147
|
writeQueueTimeoutMs;
|
|
148
|
+
/**
|
|
149
|
+
* Per-frame UTF-8 data ceiling. The packaged Python peer receives this
|
|
150
|
+
* transport's `maxLineLength` when it is spawned, so request and response
|
|
151
|
+
* framing use the same fixed ceiling without runtime negotiation.
|
|
152
|
+
*/
|
|
153
|
+
frameBytes;
|
|
154
|
+
/**
|
|
155
|
+
* Reassembles `tywrap-frame/1` response frames into single logical response
|
|
156
|
+
* lines. Per-id discard tracks
|
|
157
|
+
* timed-out/aborted streams so late frames cannot desync stdout.
|
|
158
|
+
*/
|
|
159
|
+
responseReassembler = null;
|
|
101
160
|
// Process state
|
|
102
161
|
process = null;
|
|
103
162
|
processExited = false;
|
|
104
163
|
processError = null;
|
|
105
164
|
// Stream buffers
|
|
106
165
|
stdoutBuffer = '';
|
|
166
|
+
stdoutScanOffset = 0;
|
|
107
167
|
stderrBuffer = '';
|
|
108
168
|
// Request tracking
|
|
109
169
|
pending = new Map();
|
|
@@ -112,9 +172,24 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
112
172
|
});
|
|
113
173
|
requestCount = 0;
|
|
114
174
|
needsRestart = false;
|
|
175
|
+
/** Serializes restart decisions with dispatch reservations. */
|
|
176
|
+
dispatchMutex = Promise.resolve();
|
|
177
|
+
activeDispatches = 0;
|
|
178
|
+
dispatchDrainWaiters = [];
|
|
115
179
|
// Write queue for backpressure
|
|
116
180
|
writeQueue = [];
|
|
181
|
+
writeQueueHead = 0;
|
|
117
182
|
draining = false;
|
|
183
|
+
/**
|
|
184
|
+
* Per-logical-request write mutex (W5). When a request is chunked into
|
|
185
|
+
* `tywrap-frame/1` frames, all of that request's frames must reach stdin
|
|
186
|
+
* contiguously — no other request's frame (or single line) may interleave —
|
|
187
|
+
* or the Python reassembler would see frames from two ids mixed on one stream.
|
|
188
|
+
* `writeChunkedRequest` chains the whole frame burst onto this tail; the
|
|
189
|
+
* single-line write path also serializes behind it so a small request issued
|
|
190
|
+
* concurrently never slips between another request's frames.
|
|
191
|
+
*/
|
|
192
|
+
writeMutex = Promise.resolve();
|
|
118
193
|
/**
|
|
119
194
|
* Create a new SubprocessTransport.
|
|
120
195
|
*
|
|
@@ -129,6 +204,12 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
129
204
|
this.maxLineLength = options.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
130
205
|
this.restartAfterRequests = options.restartAfterRequests ?? 0;
|
|
131
206
|
this.writeQueueTimeoutMs = options.writeQueueTimeoutMs ?? DEFAULT_WRITE_QUEUE_TIMEOUT_MS;
|
|
207
|
+
this.maxReassemblyBytes = options.maxReassemblyBytes ?? DEFAULT_MAX_REASSEMBLY_BYTES;
|
|
208
|
+
this.frameBytes = this.maxLineLength;
|
|
209
|
+
this.responseReassembler = new Reassembler({
|
|
210
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
211
|
+
expectedStream: 'response',
|
|
212
|
+
});
|
|
132
213
|
}
|
|
133
214
|
// ===========================================================================
|
|
134
215
|
// TRANSPORT INTERFACE
|
|
@@ -141,7 +222,7 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
141
222
|
* @param signal - Optional AbortSignal for cancellation
|
|
142
223
|
* @returns The raw JSON response string
|
|
143
224
|
*/
|
|
144
|
-
async send(message, timeoutMs, signal) {
|
|
225
|
+
async send(message, timeoutMs, signal, requestId) {
|
|
145
226
|
// Check disposed state
|
|
146
227
|
if (this.isDisposed) {
|
|
147
228
|
throw new BridgeDisposedError('Transport has been disposed');
|
|
@@ -162,38 +243,52 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
162
243
|
throw new BridgeTimeoutError('Operation aborted');
|
|
163
244
|
}
|
|
164
245
|
// Extract message ID for response correlation
|
|
165
|
-
const messageId = extractMessageId(message);
|
|
246
|
+
const messageId = requestId ?? extractMessageId(message);
|
|
166
247
|
if (messageId === null) {
|
|
167
248
|
throw new BridgeProtocolError('Message must contain an "id" field');
|
|
168
249
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
250
|
+
let ownsDispatchReservation = false;
|
|
251
|
+
let reservationReleased = false;
|
|
252
|
+
const releaseOwnedReservation = () => {
|
|
253
|
+
if (ownsDispatchReservation && !reservationReleased) {
|
|
254
|
+
reservationReleased = true;
|
|
255
|
+
this.releaseDispatch();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
174
258
|
// Create promise for response
|
|
175
|
-
|
|
259
|
+
const response = new Promise((resolve, reject) => {
|
|
176
260
|
// Set up timeout if specified
|
|
177
261
|
let timer;
|
|
262
|
+
// Defined before the timer so the timeout path can also detach it.
|
|
263
|
+
const abortHandler = () => {
|
|
264
|
+
if (timer) {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
this.pending.delete(messageId);
|
|
268
|
+
this.timedOutRequests.mark(messageId);
|
|
269
|
+
// Same late-frame discard as the timeout path (see below).
|
|
270
|
+
this.responseReassembler?.discard(messageId);
|
|
271
|
+
reject(new BridgeTimeoutError('Operation aborted'));
|
|
272
|
+
};
|
|
178
273
|
if (timeoutMs > 0) {
|
|
179
274
|
timer = setTimeout(() => {
|
|
180
275
|
this.pending.delete(messageId);
|
|
181
276
|
this.timedOutRequests.mark(messageId);
|
|
277
|
+
// Discard any in-flight chunked response stream for this id so late
|
|
278
|
+
// frames are dropped rather than desyncing stdout (the single-line
|
|
279
|
+
// timedOutRequests.consume above is one-shot and insufficient for a
|
|
280
|
+
// multi-frame stream).
|
|
281
|
+
this.responseReassembler?.discard(messageId);
|
|
282
|
+
// Detach the abort listener: on timeout the abort never fires, so
|
|
283
|
+
// without this it would leak on a long-lived AbortSignal and could
|
|
284
|
+
// re-enter abortHandler after this promise has already settled.
|
|
285
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
182
286
|
const stderrTail = this.getStderrTail();
|
|
183
287
|
const baseMsg = `Operation timed out after ${timeoutMs}ms`;
|
|
184
288
|
const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg;
|
|
185
289
|
reject(new BridgeTimeoutError(msg));
|
|
186
290
|
}, timeoutMs);
|
|
187
291
|
}
|
|
188
|
-
// Set up abort handler
|
|
189
|
-
const abortHandler = () => {
|
|
190
|
-
if (timer) {
|
|
191
|
-
clearTimeout(timer);
|
|
192
|
-
}
|
|
193
|
-
this.pending.delete(messageId);
|
|
194
|
-
this.timedOutRequests.mark(messageId);
|
|
195
|
-
reject(new BridgeTimeoutError('Operation aborted'));
|
|
196
|
-
};
|
|
197
292
|
if (signal) {
|
|
198
293
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
199
294
|
}
|
|
@@ -212,14 +307,36 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
212
307
|
signal?.removeEventListener('abort', abortHandler);
|
|
213
308
|
reject(error);
|
|
214
309
|
};
|
|
215
|
-
// Register pending request
|
|
216
|
-
|
|
310
|
+
// Register pending request. Captured by reference so the write path can
|
|
311
|
+
// bind liveness to THIS exact entry (not just the id) — see writeRequest.
|
|
312
|
+
const pendingEntry = {
|
|
217
313
|
resolve: wrappedResolve,
|
|
218
314
|
reject: wrappedReject,
|
|
219
315
|
timer,
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
316
|
+
};
|
|
317
|
+
this.pending.set(messageId, pendingEntry);
|
|
318
|
+
// Write message to stdin. When the encoded request exceeds the per-frame
|
|
319
|
+
// ceiling, it is fragmented into
|
|
320
|
+
// `tywrap-frame/1` request frames written contiguously under the write
|
|
321
|
+
// mutex (W5); otherwise it goes out as a single JSONL line. Both paths
|
|
322
|
+
// serialize through the same mutex so a small request can never slip
|
|
323
|
+
// between another request's frames.
|
|
324
|
+
this.reserveDispatch(() => this.pending.get(messageId) === pendingEntry && !signal?.aborted)
|
|
325
|
+
.then(reserved => {
|
|
326
|
+
if (!reserved) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
ownsDispatchReservation = true;
|
|
330
|
+
// Timeout/abort may have settled while this reservation waited behind
|
|
331
|
+
// a generation restart. Do not write the abandoned request, and release
|
|
332
|
+
// only the reservation this send actually acquired.
|
|
333
|
+
if (this.pending.get(messageId) !== pendingEntry) {
|
|
334
|
+
releaseOwnedReservation();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
return this.writeRequest(message, messageId, signal, pendingEntry);
|
|
338
|
+
})
|
|
339
|
+
.catch(err => {
|
|
223
340
|
this.pending.delete(messageId);
|
|
224
341
|
if (timer) {
|
|
225
342
|
clearTimeout(timer);
|
|
@@ -227,23 +344,86 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
227
344
|
signal?.removeEventListener('abort', abortHandler);
|
|
228
345
|
reject(this.classifyError(err));
|
|
229
346
|
});
|
|
347
|
+
});
|
|
348
|
+
return response.finally(releaseOwnedReservation);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Atomically reserve the current process generation for one request. A
|
|
352
|
+
* threshold restart waits for every request reserved on the old generation
|
|
353
|
+
* to settle before killing it; later callers remain queued behind this lock.
|
|
354
|
+
*/
|
|
355
|
+
async withDispatchMutex(operation) {
|
|
356
|
+
let unlock;
|
|
357
|
+
const previous = this.dispatchMutex;
|
|
358
|
+
this.dispatchMutex = new Promise(resolve => {
|
|
359
|
+
unlock = resolve;
|
|
360
|
+
});
|
|
361
|
+
await previous;
|
|
362
|
+
try {
|
|
363
|
+
return await operation();
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
unlock();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async reserveDispatch(isLive) {
|
|
370
|
+
return this.withDispatchMutex(async () => {
|
|
371
|
+
// Cancellation may win while this request waits behind an earlier
|
|
372
|
+
// generation. It must consume neither a request slot nor a restart.
|
|
373
|
+
if (!isLive()) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
if (this.isLifecycleEnding()) {
|
|
377
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
378
|
+
}
|
|
379
|
+
if (this.needsRestart ||
|
|
380
|
+
(this.restartAfterRequests > 0 && this.requestCount >= this.restartAfterRequests)) {
|
|
381
|
+
if (this.activeDispatches > 0) {
|
|
382
|
+
await new Promise(resolve => this.dispatchDrainWaiters.push(resolve));
|
|
383
|
+
}
|
|
384
|
+
// The owner or transport can settle while waiting for the old
|
|
385
|
+
// generation to drain. Do not restart for work that no longer exists.
|
|
386
|
+
if (!isLive()) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
if (this.isLifecycleEnding()) {
|
|
390
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
391
|
+
}
|
|
392
|
+
await this.restartProcess();
|
|
393
|
+
}
|
|
394
|
+
this.activeDispatches++;
|
|
230
395
|
this.requestCount++;
|
|
396
|
+
return true;
|
|
231
397
|
});
|
|
232
398
|
}
|
|
399
|
+
releaseDispatch() {
|
|
400
|
+
this.activeDispatches = Math.max(0, this.activeDispatches - 1);
|
|
401
|
+
if (this.activeDispatches === 0) {
|
|
402
|
+
for (const resolve of this.dispatchDrainWaiters.splice(0)) {
|
|
403
|
+
resolve();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
isLifecycleEnding() {
|
|
408
|
+
return this.state === 'disposing' || this.isDisposed;
|
|
409
|
+
}
|
|
233
410
|
/**
|
|
234
411
|
* Static capability descriptor for the subprocess backend.
|
|
235
412
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
413
|
+
* Per the {@link Transport.capabilities} contract this is lifecycle-independent
|
|
414
|
+
* (safe before `init()` / after `dispose()`) and never makes a Python round
|
|
415
|
+
* trip. Subprocess carries Arrow IPC and arbitrary binary (bytes envelopes)
|
|
416
|
+
* over the JSONL stream. `supportsChunking` is always `true` because the npm
|
|
417
|
+
* package ships both `tywrap-frame/1` peers. `supportsStreaming` stays `false`.
|
|
418
|
+
* `maxFrameBytes` is the fixed per-frame data ceiling supplied to the Python
|
|
419
|
+
* bridge at spawn time.
|
|
240
420
|
*/
|
|
241
421
|
capabilities() {
|
|
242
422
|
return {
|
|
243
423
|
backend: 'subprocess',
|
|
244
424
|
supportsArrow: true,
|
|
245
425
|
supportsBinary: true,
|
|
246
|
-
supportsChunking:
|
|
426
|
+
supportsChunking: true,
|
|
247
427
|
supportsStreaming: false,
|
|
248
428
|
maxFrameBytes: this.maxLineLength,
|
|
249
429
|
};
|
|
@@ -252,7 +432,8 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
252
432
|
// BOUNDED CONTEXT LIFECYCLE
|
|
253
433
|
// ===========================================================================
|
|
254
434
|
/**
|
|
255
|
-
* Initialize the transport by spawning the Python process.
|
|
435
|
+
* Initialize the transport by spawning the Python process. The packaged
|
|
436
|
+
* bridge always speaks `tywrap-frame/1`.
|
|
256
437
|
*/
|
|
257
438
|
async doInit() {
|
|
258
439
|
await this.spawnProcess();
|
|
@@ -273,18 +454,24 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
273
454
|
}
|
|
274
455
|
this.pending.clear();
|
|
275
456
|
// Clear write queue
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
this.
|
|
281
|
-
// Kill process
|
|
282
|
-
await this.killProcess();
|
|
457
|
+
this.rejectAllQueuedWrites(error);
|
|
458
|
+
// Fence queued restart work before killing the process. A restart already
|
|
459
|
+
// holding the mutex observes the disposing state before it can spawn; if it
|
|
460
|
+
// spawned just before disposal began, this barrier kills that child too.
|
|
461
|
+
await this.withDispatchMutex(() => this.killProcess());
|
|
283
462
|
// Clear buffers
|
|
284
463
|
this.stdoutBuffer = '';
|
|
464
|
+
this.stdoutScanOffset = 0;
|
|
285
465
|
this.stderrBuffer = '';
|
|
286
466
|
this.timedOutRequests.clear();
|
|
287
467
|
this.requestCount = 0;
|
|
468
|
+
this.responseReassembler = new Reassembler({
|
|
469
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
470
|
+
expectedStream: 'response',
|
|
471
|
+
});
|
|
472
|
+
// Reset the per-request write mutex so a disposed transport starts from a
|
|
473
|
+
// clean (resolved) tail if reused.
|
|
474
|
+
this.writeMutex = Promise.resolve();
|
|
288
475
|
}
|
|
289
476
|
// ===========================================================================
|
|
290
477
|
// PROCESS MANAGEMENT
|
|
@@ -297,6 +484,13 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
297
484
|
// If env is provided, it should be the complete environment (already filtered by NodeBridge)
|
|
298
485
|
// We only add Python-specific variables on top
|
|
299
486
|
const baseEnv = Object.keys(this.envOverrides).length > 0 ? this.envOverrides : process.env;
|
|
487
|
+
// Advertise `tywrap-frame/1` chunked transport so the bridge fragments
|
|
488
|
+
// oversize responses. maxFrameBytes is the JSONL line ceiling: the bridge
|
|
489
|
+
// caps each frame's *data slice* at this many UTF-8 bytes, and the TS reader
|
|
490
|
+
// (see the frame-aware line ceiling) allows for the JSON envelope + escaping
|
|
491
|
+
// on top of it. Spread (rather than dynamic index assignment) keeps the
|
|
492
|
+
// computed keys off ESLint's object-injection sink. See
|
|
493
|
+
// docs/transport-framing.md.
|
|
300
494
|
const env = {
|
|
301
495
|
...baseEnv,
|
|
302
496
|
// Ensure Python uses UTF-8
|
|
@@ -306,7 +500,7 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
306
500
|
PYTHONUNBUFFERED: '1',
|
|
307
501
|
};
|
|
308
502
|
// Spawn process
|
|
309
|
-
this.process = spawn(this.pythonPath, [this.bridgeScript], {
|
|
503
|
+
this.process = spawn(this.pythonPath, [this.bridgeScript, '--tywrap-max-frame-bytes', String(this.frameBytes)], {
|
|
310
504
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
311
505
|
env,
|
|
312
506
|
cwd: this.cwd,
|
|
@@ -382,14 +576,33 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
382
576
|
* Restart the Python process.
|
|
383
577
|
*/
|
|
384
578
|
async restartProcess() {
|
|
579
|
+
if (this.isLifecycleEnding()) {
|
|
580
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
581
|
+
}
|
|
385
582
|
// Kill existing process
|
|
386
583
|
await this.killProcess();
|
|
387
584
|
// Clear buffers and restart flags
|
|
388
585
|
this.stdoutBuffer = '';
|
|
586
|
+
this.stdoutScanOffset = 0;
|
|
389
587
|
this.stderrBuffer = '';
|
|
390
588
|
this.requestCount = 0;
|
|
391
589
|
this.needsRestart = false;
|
|
392
|
-
//
|
|
590
|
+
// Drop any partial reassembly + discard tracking: the new process owns a
|
|
591
|
+
// fresh stdout stream, so stale per-id state from the dead process must not
|
|
592
|
+
// leak across the restart boundary.
|
|
593
|
+
this.responseReassembler = new Reassembler({
|
|
594
|
+
maxReassemblyBytes: this.maxReassemblyBytes,
|
|
595
|
+
expectedStream: 'response',
|
|
596
|
+
});
|
|
597
|
+
// The new process owns a fresh stdin stream; reset the write mutex so a
|
|
598
|
+
// pending frame burst against the dead process cannot serialize behind it.
|
|
599
|
+
this.writeMutex = Promise.resolve();
|
|
600
|
+
// Disposal may begin while the old child is being killed. Never publish a
|
|
601
|
+
// replacement after lifecycle teardown has started.
|
|
602
|
+
if (this.isLifecycleEnding()) {
|
|
603
|
+
throw new BridgeDisposedError('Transport has been disposed');
|
|
604
|
+
}
|
|
605
|
+
// Spawn a fresh process with the packaged always-on framing protocol.
|
|
393
606
|
await this.spawnProcess();
|
|
394
607
|
}
|
|
395
608
|
/**
|
|
@@ -403,40 +616,72 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
403
616
|
// ===========================================================================
|
|
404
617
|
// STREAM HANDLERS
|
|
405
618
|
// ===========================================================================
|
|
619
|
+
/**
|
|
620
|
+
* Effective stdout line ceiling.
|
|
621
|
+
*
|
|
622
|
+
* A single wire line may be a `tywrap-frame/1` envelope whose
|
|
623
|
+
* `data` slice is capped at `maxLineLength` UTF-8 bytes by the bridge, but the
|
|
624
|
+
* JSON envelope adds escaping (`"`/`\`) plus fixed keys; the ceiling is widened
|
|
625
|
+
* to bound that overhead so a legitimate frame line is never rejected while a
|
|
626
|
+
* runaway/garbage line still is.
|
|
627
|
+
*/
|
|
628
|
+
effectiveLineCeiling() {
|
|
629
|
+
return this.maxLineLength * FRAME_DATA_ESCAPE_FACTOR + FRAME_ENVELOPE_HEADROOM;
|
|
630
|
+
}
|
|
406
631
|
/**
|
|
407
632
|
* Handle stdout data from the Python process.
|
|
408
633
|
*/
|
|
409
634
|
handleStdoutData(chunk) {
|
|
410
635
|
this.stdoutBuffer += chunk.toString();
|
|
411
|
-
|
|
412
|
-
|
|
636
|
+
const ceiling = this.effectiveLineCeiling();
|
|
637
|
+
// Scan only bytes newly appended since the last incomplete line.
|
|
638
|
+
let newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
|
|
639
|
+
if (this.stdoutBuffer.length > ceiling && newlineIndex === -1) {
|
|
413
640
|
const snippet = this.stdoutBuffer.slice(0, 500);
|
|
414
641
|
this.stdoutBuffer = '';
|
|
415
|
-
this.
|
|
642
|
+
this.stdoutScanOffset = 0;
|
|
643
|
+
this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
|
|
416
644
|
return;
|
|
417
645
|
}
|
|
418
646
|
// Process complete lines
|
|
419
|
-
|
|
420
|
-
while ((newlineIndex = this.stdoutBuffer.indexOf('\n')) !== -1) {
|
|
647
|
+
while (newlineIndex !== -1) {
|
|
421
648
|
const line = this.stdoutBuffer.slice(0, newlineIndex);
|
|
422
649
|
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
650
|
+
this.stdoutScanOffset = 0;
|
|
423
651
|
// Skip empty lines
|
|
424
652
|
if (!line.trim()) {
|
|
653
|
+
newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
|
|
425
654
|
continue;
|
|
426
655
|
}
|
|
427
656
|
// Check line length
|
|
428
|
-
if (line.length >
|
|
657
|
+
if (line.length > ceiling) {
|
|
429
658
|
const snippet = line.slice(0, 500);
|
|
430
|
-
this.handleProtocolError(`Response line exceeded ${
|
|
659
|
+
this.handleProtocolError(`Response line exceeded ${ceiling} bytes`, snippet);
|
|
431
660
|
return;
|
|
432
661
|
}
|
|
433
662
|
this.handleResponseLine(line);
|
|
663
|
+
newlineIndex = this.stdoutBuffer.indexOf('\n', this.stdoutScanOffset);
|
|
434
664
|
}
|
|
665
|
+
this.stdoutScanOffset = this.stdoutBuffer.length;
|
|
435
666
|
}
|
|
436
667
|
/**
|
|
437
668
|
* Handle a complete response line from stdout.
|
|
669
|
+
*
|
|
670
|
+
* A line may be a `tywrap-frame/1` envelope: it is
|
|
671
|
+
* routed into the per-id {@link Reassembler}, which returns the reassembled
|
|
672
|
+
* logical response only once the stream is complete and valid. Single-line
|
|
673
|
+
* (non-frame) responses keep the original fast path unchanged.
|
|
438
674
|
*/
|
|
439
675
|
handleResponseLine(line) {
|
|
676
|
+
if (this.responseReassembler) {
|
|
677
|
+
const probe = probeFrameLine(line);
|
|
678
|
+
if (probe.kind === 'frame') {
|
|
679
|
+
this.handleResponseFrame(probe.value, line);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// probe.kind 'plain'/'invalid' falls through to the legacy single-line
|
|
683
|
+
// path below, which extracts the id and validates JSON as before.
|
|
684
|
+
}
|
|
440
685
|
// Extract ID to find pending request
|
|
441
686
|
const messageId = extractMessageId(line);
|
|
442
687
|
if (messageId === null) {
|
|
@@ -462,6 +707,70 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
462
707
|
// Resolve with raw response
|
|
463
708
|
pending.resolve(line);
|
|
464
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Route one `tywrap-frame/1` response frame into the reassembler.
|
|
712
|
+
*
|
|
713
|
+
* On completion the reassembled logical line resolves the pending request. The
|
|
714
|
+
* reassembler validates structure, ordering, byte count, and UTF-8 internally
|
|
715
|
+
* and throws on any framing violation (malformed/duplicate/byte-mismatch/
|
|
716
|
+
* unknown-protocol) — those reject the pending id and mark the subprocess for
|
|
717
|
+
* restart, since stdout can no longer be trusted to be frame-aligned. Frames
|
|
718
|
+
* for a timed-out/aborted id are silently discarded by the reassembler (it
|
|
719
|
+
* returns `null` and tracks the discard set) so late multi-frame responses
|
|
720
|
+
* cannot desync the stream.
|
|
721
|
+
*/
|
|
722
|
+
handleResponseFrame(rawFrame, line) {
|
|
723
|
+
const reassembler = this.responseReassembler;
|
|
724
|
+
if (!reassembler) {
|
|
725
|
+
// Unreachable: the always-on framing path constructs the reassembler.
|
|
726
|
+
this.handleProtocolError('Received frame with no reassembler', line);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const frameId = rawFrame.id;
|
|
730
|
+
let reassembled;
|
|
731
|
+
try {
|
|
732
|
+
reassembled = reassembler.accept(rawFrame);
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
// Framing corruption: stdout is no longer frame-aligned. Reject the
|
|
736
|
+
// correlated pending request (if any) and force a restart.
|
|
737
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
738
|
+
this.rejectFrameId(frameId, `Frame reassembly failed: ${message}`, line);
|
|
739
|
+
this.markForRestart();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (reassembled === null) {
|
|
743
|
+
// More frames needed, or this frame belonged to a discarded (timed-out)
|
|
744
|
+
// id and was dropped. Either way: nothing to resolve yet.
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Stream complete: resolve the correlated pending request with the single
|
|
748
|
+
// logical response line. extractMessageId is reused so the resolution path
|
|
749
|
+
// matches the non-chunked case exactly.
|
|
750
|
+
this.handleResponseLine(reassembled);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Reject the pending request correlated to a frame id, if one exists.
|
|
754
|
+
*
|
|
755
|
+
* Used when frame reassembly throws. If the id is unknown (e.g. it already
|
|
756
|
+
* timed out and was dropped from `pending`), the error still surfaces as a
|
|
757
|
+
* protocol error so the desync is not swallowed silently.
|
|
758
|
+
*/
|
|
759
|
+
rejectFrameId(frameId, details, line) {
|
|
760
|
+
if (typeof frameId === 'number' && Number.isInteger(frameId)) {
|
|
761
|
+
const pending = this.pending.get(frameId);
|
|
762
|
+
if (pending) {
|
|
763
|
+
this.pending.delete(frameId);
|
|
764
|
+
if (pending.timer) {
|
|
765
|
+
clearTimeout(pending.timer);
|
|
766
|
+
}
|
|
767
|
+
pending.reject(new BridgeProtocolError(this.withStderrTail(details)));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// No correlated pending request: still a protocol-level desync.
|
|
772
|
+
this.handleProtocolError(details, line);
|
|
773
|
+
}
|
|
465
774
|
/**
|
|
466
775
|
* Handle stderr data from the Python process.
|
|
467
776
|
*/
|
|
@@ -523,11 +832,7 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
523
832
|
// EPIPE means process died
|
|
524
833
|
const error = new BridgeProtocolError(this.withStderrTail(`stdin error: ${err.message}`));
|
|
525
834
|
// Reject all pending writes
|
|
526
|
-
|
|
527
|
-
this.clearQueuedWriteTimeout(queued);
|
|
528
|
-
queued.reject(error);
|
|
529
|
-
}
|
|
530
|
-
this.writeQueue.length = 0;
|
|
835
|
+
this.rejectAllQueuedWrites(error);
|
|
531
836
|
// Reject all pending requests
|
|
532
837
|
this.rejectAllPending(error);
|
|
533
838
|
// Mark for restart on next send
|
|
@@ -559,15 +864,13 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
559
864
|
* Create a queued write entry with a timeout timer.
|
|
560
865
|
* The timer fires if the drain event never comes.
|
|
561
866
|
*/
|
|
562
|
-
createQueuedWrite(data, resolve, reject) {
|
|
867
|
+
createQueuedWrite(data, resolve, reject, isLive) {
|
|
563
868
|
const queuedAt = Date.now();
|
|
564
|
-
const entry = { data, resolve, reject, queuedAt };
|
|
869
|
+
const entry = { data, resolve, reject, queuedAt, isLive, isQueued: true };
|
|
565
870
|
// Set up timeout timer that fires if drain never happens
|
|
566
871
|
entry.timeoutHandle = setTimeout(() => {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (index !== -1) {
|
|
570
|
-
this.writeQueue.splice(index, 1);
|
|
872
|
+
if (entry.isQueued) {
|
|
873
|
+
entry.isQueued = false;
|
|
571
874
|
reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${this.writeQueueTimeoutMs}ms without drain`));
|
|
572
875
|
}
|
|
573
876
|
}, this.writeQueueTimeoutMs);
|
|
@@ -589,15 +892,22 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
589
892
|
/**
|
|
590
893
|
* Write data to stdin with backpressure handling.
|
|
591
894
|
*/
|
|
592
|
-
writeToStdin(data) {
|
|
895
|
+
writeToStdin(data, isLive) {
|
|
593
896
|
return new Promise((resolve, reject) => {
|
|
594
897
|
if (!this.process?.stdin || this.processExited) {
|
|
595
898
|
reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available')));
|
|
596
899
|
return;
|
|
597
900
|
}
|
|
598
|
-
if (this.draining || this.writeQueue.length >
|
|
599
|
-
// Queue the write with timestamp and
|
|
600
|
-
|
|
901
|
+
if (this.draining || this.writeQueue.length > this.writeQueueHead) {
|
|
902
|
+
// Queue the write with timestamp, timeout timer, and liveness predicate
|
|
903
|
+
// (checked again at flush — see processQueuedWrite).
|
|
904
|
+
this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive));
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
// Skip a write whose request was abandoned (timed out/aborted) before it
|
|
908
|
+
// reached stdin — never execute an operation the caller gave up on.
|
|
909
|
+
if (isLive && !isLive()) {
|
|
910
|
+
resolve();
|
|
601
911
|
return;
|
|
602
912
|
}
|
|
603
913
|
// Try direct write (wrap in try-catch for synchronous EPIPE errors)
|
|
@@ -621,16 +931,106 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
621
931
|
}
|
|
622
932
|
});
|
|
623
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* Write one logical request to stdin, fragmenting it into `tywrap-frame/1`
|
|
936
|
+
* request frames when the encoded request exceeds
|
|
937
|
+
* the per-frame ceiling (W5 — the mirror of W4's response chunking).
|
|
938
|
+
*
|
|
939
|
+
* Both the chunked and single-line paths run under {@link writeMutex} so a
|
|
940
|
+
* logical request's bytes (one line, or a burst of frames) reach stdin
|
|
941
|
+
* contiguously: a small request issued concurrently can never interleave
|
|
942
|
+
* between another request's frames, which would desync the Python
|
|
943
|
+
* reassembler (it correlates frames by id, but the JSONL stream itself must
|
|
944
|
+
* stay frame-aligned). The mutex tail is advanced regardless of success so a
|
|
945
|
+
* failed write never wedges every subsequent request.
|
|
946
|
+
*
|
|
947
|
+
* @param message - the encoded logical JSON request (no trailing newline)
|
|
948
|
+
* @param messageId - the request's correlation id (already validated integer)
|
|
949
|
+
* @param signal - optional abort signal; an abort observed between frames
|
|
950
|
+
* stops further frames and rejects this send (the pending entry is rejected
|
|
951
|
+
* by the abort handler / the caller's `.catch`).
|
|
952
|
+
*/
|
|
953
|
+
writeRequest(message, messageId, signal, pendingEntry) {
|
|
954
|
+
// The request is "live" only while THIS send's exact pending entry is still
|
|
955
|
+
// registered (the timeout and abort handlers delete it) and the signal is not
|
|
956
|
+
// aborted. Binding to the entry IDENTITY — not just the id — closes the
|
|
957
|
+
// id-reuse hole: if the id is recycled by a later send while this write is
|
|
958
|
+
// still queued, the stale write sees a different entry and is skipped.
|
|
959
|
+
// (RpcClient ids are monotonic, but Transport.send is public and does not
|
|
960
|
+
// enforce id uniqueness.) Gating EVERY write point on this — the run closure,
|
|
961
|
+
// the direct stdin write, the backpressure-queue flush, and each chunked
|
|
962
|
+
// frame — prevents an abandoned request from executing on Python, even one
|
|
963
|
+
// whose write sat queued under backpressure past the cancellation.
|
|
964
|
+
const isLive = () => !signal?.aborted &&
|
|
965
|
+
(pendingEntry !== undefined
|
|
966
|
+
? this.pending.get(messageId) === pendingEntry
|
|
967
|
+
: this.pending.has(messageId));
|
|
968
|
+
const run = () => {
|
|
969
|
+
if (!isLive()) {
|
|
970
|
+
return Promise.resolve();
|
|
971
|
+
}
|
|
972
|
+
// Chunk when the encoded request exceeds the fixed per-frame ceiling
|
|
973
|
+
// supplied to the packaged Python peer. Otherwise use one JSONL line.
|
|
974
|
+
if (utf8ByteLength(message) > this.frameBytes) {
|
|
975
|
+
return this.writeChunkedRequest(message, messageId, signal, isLive);
|
|
976
|
+
}
|
|
977
|
+
return this.writeToStdin(`${message}\n`, isLive);
|
|
978
|
+
};
|
|
979
|
+
// Serialize the whole logical write onto the mutex tail. We chain the next
|
|
980
|
+
// tail off the settled (caught) result so one failed/aborted write does not
|
|
981
|
+
// poison the chain for later requests.
|
|
982
|
+
const result = this.writeMutex.then(run);
|
|
983
|
+
this.writeMutex = result.then(() => undefined, () => undefined);
|
|
984
|
+
return result;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Fragment a logical request into `tywrap-frame/1` request frames and write
|
|
988
|
+
* them contiguously (one JSONL line per frame). Runs while holding the write
|
|
989
|
+
* mutex (see {@link writeRequest}), so no other write interleaves.
|
|
990
|
+
*
|
|
991
|
+
* Each frame is awaited in turn so backpressure on stdin is respected. Before
|
|
992
|
+
* every frame the abort signal and process liveness are re-checked: an abort
|
|
993
|
+
* mid-burst stops the remaining frames and rejects the send LOUD (the Python
|
|
994
|
+
* reassembler drops the now-incomplete id when the next request's frames /
|
|
995
|
+
* timeout arrive, exactly as the response side handles a discarded id).
|
|
996
|
+
*/
|
|
997
|
+
async writeChunkedRequest(message, messageId, signal, isLive) {
|
|
998
|
+
const frames = encodeFrames(message, {
|
|
999
|
+
id: messageId,
|
|
1000
|
+
stream: 'request',
|
|
1001
|
+
maxFrameBytes: this.frameBytes,
|
|
1002
|
+
});
|
|
1003
|
+
for (const frame of frames) {
|
|
1004
|
+
// Stop the burst if the caller aborted, the request was abandoned (timed
|
|
1005
|
+
// out -> pending deleted, caught by isLive), or the process died between
|
|
1006
|
+
// frames. A partial request stream is dropped by the Python reassembler.
|
|
1007
|
+
if (signal?.aborted || (isLive && !isLive())) {
|
|
1008
|
+
throw new BridgeTimeoutError('Operation aborted');
|
|
1009
|
+
}
|
|
1010
|
+
if (this.processExited || !this.process) {
|
|
1011
|
+
throw new BridgeProtocolError(this.withStderrTail('Process stdin not available'));
|
|
1012
|
+
}
|
|
1013
|
+
// One frame per JSONL line; await each so stdin backpressure is honored.
|
|
1014
|
+
// isLive gates a frame that ends up queued under backpressure past a late
|
|
1015
|
+
// cancellation, so an abandoned chunked request never completes on Python.
|
|
1016
|
+
await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
624
1019
|
/**
|
|
625
1020
|
* Reject and clear every entry currently in the write queue.
|
|
626
1021
|
* Clears each entry's timeout before rejecting so no late timer fires.
|
|
627
1022
|
*/
|
|
628
1023
|
rejectAllQueuedWrites(error) {
|
|
629
|
-
for (
|
|
630
|
-
this.
|
|
631
|
-
|
|
1024
|
+
for (let index = this.writeQueueHead; index < this.writeQueue.length; index += 1) {
|
|
1025
|
+
const queued = this.writeQueue[index];
|
|
1026
|
+
if (queued?.isQueued) {
|
|
1027
|
+
queued.isQueued = false;
|
|
1028
|
+
this.clearQueuedWriteTimeout(queued);
|
|
1029
|
+
queued.reject(error);
|
|
1030
|
+
}
|
|
632
1031
|
}
|
|
633
1032
|
this.writeQueue.length = 0;
|
|
1033
|
+
this.writeQueueHead = 0;
|
|
634
1034
|
}
|
|
635
1035
|
/**
|
|
636
1036
|
* Process a single dequeued write entry: enforce the fallback queue timeout,
|
|
@@ -648,6 +1048,13 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
648
1048
|
queued.reject(new BridgeTimeoutError(`Write queue timeout: entry waited ${now - queued.queuedAt}ms (limit: ${this.writeQueueTimeoutMs}ms)`));
|
|
649
1049
|
return 'continue';
|
|
650
1050
|
}
|
|
1051
|
+
// Skip the write if the request was abandoned (timed out/aborted) while it
|
|
1052
|
+
// sat in the backpressure queue — never execute an operation the caller gave
|
|
1053
|
+
// up on. Resolve as a no-op so the mutex chain stays healthy.
|
|
1054
|
+
if (queued.isLive && !queued.isLive()) {
|
|
1055
|
+
queued.resolve();
|
|
1056
|
+
return 'continue';
|
|
1057
|
+
}
|
|
651
1058
|
try {
|
|
652
1059
|
const canWrite = stdin.write(queued.data);
|
|
653
1060
|
if (canWrite) {
|
|
@@ -675,7 +1082,7 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
675
1082
|
*/
|
|
676
1083
|
flushWriteQueue() {
|
|
677
1084
|
const now = Date.now();
|
|
678
|
-
while (this.writeQueue.length
|
|
1085
|
+
while (this.writeQueueHead < this.writeQueue.length && !this.draining) {
|
|
679
1086
|
const stdin = this.process?.stdin;
|
|
680
1087
|
if (!stdin || this.processExited) {
|
|
681
1088
|
// Process died - reject all queued writes
|
|
@@ -683,10 +1090,15 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
683
1090
|
this.markForRestart();
|
|
684
1091
|
return;
|
|
685
1092
|
}
|
|
686
|
-
const queued = this.writeQueue.
|
|
1093
|
+
const queued = this.writeQueue[this.writeQueueHead];
|
|
1094
|
+
this.writeQueueHead += 1;
|
|
687
1095
|
if (!queued) {
|
|
688
1096
|
return;
|
|
689
1097
|
}
|
|
1098
|
+
if (!queued.isQueued) {
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
queued.isQueued = false;
|
|
690
1102
|
// Clear the timeout since we're processing this entry now
|
|
691
1103
|
this.clearQueuedWriteTimeout(queued);
|
|
692
1104
|
const status = this.processQueuedWrite(queued, stdin, now);
|
|
@@ -694,6 +1106,10 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
694
1106
|
return;
|
|
695
1107
|
}
|
|
696
1108
|
}
|
|
1109
|
+
if (this.writeQueueHead === this.writeQueue.length) {
|
|
1110
|
+
this.writeQueue.length = 0;
|
|
1111
|
+
this.writeQueueHead = 0;
|
|
1112
|
+
}
|
|
697
1113
|
}
|
|
698
1114
|
// ===========================================================================
|
|
699
1115
|
// HELPERS
|
|
@@ -712,7 +1128,15 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
712
1128
|
return stderrTail ? `${message}. Stderr:\n${stderrTail}` : message;
|
|
713
1129
|
}
|
|
714
1130
|
/**
|
|
715
|
-
* Handle a protocol error by rejecting all pending requests
|
|
1131
|
+
* Handle a protocol error by rejecting all pending requests and marking the
|
|
1132
|
+
* subprocess for restart.
|
|
1133
|
+
*
|
|
1134
|
+
* Every caller represents genuine stdout-stream corruption (a too-long line, a
|
|
1135
|
+
* response with no `id`, a frame with no reassembler, or a truly unexpected id
|
|
1136
|
+
* — benign late responses from timed-out requests are already filtered upstream
|
|
1137
|
+
* via {@link timedOutRequests}). After such an error stdout can no longer be
|
|
1138
|
+
* trusted to be line/frame-aligned, so the process is marked for restart —
|
|
1139
|
+
* matching the frame-reassembly-corruption path and the framing spec.
|
|
716
1140
|
*/
|
|
717
1141
|
handleProtocolError(details, line) {
|
|
718
1142
|
const snippet = line ? (line.length > 500 ? `${line.slice(0, 500)}...` : line) : undefined;
|
|
@@ -722,6 +1146,7 @@ export class SubprocessTransport extends DisposableBase {
|
|
|
722
1146
|
: `Protocol error: ${details}\n${hint}`;
|
|
723
1147
|
const error = new BridgeProtocolError(msg);
|
|
724
1148
|
this.rejectAllPending(error);
|
|
1149
|
+
this.markForRestart();
|
|
725
1150
|
}
|
|
726
1151
|
/**
|
|
727
1152
|
* Reject all pending requests with an error.
|