@statewalker/webrun-rpc 0.5.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,13 @@
1
+ import { getPortCloseSignal } from "./close-signal.js";
1
2
  import type { MessageTarget } from "./message-target.js";
2
- import type { PortEnvelope, PortMux, PortMuxOptions } from "./port-types.js";
3
+ import { nextTask, setPortSendState } from "./port-credit.js";
4
+ import type {
5
+ OpenPortOptions,
6
+ PortEnvelope,
7
+ PortInfo,
8
+ PortMux,
9
+ PortMuxOptions,
10
+ } from "./port-types.js";
3
11
  import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
4
12
 
5
13
  /**
@@ -8,30 +16,199 @@ import { newVirtualPort, type VirtualPortHandle } from "./virtual-port.js";
8
16
  */
9
17
  export const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
10
18
 
19
+ /**
20
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
21
+ * whose codec passes unknown envelopes through reads them as a close of that
22
+ * id, which must therefore not be a real port (0 is the initiator's first).
23
+ */
24
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
25
+
26
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
27
+ const MAX_TIMER_MS = 2 ** 31 - 1;
28
+
11
29
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
12
30
  export const DEFAULT_MAX_PORTS = 1024;
13
31
 
32
+ /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
33
+ export const DEFAULT_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
34
+
35
+ /**
36
+ * What a message costs a port's window when the codec cannot say
37
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
38
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
39
+ * from the same structured value, so they agree.
40
+ */
41
+ export function estimateMessageSize(payload: unknown): number {
42
+ let budget = 256;
43
+ const walk = (v: unknown, depth: number): number => {
44
+ if (budget-- <= 0) return 0;
45
+ if (typeof v === "string") return v.length;
46
+ if (v instanceof ArrayBuffer) return v.byteLength;
47
+ if (ArrayBuffer.isView(v)) return v.byteLength;
48
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
49
+ let n = 16;
50
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
51
+ else for (const k in v) n += k.length + walk((v as Record<string, unknown>)[k], depth + 1);
52
+ return n;
53
+ };
54
+ return Math.max(64, walk(payload, 0));
55
+ }
56
+
14
57
  /**
15
58
  * The default `PortMux`: emulates multiplexing over a single port.
16
59
  *
17
60
  * A transport that already multiplexes natively supplies its own `PortMux`
18
61
  * instead — this implementation is for transports that offer one pipe.
19
62
  */
20
- export function multiplexPort(port: MessageTarget, options: PortMuxOptions): PortMux {
63
+ export function multiplexPort(
64
+ port: MessageTarget,
65
+ options: PortMuxOptions,
66
+ ): PortMux & { ports(): PortInfo[] } {
21
67
  const {
22
68
  codec,
23
69
  onPort,
24
70
  side = "initiator",
25
71
  maxPorts = DEFAULT_MAX_PORTS,
26
72
  maxMessageSize,
73
+ signal,
74
+ window,
75
+ frameSize,
76
+ keepalive,
77
+ onLeak,
27
78
  } = options;
79
+ const maxReassembledBytes = options.maxReassembledBytes ?? DEFAULT_MAX_REASSEMBLED_BYTES;
80
+ const misses = keepalive?.misses ?? 3;
81
+ if (
82
+ keepalive &&
83
+ !(
84
+ Number.isFinite(keepalive.intervalMs) &&
85
+ keepalive.intervalMs > 0 &&
86
+ keepalive.intervalMs <= MAX_TIMER_MS &&
87
+ Number.isSafeInteger(misses) &&
88
+ misses > 0
89
+ )
90
+ ) {
91
+ throw new RangeError(
92
+ `webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`,
93
+ );
94
+ }
95
+ // A frame of no bytes would never advance the split: refuse it here.
96
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) {
97
+ throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
98
+ }
99
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) {
100
+ throw new RangeError(
101
+ `webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`,
102
+ );
103
+ }
28
104
 
