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