@statewalker/webrun-rpc 0.8.0 → 0.10.1
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 +156 -10
- package/dist/duplex-over-port.d.ts +3 -1
- package/dist/duplex-over-port.d.ts.map +1 -1
- package/dist/index.js +452 -92
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/port-credit.d.ts +51 -0
- package/dist/port-credit.d.ts.map +1 -1
- package/dist/port-types.d.ts +24 -2
- package/dist/port-types.d.ts.map +1 -1
- package/dist/structured-codec.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/duplex-over-port.ts +346 -40
- package/src/multiplex-port.ts +161 -16
- package/src/port-credit.ts +86 -0
- package/src/port-types.ts +19 -3
- package/src/structured-codec.ts +5 -1
package/src/duplex-over-port.ts
CHANGED
|
@@ -12,6 +12,13 @@ import { callPort, NO_TIMEOUT } from "./call-port.js";
|
|
|
12
12
|
import { getPortCloseSignal } from "./close-signal.js";
|
|
13
13
|
import { listenPort } from "./listen-port.js";
|
|
14
14
|
import type { MessageTarget } from "./message-target.js";
|
|
15
|
+
import {
|
|
16
|
+
endPort,
|
|
17
|
+
portAgreement,
|
|
18
|
+
portBufferedAmount,
|
|
19
|
+
whenPortEnded,
|
|
20
|
+
whenPortReady,
|
|
21
|
+
} from "./port-credit.js";
|
|
15
22
|
import { returningAtOnce } from "./return-at-once.js";
|
|
16
23
|
import { throughAbort } from "./through-abort.js";
|
|
17
24
|
|
|
@@ -25,6 +32,83 @@ import { throughAbort } from "./through-abort.js";
|
|
|
25
32
|
*/
|
|
26
33
|
export const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The `type` of a pipelined chunk: a plain port message, not a `callPort`
|
|
37
|
+
* request, sent only on a port where both ends agreed `pipeline`. The window's
|
|
38
|
+
* credit is its confirmation.
|
|
39
|
+
*/
|
|
40
|
+
const CHUNK = "webrun-rpc:chunk";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a pipelined chunk counts beyond its bytes, against the window, on the
|
|
44
|
+
* receiving side. Both codecs' windows count more for the chunk's envelope
|
|
45
|
+
* (the estimate at least 64, msgpack's keys alone more than this), so an honest
|
|
46
|
+
* sender never makes the receiver count past the window; a sender of empty
|
|
47
|
+
* chunks still cannot have it hold an unbounded number of them.
|
|
48
|
+
*/
|
|
49
|
+
const CHUNK_OVERHEAD = 32;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
|
|
53
|
+
* `view` itself when it already is one, else a copy. Structured clone carries
|
|
54
|
+
* and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
|
|
55
|
+
* views of the frame, while the window and the receiver's count see only the
|
|
56
|
+
* view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
|
|
57
|
+
* Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
|
|
58
|
+
* `slice()` is another view on the same memory.
|
|
59
|
+
*/
|
|
60
|
+
function ownBytes(view: ArrayBufferView): Uint8Array {
|
|
61
|
+
if (
|
|
62
|
+
view instanceof Uint8Array &&
|
|
63
|
+
view.constructor === Uint8Array &&
|
|
64
|
+
view.byteOffset === 0 &&
|
|
65
|
+
view.byteLength === view.buffer.byteLength
|
|
66
|
+
) {
|
|
67
|
+
return view;
|
|
68
|
+
}
|
|
69
|
+
return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A producer's chunk as bytes this end may send, in either mode: any view or
|
|
74
|
+
* `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
|
|
75
|
+
* as the producer's own error, rather than reaching a peer that would refuse it.
|
|
76
|
+
*/
|
|
77
|
+
/**
|
|
78
|
+
* A received confirmed chunk's value, as its own bytes, put back in place of
|
|
79
|
+
* the original in `chunk`: `listenPort` keeps the request until the handler
|
|
80
|
+
* returns, and the original would keep its whole buffer that long. A function
|
|
81
|
+
* of its own, so that no temporary of the suspended handler holds the
|
|
82
|
+
* original either.
|
|
83
|
+
*/
|
|
84
|
+
function adoptValue(chunk: WireChunk): WireChunk["value"] {
|
|
85
|
+
const original: unknown = chunk.value;
|
|
86
|
+
if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
|
|
87
|
+
return chunk.value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function chunkBytes(value: unknown): Uint8Array {
|
|
91
|
+
if (ArrayBuffer.isView(value)) return ownBytes(value);
|
|
92
|
+
if (
|
|
93
|
+
value instanceof ArrayBuffer ||
|
|
94
|
+
Object.prototype.toString.call(value) === "[object ArrayBuffer]"
|
|
95
|
+
) {
|
|
96
|
+
return new Uint8Array(value as ArrayBuffer);
|
|
97
|
+
}
|
|
98
|
+
throw new TypeError(
|
|
99
|
+
`webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got ${value === null ? "null" : typeof value}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MIXED_MESSAGE =
|
|
104
|
+
"webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
|
|
105
|
+
|
|
106
|
+
/** This end may pipeline its own chunks: agreed, and the window holds them. */
|
|
107
|
+
const mayPipeline = (port: MessageTarget): boolean => {
|
|
108
|
+
const agreement = portAgreement(port);
|
|
109
|
+
return !!agreement?.enforced && agreement.caps.has("pipeline");
|
|
110
|
+
};
|
|
111
|
+
|
|
28
112
|
/** Caller's input travels on this channel; the handler listens on it. */
|
|
29
113
|
const CHANNEL_IN = "in";
|
|
30
114
|
/** The handler's output travels on this channel; the caller listens on it. */
|
|
@@ -64,7 +148,9 @@ export interface DuplexOverPortOptions {
|
|
|
64
148
|
* same way on the other channel. Within each direction the next chunk is never
|
|
65
149
|
* sent until the previous one has been delivered *and* pulled past by the
|
|
66
150
|
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
67
|
-
* `listenPort` withholds it until then.
|
|
151
|
+
* `listenPort` withholds it until then. On a port whose window both ends
|
|
152
|
+
* speak and that agreed `pipeline`, chunks go as plain messages instead, and
|
|
153
|
+
* the window's credit is the confirmation: up to a window is in flight.
|
|
68
154
|
*
|
|
69
155
|
* A stream port carries exactly one invocation. To make several calls, open
|
|
70
156
|
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
@@ -86,7 +172,7 @@ export function serveDuplexOverPort(
|
|
|
86
172
|
const notice = installAbortNotice(port, controller);
|
|
87
173
|
abortOnPortClose(port, controller);
|
|
88
174
|
const clock = installStreamTimeout(controller, options.timeout);
|
|
89
|
-
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
175
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
|
|
90
176
|
let output: AsyncGenerator<Uint8Array>;
|
|
91
177
|
try {
|
|
92
178
|
output = handler(inbound.stream);
|
|
@@ -100,19 +186,60 @@ export function serveDuplexOverPort(
|
|
|
100
186
|
options,
|
|
101
187
|
controller.signal,
|
|
102
188
|
clock.touch,
|
|
189
|
+
() => {},
|
|
103
190
|
);
|
|
104
191
|
void pump.catch(() => {});
|
|
105
192
|
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
106
193
|
return teardownOnce(controller, notice, clock, inbound, undefined);
|
|
107
194
|
}
|
|
108
|
-
|
|
195
|
+
let onSent = () => {};
|
|
196
|
+
const sent = new Promise<void>((resolve) => {
|
|
197
|
+
onSent = resolve;
|
|
198
|
+
});
|
|
199
|
+
const pump = sendChunks(
|
|
200
|
+
port,
|
|
201
|
+
CHANNEL_OUT,
|
|
202
|
+
output,
|
|
203
|
+
options,
|
|
204
|
+
controller.signal,
|
|
205
|
+
clock.touch,
|
|
206
|
+
onSent,
|
|
207
|
+
);
|
|
109
208
|
void pump.catch(() => {
|
|
110
209
|
// Reported to the peer inside sendChunks; nothing to surface locally.
|
|
111
210
|
});
|
|
112
211
|
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
212
|
+
void endServedStream(port, sent, inbound.over);
|
|
113
213
|
return teardownOnce(controller, notice, clock, inbound, output);
|
|
114
214
|
}
|
|
115
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The serving side's normal finish. Once the handler's last chunk has gone
|
|
218
|
+
* without an error, and no more input is coming (`over`: the caller's last
|
|
219
|
+
* chunk arrived without an error and was read, or the caller ended its
|
|
220
|
+
* direction behind it, for a handler that did not read all its input), this
|
|
221
|
+
* side ends its direction too, where `end` was agreed. Without `end` nothing
|
|
222
|
+
* happens here: the caller closes the port. A stream that never gets that far
|
|
223
|
+
* was abandoned, and the caller's notice and `close` free the port; this then
|
|
224
|
+
* never resumes. It needs no race against the port's close signal: nothing
|
|
225
|
+
* awaits it, and what keeps it waiting is the input's watcher, a listener of
|
|
226
|
+
* the port that a close drops, after which the wait is collected.
|
|
227
|
+
*
|
|
228
|
+
* Nothing this side still posts is lost behind its `end`. The caller ends only
|
|
229
|
+
* once its own last chunk was confirmed, so no confirmation is owed to it
|
|
230
|
+
* here, and what the freed port's close signal aborts has settled: the last
|
|
231
|
+
* chunk has left, and input nobody reads is dropped, as on a close.
|
|
232
|
+
*/
|
|
233
|
+
async function endServedStream(
|
|
234
|
+
port: MessageTarget,
|
|
235
|
+
sent: Promise<void>,
|
|
236
|
+
over: Promise<void>,
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
await sent;
|
|
239
|
+
await over;
|
|
240
|
+
endPort(port);
|
|
241
|
+
}
|
|
242
|
+
|
|
116
243
|
/**
|
|
117
244
|
* A stream that completes normally has nobody left to call the returned
|
|
118
245
|
* teardown — the caller only knows the stream ended, not that it must also
|
|
@@ -171,17 +298,25 @@ function runCallerSide(
|
|
|
171
298
|
const notice = installAbortNotice(port, controller);
|
|
172
299
|
abortOnPortClose(port, controller);
|
|
173
300
|
const clock = installStreamTimeout(controller, options.timeout);
|
|
174
|
-
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
175
|
-
|
|
301
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
|
|
302
|
+
let sent = false;
|
|
303
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
|
|
304
|
+
sent = true;
|
|
305
|
+
});
|
|
176
306
|
void pump.catch(() => {
|
|
177
307
|
// The outbound half's failure surfaces to the peer, not to this consumer:
|
|
178
308
|
// the consumer's contract is the inbound half.
|
|
179
309
|
});
|
|
180
310
|
|
|
181
311
|
const stream = (async function* () {
|
|
312
|
+
let received = false;
|
|
182
313
|
try {
|
|
183
314
|
yield* inbound.stream;
|
|
315
|
+
received = true;
|
|
184
316
|
} finally {
|
|
317
|
+
// Both directions ended normally: the handler's end arrived without an
|
|
318
|
+
// error, and this side's left without one. Anything else is abandoned.
|
|
319
|
+
const ended = received && sent;
|
|
185
320
|
// Abort BEFORE awaiting the pump. `callPort` runs with NO_TIMEOUT here,
|
|
186
321
|
// so an un-aborted in-flight chunk call would never settle and this
|
|
187
322
|
// `finally` would deadlock — which is exactly the defect found in the
|
|
@@ -189,15 +324,26 @@ function runCallerSide(
|
|
|
189
324
|
if (!controller.signal.aborted) {
|
|
190
325
|
controller.abort(new Error("webrun-rpc: the caller abandoned the stream"));
|
|
191
326
|
}
|
|
192
|
-
|
|
327
|
+
// A normal finish ends this direction where `end` was agreed, rather
|
|
328
|
+
// than closing: the port is then freed on both ends once the handler's
|
|
329
|
+
// side ends too, and the peer never mistakes it for an abandonment.
|
|
330
|
+
if (!ended) notice.post();
|
|
193
331
|
notice.stop();
|
|
194
332
|
clock.stop();
|
|
195
333
|
inbound.stop();
|
|
196
334
|
await pump.catch(() => {});
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
335
|
+
// By now `listenPort` has confirmed the handler's last chunk: this
|
|
336
|
+
// generator ends only after that chunk's delivery settled, and the
|
|
337
|
+
// confirmation leaves in the promise turns right after it. It must, as
|
|
338
|
+
// anything posted after the `end` is dropped.
|
|
339
|
+
if (!(ended && endPort(port))) {
|
|
340
|
+
// No `end`: the notice (if not posted above), then `close`.
|
|
341
|
+
notice.post();
|
|
342
|
+
try {
|
|
343
|
+
await port.close?.();
|
|
344
|
+
} catch {
|
|
345
|
+
/* the port may already be gone; nothing to unwind */
|
|
346
|
+
}
|
|
201
347
|
}
|
|
202
348
|
}
|
|
203
349
|
})();
|
|
@@ -325,7 +471,13 @@ function receiveChunks(
|
|
|
325
471
|
channelName: string,
|
|
326
472
|
controller: AbortController,
|
|
327
473
|
touch: () => void,
|
|
328
|
-
|
|
474
|
+
notice: { post(): void },
|
|
475
|
+
): {
|
|
476
|
+
stream: AsyncGenerator<Uint8Array>;
|
|
477
|
+
stop(): void;
|
|
478
|
+
ended: Promise<void>;
|
|
479
|
+
over: Promise<void>;
|
|
480
|
+
} {
|
|
329
481
|
let deliver: ChunkReceiver<Uint8Array> | undefined;
|
|
330
482
|
const waiting: Array<() => void> = [];
|
|
331
483
|
let finished = false;
|
|
@@ -339,6 +491,13 @@ function receiveChunks(
|
|
|
339
491
|
const ended = new Promise<void>((resolve) => {
|
|
340
492
|
resolveEnded = resolve;
|
|
341
493
|
});
|
|
494
|
+
// Resolves once this direction finished normally: the peer's last chunk,
|
|
495
|
+
// without an error, was handed to the consumer, or arrived and the peer
|
|
496
|
+
// then ended its direction (see the watcher below).
|
|
497
|
+
let resolveOver: () => void = () => {};
|
|
498
|
+
const over = new Promise<void>((resolve) => {
|
|
499
|
+
resolveOver = resolve;
|
|
500
|
+
});
|
|
342
501
|
// Set by `onAbort` the instant the signal fires, independent of whether a
|
|
343
502
|
// consumer has started iterating yet. `recieveIterator`'s installer below
|
|
344
503
|
// runs lazily, on the consumer's first `.next()` — if abort fires first,
|
|
@@ -356,44 +515,104 @@ function receiveChunks(
|
|
|
356
515
|
for (const r of waiting.splice(0)) r();
|
|
357
516
|
};
|
|
358
517
|
|
|
518
|
+
// A protocol violation fails the stream and closes its port.
|
|
519
|
+
const violate = (message: string): Error => {
|
|
520
|
+
poison = new Error(message);
|
|
521
|
+
// Route through the existing abort machinery instead of duplicating
|
|
522
|
+
// it by hand. `onAbort` below (a) sets `aborted`/`abortReason`, so
|
|
523
|
+
// `recieveIterator`'s installer replays the error even if poison
|
|
524
|
+
// fires before the consumer's first `.next()` — otherwise `deliver`
|
|
525
|
+
// is still undefined here and the local stream hangs forever; and
|
|
526
|
+
// (b) since `controller.signal` is the very signal `sendChunks`
|
|
527
|
+
// passes into `callPort` for *both* directions of this stream,
|
|
528
|
+
// aborting it rejects any outbound call parked at `NO_TIMEOUT` —
|
|
529
|
+
// otherwise `port.close()` below makes that call unsettleable, the
|
|
530
|
+
// pump never returns, the handler's `finally` never runs, and
|
|
531
|
+
// `disarmClockWhenBothSidesSettle` never fires. `onAbort` also
|
|
532
|
+
// resolves `ended`, making a separate call here redundant.
|
|
533
|
+
controller.abort(poison);
|
|
534
|
+
// Close on the next macrotask, so listenPort still gets to post the
|
|
535
|
+
// refusal on this one — a virtual port goes inert the instant it
|
|
536
|
+
// closes, and a silent drop would leave the offender hanging rather
|
|
537
|
+
// than telling it what it did wrong.
|
|
538
|
+
setTimeout(() => {
|
|
539
|
+
try {
|
|
540
|
+
void port.close?.();
|
|
541
|
+
} catch {
|
|
542
|
+
/* already gone */
|
|
543
|
+
}
|
|
544
|
+
}, 0);
|
|
545
|
+
return poison;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// Where `end` is agreed, the peer's `end` is the other way this direction
|
|
549
|
+
// can be over. This watcher sees the peer's last chunk even once the
|
|
550
|
+
// consumer stopped reading (and the listeners below are gone): an `end`
|
|
551
|
+
// behind a last chunk without an error finishes the input, and one without
|
|
552
|
+
// it is a violation, or the consumer would wait forever for a chunk that
|
|
553
|
+
// cannot come. It stays on the port until that `end`, and it refers to
|
|
554
|
+
// `port` on purpose: the mux holds a port's listeners, so a stream that
|
|
555
|
+
// only waits for the peer's `end` keeps its port, which nothing else of
|
|
556
|
+
// its own may still hold. A close drops it with the other listeners.
|
|
557
|
+
let lastChunkArrived = false;
|
|
558
|
+
let watching = false;
|
|
559
|
+
const watchPeerEnd = (): boolean => {
|
|
560
|
+
if (watching) return true;
|
|
561
|
+
if (!portAgreement(port)?.caps.has("end")) return false;
|
|
562
|
+
watching = true;
|
|
563
|
+
void whenPortEnded(port).then(() => {
|
|
564
|
+
port.removeEventListener("message", watcher);
|
|
565
|
+
if (lastChunkArrived) {
|
|
566
|
+
resolveOver();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
notice.post();
|
|
570
|
+
violate(
|
|
571
|
+
"webrun-rpc: the peer ended its direction without finishing it; the stream port is closed",
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
return true;
|
|
575
|
+
};
|
|
576
|
+
const watcher = (event: MessageEvent): void => {
|
|
577
|
+
const data = event.data as
|
|
578
|
+
| { type?: unknown; channelName?: unknown; chunk?: WireChunk; params?: WireChunk }
|
|
579
|
+
| undefined;
|
|
580
|
+
if (data?.channelName !== channelName) return;
|
|
581
|
+
const chunk =
|
|
582
|
+
data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : undefined;
|
|
583
|
+
if (!chunk) return;
|
|
584
|
+
// The opener learns the agreement from the ack, which comes before any
|
|
585
|
+
// chunk: decided at the first one, and without `end` nothing is watched.
|
|
586
|
+
if (!watchPeerEnd()) {
|
|
587
|
+
port.removeEventListener("message", watcher);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (chunk.done && !chunk.error) lastChunkArrived = true;
|
|
591
|
+
};
|
|
592
|
+
port.addEventListener("message", watcher);
|
|
593
|
+
watchPeerEnd();
|
|
594
|
+
|
|
595
|
+
// Bytes of pipelined chunks delivered and not yet consumed (see `onChunk`).
|
|
596
|
+
let held = 0;
|
|
359
597
|
const off = listenPort<WireChunk, void>(
|
|
360
598
|
port,
|
|
361
|
-
async (
|
|
599
|
+
async (params) => {
|
|
600
|
+
const { done, error } = params;
|
|
601
|
+
const value = adoptValue(params);
|
|
362
602
|
touch();
|
|
363
603
|
if (poison) throw poison;
|
|
604
|
+
// A confirmed chunk is held outside the pipelined count: one beside
|
|
605
|
+
// pipelined ones would let a peer hold more than the window plus one
|
|
606
|
+
// chunk. An honest sender switches to pipelining once and stays.
|
|
607
|
+
if (held > 0) throw violate(MIXED_MESSAGE);
|
|
364
608
|
if (outstanding) {
|
|
365
609
|
// Spec D15: a second chunk before the first was confirmed is a
|
|
366
610
|
// protocol violation, not a resource question. A count of one needs
|
|
367
611
|
// no threshold and no byte accounting, and it bounds memory by
|
|
368
612
|
// construction: maxPorts x one chunk.
|
|
369
|
-
|
|
613
|
+
throw violate(
|
|
370
614
|
"webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed",
|
|
371
615
|
);
|
|
372
|
-
// Route through the existing abort machinery instead of duplicating
|
|
373
|
-
// it by hand. `onAbort` below (a) sets `aborted`/`abortReason`, so
|
|
374
|
-
// `recieveIterator`'s installer replays the error even if poison
|
|
375
|
-
// fires before the consumer's first `.next()` — otherwise `deliver`
|
|
376
|
-
// is still undefined here and the local stream hangs forever; and
|
|
377
|
-
// (b) since `controller.signal` is the very signal `sendChunks`
|
|
378
|
-
// passes into `callPort` for *both* directions of this stream,
|
|
379
|
-
// aborting it rejects any outbound call parked at `NO_TIMEOUT` —
|
|
380
|
-
// otherwise `port.close()` below makes that call unsettleable, the
|
|
381
|
-
// pump never returns, the handler's `finally` never runs, and
|
|
382
|
-
// `disarmClockWhenBothSidesSettle` never fires. `onAbort` also
|
|
383
|
-
// resolves `ended`, making a separate call here redundant.
|
|
384
|
-
controller.abort(poison);
|
|
385
|
-
// Close on the next macrotask, so listenPort still gets to post the
|
|
386
|
-
// refusal on this one — a virtual port goes inert the instant it
|
|
387
|
-
// closes, and a silent drop would leave the offender hanging rather
|
|
388
|
-
// than telling it what it did wrong.
|
|
389
|
-
setTimeout(() => {
|
|
390
|
-
try {
|
|
391
|
-
void port.close?.();
|
|
392
|
-
} catch {
|
|
393
|
-
/* already gone */
|
|
394
|
-
}
|
|
395
|
-
}, 0);
|
|
396
|
-
throw poison;
|
|
397
616
|
}
|
|
398
617
|
outstanding = true;
|
|
399
618
|
try {
|
|
@@ -404,6 +623,7 @@ function receiveChunks(
|
|
|
404
623
|
value,
|
|
405
624
|
error: error ? deserializeError(error) : undefined,
|
|
406
625
|
});
|
|
626
|
+
if (done && !error) resolveOver();
|
|
407
627
|
} finally {
|
|
408
628
|
outstanding = false;
|
|
409
629
|
}
|
|
@@ -412,6 +632,59 @@ function receiveChunks(
|
|
|
412
632
|
{ channelName },
|
|
413
633
|
);
|
|
414
634
|
|
|
635
|
+
// Pipelined chunks: plain messages, held until the consumer pulls past
|
|
636
|
+
// them. The listener's promise settles only then, so the mux returns the
|
|
637
|
+
// window's credit for a chunk once it is consumed, not once it arrived —
|
|
638
|
+
// that is what bounds a stalled reader to one window. A peer that ignores
|
|
639
|
+
// the credit is caught by counting what is held: an honest one never makes
|
|
640
|
+
// it pass the window, so past the window plus this chunk it is refused.
|
|
641
|
+
const onChunk = (event: MessageEvent): Promise<void> | undefined => {
|
|
642
|
+
const data = event.data as
|
|
643
|
+
| { type?: unknown; channelName?: unknown; chunk?: WireChunk }
|
|
644
|
+
| undefined;
|
|
645
|
+
if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
|
|
646
|
+
if (poison) return;
|
|
647
|
+
// Agreed is enough here, unlike for sending: the peer pipelines only once
|
|
648
|
+
// the window holds its own sends, and agreeing told it that it may.
|
|
649
|
+
const agreement = portAgreement(port);
|
|
650
|
+
if (!agreement?.caps.has("pipeline")) {
|
|
651
|
+
violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (outstanding) {
|
|
655
|
+
violate(MIXED_MESSAGE);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// What is held is counted by its bytes, so it must be bytes.
|
|
659
|
+
if (data.chunk.value !== undefined && !ArrayBuffer.isView(data.chunk.value)) {
|
|
660
|
+
violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (held > agreement.window) {
|
|
664
|
+
violate(
|
|
665
|
+
`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`,
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
touch();
|
|
670
|
+
const { done, error } = data.chunk;
|
|
671
|
+
const value = data.chunk.value && ownBytes(data.chunk.value);
|
|
672
|
+
const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
|
|
673
|
+
held += size;
|
|
674
|
+
return (async () => {
|
|
675
|
+
try {
|
|
676
|
+
await ready();
|
|
677
|
+
if (finished) return;
|
|
678
|
+
await deliver?.({ done, value, error: error ? deserializeError(error) : undefined });
|
|
679
|
+
if (done && !error) resolveOver();
|
|
680
|
+
if (done) resolveEnded();
|
|
681
|
+
} finally {
|
|
682
|
+
held -= size;
|
|
683
|
+
}
|
|
684
|
+
})();
|
|
685
|
+
};
|
|
686
|
+
port.addEventListener("message", onChunk);
|
|
687
|
+
|
|
415
688
|
// Every path that stops listening to this channel — the consumer walking
|
|
416
689
|
// away early (`break`/`return`/`throw` on the `for await`), an external
|
|
417
690
|
// `stop()`, or the abort branch below — means no more chunks are coming
|
|
@@ -429,6 +702,7 @@ function receiveChunks(
|
|
|
429
702
|
finished = true;
|
|
430
703
|
wake();
|
|
431
704
|
off();
|
|
705
|
+
port.removeEventListener("message", onChunk);
|
|
432
706
|
controller.signal.removeEventListener("abort", onAbort);
|
|
433
707
|
resolveEnded();
|
|
434
708
|
};
|
|
@@ -464,12 +738,16 @@ function receiveChunks(
|
|
|
464
738
|
stream,
|
|
465
739
|
stop: detach,
|
|
466
740
|
ended,
|
|
741
|
+
over,
|
|
467
742
|
};
|
|
468
743
|
}
|
|
469
744
|
|
|
470
745
|
/**
|
|
471
746
|
* The sending half of one direction: one `callPort` per chunk, one call
|
|
472
|
-
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8)
|
|
747
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
|
|
748
|
+
* or, once pipelining is agreed, plain chunk messages held back by the window.
|
|
749
|
+
* `onSent` runs once the end of the output has left without an error: posted
|
|
750
|
+
* when pipelined, confirmed when not.
|
|
473
751
|
*/
|
|
474
752
|
async function sendChunks(
|
|
475
753
|
port: MessageTarget,
|
|
@@ -478,6 +756,7 @@ async function sendChunks(
|
|
|
478
756
|
{ maxMessageSize, log }: DuplexOverPortOptions,
|
|
479
757
|
signal: AbortSignal,
|
|
480
758
|
touch: () => void,
|
|
759
|
+
onSent: () => void,
|
|
481
760
|
): Promise<void> {
|
|
482
761
|
const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
|
|
483
762
|
const stream = throughAbort(framed, signal);
|
|
@@ -492,16 +771,26 @@ async function sendChunks(
|
|
|
492
771
|
if (signal.aborted) return;
|
|
493
772
|
const chunk: WireChunk = {
|
|
494
773
|
done,
|
|
495
|
-
value,
|
|
774
|
+
value: done ? value : chunkBytes(value),
|
|
496
775
|
error: error === undefined ? undefined : serializeError(error),
|
|
497
776
|
};
|
|
498
777
|
log?.("[duplexOverPort] send", { channelName, done, size: value?.byteLength });
|
|
778
|
+
// Decided per chunk: acknowledged calls until `pipeline` is agreed (the
|
|
779
|
+
// opener learns it from the ack), plain messages from then on.
|
|
780
|
+
if (mayPipeline(port)) {
|
|
781
|
+
port.postMessage({ type: CHUNK, channelName, chunk });
|
|
782
|
+
touch();
|
|
783
|
+
if (done && !error) onSent();
|
|
784
|
+
if (portBufferedAmount(port) > 0) await whenReadyOrAborted(port, signal);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
499
787
|
await callPort<void, WireChunk>(port, chunk, {
|
|
500
788
|
channelName,
|
|
501
789
|
timeout: NO_TIMEOUT,
|
|
502
790
|
signal,
|
|
503
791
|
});
|
|
504
792
|
touch();
|
|
793
|
+
if (done && !error) onSent();
|
|
505
794
|
}, stream);
|
|
506
795
|
} catch (err) {
|
|
507
796
|
// An abort is the expected way this ends when the local side walks away;
|
|
@@ -512,3 +801,20 @@ async function sendChunks(
|
|
|
512
801
|
signal.removeEventListener("abort", returnProducer);
|
|
513
802
|
}
|
|
514
803
|
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* A pipelined sender waits only for room in the window, or for the abort. The
|
|
807
|
+
* abort listener lives for this one wait: a promise held for the whole stream
|
|
808
|
+
* would keep one reaction per wait until the stream ends.
|
|
809
|
+
*/
|
|
810
|
+
function whenReadyOrAborted(port: MessageTarget, signal: AbortSignal): Promise<void> {
|
|
811
|
+
if (signal.aborted) return Promise.resolve();
|
|
812
|
+
return new Promise<void>((resolve) => {
|
|
813
|
+
const settle = () => {
|
|
814
|
+
signal.removeEventListener("abort", settle);
|
|
815
|
+
resolve();
|
|
816
|
+
};
|
|
817
|
+
signal.addEventListener("abort", settle, { once: true });
|
|
818
|
+
whenPortReady(port).then(settle, settle);
|
|
819
|
+
});
|
|
820
|
+
}
|