29
- const open = new Map<number, VirtualPortHandle>();
105
+ /**
106
+ * Flow control of one port (`PortMuxOptions.window`): the send side's queue
107
+ * and the bytes delivered but not yet consumed, and the receive side's
108
+ * consumed bytes not yet granted back.
109
+ */
110
+ interface Credit {
111
+ window: number;
112
+ /**
113
+ * The peer applies the window. Until it says so (the responder's
114
+ * zero-byte `credit`), bytes are counted but nothing waits: an older peer
115
+ * never sends credit, and its ports must run as they did before.
116
+ */
117
+ enforced: boolean;
118
+ /** This side opened the port; the other answers its window with an ack. */
119
+ opener: boolean;
120
+ /**
121
+ * Both ends count encoded bytes (`PortCodec.encode` / `decode`), and so
122
+ * may split a large message into frames (`frameSize`). If either cannot,
123
+ * both count the shared estimate and send messages whole: two ends
124
+ * counting different sizes would leak window on every message until the
125
+ * port wedged.
126
+ */
127
+ exact: boolean;
128
+ outstanding: number;
129
+ queue: { send: (whole?: boolean) => void; size: number }[];
130
+ queued: number;
131
+ unconsumed: number;
132
+ waiters: { below: number; resolve: () => void }[];
133
+ toGrant: number;
134
+ grantScheduled: boolean;
135
+ }
136
+ /**
137
+ * What the mux keeps of a port: never the consumer's `port` object itself,
138
+ * so a port its consumer dropped can be collected and reported (`onLeak`).
139
+ */
140
+ type PortRecord = Pick<
141
+ VirtualPortHandle,
142
+ "deliver" | "markClosed" | "isClosed" | "hasListeners"
143
+ > & {
144
+ /** Close the port as its consumer's `close()` would, without its object. */
145
+ close(reason: unknown): void;
146
+ };
147
+ interface Entry extends PortInfo {
148
+ handle: PortRecord;
149
+ credit?: Credit;
150
+ /**
151
+ * The frames of a message still arriving. `room`: what the window left
152
+ * when the first came, if anything was still owed back then. `charged`:
153
+ * its bytes past the window, counted in `reassembling`.
154
+ */
155
+ partial?: {
156
+ chunks: Uint8Array[];
157
+ bytes: number;
158
+ room?: number;
159
+ owed: number;
160
+ charged: number;
161
+ };
162
+ }
163
+ const open = new Map<number, Entry>();
164
+ // Bytes of partial messages held past their port's window, across every
165
+ // port (`maxReassembledBytes`). What fits a window is bounded by it already.
166
+ let reassembling = 0;
167
+ const freePartial = (entry: Entry | undefined): void => {
168
+ if (!entry?.partial) return;
169
+ reassembling -= entry.partial.charged;
170
+ entry.partial = undefined;
171
+ };
172
+ // A port collected while still open: report it, then free its slot on both
173
+ // ends as its close would. Only with `onLeak` — a registry costs per port.
174
+ const leaks = onLeak
175
+ ? new FinalizationRegistry<{
176
+ id: number;
177
+ record: PortRecord;
178
+ release: (reason: unknown) => void;
179
+ }>(({ id, record, release }) => {
180
+ const entry = open.get(id);
181
+ if (entry?.handle !== record) return;
182
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
183
+ // A port whose consumer still listens is still receiving (a
184
+ // subscription that keeps only its listener): report it, but leave it
185
+ // open. One with no listener can neither send nor receive any more.
186
+ if (!record.hasListeners()) {
187
+ release("leaked");
188
+ record.markClosed(new Error("webrun-rpc: the port was dropped without close()"));
189
+ }
190
+ try {
191
+ onLeak?.(info);
192
+ } catch {
193
+ // A reporter's fault must not escape into the finalizer.
194
+ }
195
+ })
196
+ : undefined;
197
+ const touch = (id: number): void => {
198
+ const entry = open.get(id);
199
+ if (entry) entry.lastActivity = Date.now();
200
+ };
30
201
  let nextId = side === "initiator" ? 0 : 1;
31
202
  let muxClosed = false;
203
+ /** This end counts encoded bytes and reassembles frames. */
204
+ const exactCodec = codec.encode != null && codec.decode != null;
32
205
 
33
206
  const post = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
34
207
  if (muxClosed) return;
208
+ codecPost(envelope, transfer);
209
+ };
210
+ /** Like `post`, also while closing: the mux's own close notices. */
211
+ const codecPost = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
35
212
  try {
36
213
  codec.post(port, envelope, transfer);
37
214
  } catch {
@@ -40,22 +217,263 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
40
217
  }
41
218
  };
42
219
 
43
- const attach = (id: number): VirtualPortHandle => {
44
- let self!: VirtualPortHandle;
45
- self = newVirtualPort(
46
- (payload, transfer) => post({ type: "message", id, payload }, transfer),
220
+ // FAIR FRAMING (`frameSize`): what each port has to put on the pipe, taken
221
+ // one item per port in turn whenever the pipe has room (`whenDrained`). A
222
+ // large message is split into frames first, so a small one on another port
223
+ // waits for one frame, not for the whole message. Without `frameSize`
224
+ // everything is posted at once, as before.
225
+ const outgoing = new Map<number, Array<() => void>>();
226
+ const ring: number[] = [];
227
+ let pumping = false;
228
+ const pump = async (): Promise<void> => {
229
+ pumping = true;
230
+ try {
231
+ while (ring.length && !muxClosed) {
232
+ // A pipe without the method has no way to say it is full; the loop
233
+ // then runs to the end synchronously.
234
+ if (port.whenDrained) await port.whenDrained();
235
+ if (muxClosed) break;
236
+ // biome-ignore lint/style/noNonNullAssertion: the loop condition checks the ring is not empty.
237
+ const id = ring.shift()!;
238
+ const queue = outgoing.get(id);
239
+ const item = queue?.shift();
240
+ if (queue?.length) ring.push(id);
241
+ else outgoing.delete(id);
242
+ item?.();
243
+ }
244
+ } catch (err) {
245
+ // The pipe failed while this mux waited for room: nothing queued can
246
+ // go out now, and the ports must hear so rather than wait forever.
247
+ close(err).catch(() => {});
248
+ } finally {
249
+ pumping = false;
250
+ }
251
+ };
252
+ const sendOnPort = (id: number, item: () => void): void => {
253
+ if (frameSize == null) {
254
+ item();
255
+ return;
256
+ }
257
+ let queue = outgoing.get(id);
258
+ if (!queue) {
259
+ queue = [];
260
+ outgoing.set(id, queue);
261
+ ring.push(id);
262
+ }
263
+ queue.push(item);
264
+ if (!pumping) void pump();
265
+ };
266
+ const dropOutgoing = (id: number): void => {
267
+ if (!outgoing.delete(id)) return;
268
+ const at = ring.indexOf(id);
269
+ if (at >= 0) ring.splice(at, 1);
270
+ };
271
+ const postBytes = (bytes: Uint8Array): void => {
272
+ if (muxClosed) return;
273
+ try {
274
+ port.postMessage(bytes);
275
+ } catch {
276
+ // The underlying port is gone, as in `post`.
277
+ }
278
+ };
279
+
280
+ const releaseWaiters = (c: Credit | undefined): void => {
281
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
282
+ };
283
+
284
+ const flush = (c: Credit): void => {
285
+ while (c.queue.length) {
286
+ // biome-ignore lint/style/noNonNullAssertion: the loop condition checks the queue is not empty.
287
+ const next = c.queue[0]!;
288
+ // A message larger than the window goes out once nothing else is outstanding.
289
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
290
+ c.queue.shift();
291
+ c.queued -= next.size;
292
+ c.outstanding += next.size;
293
+ next.send();
294
+ }
295
+ for (const w of [...c.waiters]) {
296
+ if (c.queued <= w.below) {
297
+ c.waiters.splice(c.waiters.indexOf(w), 1);
298
+ w.resolve();
299
+ }
300
+ }
301
+ };
302
+
303
+ // Credit goes back in batches: at once from a quarter of the window, else on
304
+ // the next task (so a burst of small messages costs one credit envelope, and
305
+ // a quiet port still gets its last bytes back).
306
+ const grant = (id: number, c: Credit, bytes: number): void => {
307
+ c.unconsumed -= bytes;
308
+ c.toGrant += bytes;
309
+ const send = () => {
310
+ c.grantScheduled = false;
311
+ if (!c.toGrant || !open.has(id)) return;
312
+ post({ type: "credit", id, bytes: c.toGrant });
313
+ c.toGrant = 0;
314
+ };
315
+ if (c.toGrant >= c.window / 4) {
316
+ send();
317
+ return;
318
+ }
319
+ if (c.grantScheduled) return;
320
+ c.grantScheduled = true;
321
+ setTimeout(send, 0);
322
+ };
323
+
324
+ const attach = (
325
+ id: number,
326
+ meta: unknown,
327
+ portWindow: unknown,
328
+ opener: boolean,
329
+ exact: boolean,
330
+ ): VirtualPortHandle => {
331
+ // A peer's window must be a positive integer; anything else means none.
332
+ const c: Credit | undefined =
333
+ typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0
334
+ ? {
335
+ window: portWindow,
336
+ // Nobody holds back until the other end has shown it reads credit:
337
+ // the responder's ack proves that to the opener, and the opener's
338
+ // answer to the ack proves it to the responder.
339
+ enforced: false,
340
+ opener,
341
+ exact,
342
+ outstanding: 0,
343
+ queue: [],
344
+ queued: 0,
345
+ unconsumed: 0,
346
+ waiters: [],
347
+ toGrant: 0,
348
+ grantScheduled: false,
349
+ }
350
+ : undefined;
351
+ const self = newVirtualPort(
352
+ (payload, transfer) => {
353
+ touch(id);
354
+ const envelope: PortEnvelope = { type: "message", id, payload };
355
+ // Without a window nothing frames or waits: post at once, so the
356
+ // payload is read now, while the caller still expects it to be.
357
+ if (!c) {
358
+ post(envelope, transfer);
359
+ return;
360
+ }
361
+ // Encoded once, here, when the codec can: the exact wire size is then
362
+ // what the window counts, on both ends, and the bytes are what frames split.
363
+ const bytes = c.exact ? codec.encode?.(envelope) : undefined;
364
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
365
+ // A message that will wait (for credit, or for the pump when framing)
366
+ // is copied now, as `postMessage` would copy it at once: the caller may
367
+ // reuse its buffer as soon as it returns. Encoded bytes are a copy already.
368
+ const waits =
369
+ frameSize != null ||
370
+ c.queue.length > 0 ||
371
+ (c.enforced && c.outstanding > 0 && c.outstanding + size > c.window);
372
+ const held =
373
+ !bytes && waits
374
+ ? { ...envelope, payload: structuredClone(payload, { transfer }) }
375
+ : undefined;
376
+ // `whole`: at close, what is still queued leaves past the window, and
377
+ // a frame past the window is what the peer refuses (`maxReassembledBytes`).
378
+ const send = (whole = false): void => {
379
+ if (!bytes) {
380
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
381
+ return;
382
+ }
383
+ // Frames only once the peer has acknowledged exact sizing: before
384
+ // that, it may be a peer that cannot reassemble them.
385
+ if (
386
+ !whole &&
387
+ frameSize != null &&
388
+ c.enforced &&
389
+ c.exact &&
390
+ bytes.byteLength > frameSize
391
+ ) {
392
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
393
+ const data = bytes.subarray(at, at + frameSize);
394
+ const frame: PortEnvelope =
395
+ at + frameSize >= bytes.byteLength
396
+ ? { type: "frame", id, data, last: true }
397
+ : { type: "frame", id, data };
398
+ sendOnPort(id, () => post(frame));
399
+ }
400
+ return;
401
+ }
402
+ sendOnPort(id, () => postBytes(bytes));
403
+ };
404
+ c.queue.push({ send, size });
405
+ c.queued += size;
406
+ flush(c);
407
+ },
47
408
  (reason) => {
48
- post({ type: "close", id, reason });
49
- open.delete(id);
50
- self.markClosed();
409
+ release(reason);
410
+ record.markClosed(reason);
51
411
  },
52
412
  );
53
- open.set(id, self);
413
+ function release(reason: unknown): void {
414
+ freePartial(open.get(id));
415
+ open.delete(id);
416
+ leaks?.unregister(record);
417
+ // What is queued goes out now, before the close and regardless of the
418
+ // window: a peer that stopped returning credit (a listener that never
419
+ // settles) must still learn that the port is closed, and nothing is
420
+ // kept for a port nobody holds. Without a window it would all have
421
+ // gone out at once anyway.
422
+ if (c?.queue.length) {
423
+ for (const item of c.queue.splice(0)) item.send(true);
424
+ c.queued = 0;
425
+ }
426
+ // Behind this port's frames, so the peer gets the whole message first.
427
+ sendOnPort(id, () => post({ type: "close", id, reason }));
428
+ releaseWaiters(c);
429
+ }
430
+ // No closure below may refer to `self`: closures in one scope share it, and
431
+ // the ones the mux keeps would then hold the consumer's port (`onLeak`).
432
+ const { markClosed, isClosed, hasListeners } = self;
433
+ if (c) {
434
+ const deliver = self.deliver;
435
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
436
+ c.unconsumed += size;
437
+ const done = deliver(payload);
438
+ if (done) void done.then(() => grant(id, c, size));
439
+ else grant(id, c, size);
440
+ return done;
441
+ };
442
+ setPortSendState(self.port, {
443
+ bufferedAmount: () => c.queued,
444
+ ready: (below = 0) =>
445
+ new Promise<void>((resolve) => {
446
+ if (c.queued <= below || isClosed()) nextTask(resolve);
447
+ else c.waiters.push({ below, resolve: () => nextTask(resolve) });
448
+ }),
449
+ });
450
+ }
451
+ const record: PortRecord = {
452
+ deliver: self.deliver,
453
+ markClosed,
454
+ isClosed,
455
+ hasListeners,
456
+ close: (reason) => {
457
+ if (isClosed()) return;
458
+ release(reason);
459
+ markClosed(reason);
460
+ },
461
+ };
462
+ leaks?.register(self.port, { id, record, release }, record);
463
+ const now = Date.now();
464
+ open.set(id, { id, meta, openedAt: now, lastActivity: now, handle: record, credit: c });
54
465
  return self;
55
466
  };
56
467
 
57
- const handleEnvelope = (envelope: PortEnvelope): void => {
468
+ const handleEnvelope = (envelope: PortEnvelope, event: MessageEvent): void => {
58
469
  if (muxClosed) return;
470
+ // Anything from the peer shows it is alive.
471
+ heardSincePing = true;
472
+ if (envelope.type === "ping" || envelope.type === "pong") {
473
+ peerKeepsAlive = true;
474
+ if (envelope.type === "ping") post({ type: "pong", id: KEEPALIVE_ID });
475
+ return;
476
+ }
59
477
 
60
478
  if (envelope.type === "open") {
61
479
  // A duplicate id is a peer bug; ignoring it is safer than replacing a
@@ -65,7 +483,25 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
65
483
  post({ type: "close", id: envelope.id, reason: "max-ports" });
66
484
  return;
67
485
  }
68
- const handle = attach(envelope.id);
486
+ // The opener asked for a window and is known to speak it.
487
+ const handle = attach(
488
+ envelope.id,
489
+ envelope.meta,
490
+ envelope.window,
491
+ false,
492
+ envelope.exact === true && exactCodec,
493
+ );
494
+ // Acknowledge the window before `onPort` runs: what `onPort` posts then
495
+ // reaches the opener after the ack, so both ends count it the same way
496
+ // (exact bytes or the estimate). A rejection's close follows the ack.
497
+ const credit = open.get(envelope.id)?.credit;
498
+ if (credit) {
499
+ post(
500
+ credit.exact
501
+ ? { type: "credit", id: envelope.id, bytes: 0, exact: true }
502
+ : { type: "credit", id: envelope.id, bytes: 0 },
503
+ );
504
+ }
69
505
  let accepted = false;
70
506
  if (onPort) {
71
507
  try {
@@ -78,41 +514,205 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
78
514
  open.delete(envelope.id);
79
515
  handle.markClosed(new Error("webrun-rpc: the port was rejected"));
80
516
  post({ type: "close", id: envelope.id, reason: "rejected" });
517
+ return;
81
518
  }
519
+
82
520
  return;
83
521
  }
84
522
 
85
- const handle = open.get(envelope.id);
523
+ const entry = open.get(envelope.id);
524
+
525
+ if (envelope.type === "credit") {
526
+ const c = entry?.credit;
527
+ if (!c || !(envelope.bytes >= 0)) return;
528
+ if (!c.enforced && c.opener) {
529
+ // The ack. It also settles the sizing: if the responder cannot count
530
+ // exact bytes, both ends fall back to the estimate, and what was
531
+ // counted exactly before the ack no longer matches what comes back.
532
+ if (c.exact && envelope.exact !== true) {
533
+ c.exact = false;
534
+ c.outstanding = 0;
535
+ }
536
+ // Show the responder its codec reads credit, so it may hold back too.
537
+ post({ type: "credit", id: envelope.id, bytes: 0 });
538
+ }
539
+ c.enforced = true;
540
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
541
+ flush(c);
542
+ return;
543
+ }
86
544
 
87
545
  if (envelope.type === "message") {
88
546
  // Drop, never queue. An id that was rejected, never opened, or already
89
547
  // closed has no consumer, and holding its traffic would be exactly the
90
548
  // buffering layer 1 refuses to do.
91
- if (!handle) return;
92
- handle.deliver(envelope.payload);
549
+ const sizeOf = (c: Credit) =>
550
+ (c.exact ? byteLengthOf(event.data) : undefined) ?? estimateMessageSize(envelope.payload);
551
+ if (!entry) return;
552
+ touch(envelope.id);
553
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : undefined);
93
554
  return;
94
555
  }
95
556
 
96
- if (!handle) return;
557
+ if (envelope.type === "frame") {
558
+ // Only where this end advertised exact sizing — the one place a peer may
559
+ // frame. What a partial message may hold is bounded below, so frames
560
+ // that never end cannot make this side buffer without limit.
561
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
562
+ touch(envelope.id);
563
+ const c = entry.credit;
564
+ // What this side still owes back (delivered and unconsumed, or consumed
565
+ // and not yet granted) is never more than what the sender counted as
566
+ // outstanding. So an honest sender starts a message only when owed plus
567
+ // the message fits the window, or when nothing is owed at all: a message
568
+ // larger than the window, bounded by `maxReassembledBytes` alone.
569
+ const owed = c.unconsumed + c.toGrant;
570
+ const partial = entry.partial ?? {
571
+ chunks: [],
572
+ bytes: 0,
573
+ owed,
574
+ charged: 0,
575
+ ...(owed > 0 ? { room: c.window - owed } : {}),
576
+ };
577
+ entry.partial = partial;
578
+ const size = envelope.data.byteLength;
579
+ // Only a message begun with nothing owed may pass the window; what it
580
+ // holds past it is what the cap, shared by all ports, bounds.
581
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
582
+ const overrun =
583
+ partial.room != null && partial.bytes + size > partial.room
584
+ ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)`
585
+ : reassembling - partial.charged + charged > maxReassembledBytes
586
+ ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)`
587
+ : undefined;
588
+ if (overrun) {
589
+ // A peer that frames past what it may send: hold none of it.
590
+ freePartial(entry);
591
+ dropOutgoing(envelope.id);
592
+ open.delete(envelope.id);
593
+ releaseWaiters(c);
594
+ entry.handle.markClosed(new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
595
+ post({ type: "close", id: envelope.id, reason: "max-reassembled-bytes" });
596
+ return;
597
+ }
598
+ partial.bytes += size;
599
+ reassembling += charged - partial.charged;
600
+ partial.charged = charged;
601
+ partial.chunks.push(envelope.data);
602
+ if (!envelope.last) return;
603
+ freePartial(entry);
604
+ const whole = new Uint8Array(partial.bytes);
605
+ let at = 0;
606
+ for (const chunk of partial.chunks) {
607
+ whole.set(chunk, at);
608
+ at += chunk.byteLength;
609
+ }
610
+ const inner = codec.decode(whole);
611
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
612
+ entry.handle.deliver(inner.payload, whole.byteLength);
613
+ return;
614
+ }
615
+
616
+ // The peer closed the port: whatever it still had to receive is moot.
617
+ dropOutgoing(envelope.id);
618
+ if (!entry) return;
619
+ freePartial(entry);
97
620
  open.delete(envelope.id);
98
- handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
621
+ releaseWaiters(entry.credit);
622
+ entry.handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
99
623
  };
100
624
 
101
625
  const listener = (event: MessageEvent): void => {
102
626
  const envelope = codec.read(event);
103
- if (envelope) handleEnvelope(envelope);
627
+ if (envelope) handleEnvelope(envelope, event);
628
+ };
629
+
630
+ // KEEPALIVE: a MessagePort reports neither death nor a frozen peer, so the
631
+ // mux probes. It counts pings that went unanswered — nothing at all from the
632
+ // peer between one ping and the next tick — rather than time since the last
633
+ // message, so timer jitter cannot turn a prompt answer into a miss. It holds
634
+ // a peer to it only once that peer has answered or pinged: an older one,
635
+ // which never does, is never closed for it.
636
+ let peerKeepsAlive = false;
637
+ let heardSincePing = true;
638
+ let unanswered = 0;
639
+ let pinger: ReturnType<typeof setInterval> | undefined;
640
+ const tick = (): void => {
641
+ if (!keepalive) return;
642
+ // A ping still in this end's own transport buffer has not reached the
643
+ // peer: a backlog of ours is not the peer's silence.
644
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
645
+ if (heardSincePing) unanswered = 0;
646
+ else if (peerKeepsAlive && !backlogged) unanswered++;
647
+ if (unanswered >= misses) {
648
+ close(
649
+ new Error(
650
+ `webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`,
651
+ ),
652
+ ).catch(() => {});
653
+ return;
654
+ }
655
+ heardSincePing = false;
656
+ post({ type: "ping", id: KEEPALIVE_ID });
657
+ };
658
+
659
+ const close = async (reason?: unknown): Promise<void> => {
660
+ if (muxClosed) return;
661
+ clearInterval(pinger);
662
+ // Closed BEFORE the ports: `markClosed` runs their close-signal listeners
663
+ // synchronously, and one that reopens (reconnect logic) must be refused
664
+ // rather than add a port after the snapshot below, which nothing would
665
+ // ever close.
666
+ muxClosed = true;
667
+ signal?.removeEventListener("abort", onTransportAbort);
668
+ port.removeEventListener("message", listener);
669
+ const closedNow = new Set<number>();
670
+ for (const [id, { handle, credit }] of [...open]) {
671
+ // A port that a close-signal listener closed meanwhile is still sent its close here: its own
672
+ // close() posts nothing once the mux is closing.
673
+ closedNow.add(id);
674
+ codecPost({ type: "close", id });
675
+ open.delete(id);
676
+ releaseWaiters(credit);
677
+ handle.markClosed(reason ?? new Error(MUX_CLOSED_MESSAGE));
678
+ }
679
+ // Ports closed here whose close still waits behind their frames: it goes
680
+ // now, or the peer would hold them open forever.
681
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({ type: "close", id });
682
+ outgoing.clear();
683
+ ring.length = 0;
684
+ await port.close?.();
685
+ };
686
+ // Nobody awaits the transport's close on this path, and a dead transport's
687
+ // `close()` often rejects (a stream already reset): never let that become
688
+ // an unhandled rejection.
689
+ const onTransportAbort = (): void => {
690
+ close(signal?.reason).catch(() => {});
104
691
  };
105
692
 
106
693
  port.addEventListener("message", listener);
107
694
  void port.start?.();
695
+ // Started only once the pipe is wired, so a constructor that throws above
696
+ // leaves no timer behind.
697
+ if (keepalive) {
698
+ pinger = setInterval(tick, keepalive.intervalMs);
699
+ // A probe must not keep a Node process alive on its own.
700
+ (pinger as { unref?: () => void }).unref?.();
701
+ }
702
+ if (signal?.aborted) close(signal.reason).catch(() => {});
703
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
108
704
 
109
705
  return {
110
706
  maxMessageSize,
111
707
 
112
- async openPort(meta?: unknown): Promise<MessageTarget> {
708
+ async openPort(meta?: unknown, { signal: portSignal }: OpenPortOptions = {}) {
113
709
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
710
+ // As `fetch` does: a signal already aborted opens nothing.
711
+ if (portSignal?.aborted) throw portSignal.reason;
114
712
  if (open.size >= maxPorts) {
115
- throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
713
+ throw new RangeError(
714
+ `webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`,
715
+ );
116
716
  }
117
717
  // A hostile or misconfigured peer may have opened using our own
118
718
  // parity. Skipping a claimed id costs one line and keeps a local open
@@ -120,21 +720,70 @@ export function multiplexPort(port: MessageTarget, options: PortMuxOptions): Por
120
720
  while (open.has(nextId)) nextId += 2;
121
721
  const id = nextId;
122
722
  nextId += 2;
123
- const handle = attach(id);
124
- post({ type: "open", id, meta });
723
+ const handle = attach(id, meta, window, true, exactCodec);
724
+ post(
725
+ window
726
+ ? exactCodec
727
+ ? { type: "open", id, meta, window, exact: true }
728
+ : { type: "open", id, meta, window }
729
+ : { type: "open", id, meta },
730
+ );
731
+ if (portSignal) {
732
+ // Through the mux's record, not the port: a long-lived signal must not
733
+ // keep a dropped port reachable, and must still free it once dropped.
734
+ const record = open.get(id)?.handle;
735
+ const onAbort = () => {
736
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
737
+ };
738
+ portSignal.addEventListener("abort", onAbort, { once: true });
739
+ getPortCloseSignal(handle.port)?.addEventListener(
740
+ "abort",
741
+ () => portSignal.removeEventListener("abort", onAbort),
742
+ { once: true },
743
+ );
744
+ }
125
745
  return handle.port;
126
746
  },
127
747
 
128
- async close(): Promise<void> {
129
- if (muxClosed) return;
130
- for (const [id, handle] of [...open]) {
131
- post({ type: "close", id });
132
- open.delete(id);
133
- handle.markClosed(new Error(MUX_CLOSED_MESSAGE));
134
- }
135
- muxClosed = true;
136
- port.removeEventListener("message", listener);
137
- await port.close?.();
748
+ close,
749
+
750
+ ports(): PortInfo[] {
751
+ return [...open.values()].map(({ handle: _, credit, ...info }) =>
752
+ credit
753
+ ? {
754
+ ...info,
755
+ ...(credit.enforced ? { window: credit.window } : {}),
756
+ queued: credit.queued,
757
+ outstanding: credit.outstanding,
758
+ unconsumed: credit.unconsumed,
759
+ }
760
+ : info,
761
+ );
138
762
  },
139
763
  };
140
764
  }
765
+
766
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
767
+ function byteLengthOf(data: unknown): number | undefined {
768
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
769
+ return undefined;
770
+ }
771
+
772
+ /**
773
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
774
+ * a full table names who filled it. A port without a kind counts as `"?"`.
775
+ */
776
+ function describeKinds(entries: Iterable<PortInfo>): string {
777
+ const counts = new Map<string, number>();
778
+ for (const { meta } of entries) {
779
+ const kind = (meta as { kind?: unknown } | undefined)?.kind;
780
+ const name = typeof kind === "string" ? kind : "?";
781
+ counts.set(name, (counts.get(name) ?? 0) + 1);
782
+ }
783
+ if (!counts.size) return "none";
784
+ return [...counts]
785
+ .sort((x, y) => y[1] - x[1])
786
+ .slice(0, 3)
787
+ .map(([name, n]) => `${name}: ${n}`)
788
+ .join(", ");
789
+ }