@statewalker/webrun-rpc 0.8.0 → 0.10.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,8 +1,15 @@
1
1
  import { getPortCloseSignal } from "./close-signal.js";
2
2
  import type { MessageTarget } from "./message-target.js";
3
- import { nextTask, setPortSendState } from "./port-credit.js";
3
+ import {
4
+ nextTask,
5
+ type PortCap,
6
+ setPortAgreement,
7
+ setPortEndState,
8
+ setPortSendState,
9
+ } from "./port-credit.js";
4
10
  import type {
5
11
  OpenPortOptions,
12
+ PortCodec,
6
13
  PortEnvelope,
7
14
  PortInfo,
8
15
  PortMux,
@@ -26,6 +33,23 @@ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
26
33
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
27
34
  const MAX_TIMER_MS = 2 ** 31 - 1;
28
35
 
36
+ /**
37
+ * What a windowed port offers in its `open`: every capability this end
38
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
39
+ */
40
+ function ownCaps(codec: PortCodec): readonly PortCap[] {
41
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
42
+ }
43
+
44
+ /**
45
+ * The capabilities in a peer's `caps` that this end speaks too, or
46
+ * `undefined` when the peer listed none (it is then treated as an older peer).
47
+ */
48
+ function sharedCaps(own: readonly PortCap[], listed: unknown): Set<PortCap> | undefined {
49
+ if (!Array.isArray(listed)) return undefined;
50
+ return new Set(own.filter((cap) => listed.includes(cap)));
51
+ }
52
+
29
53
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
30
54
  export const DEFAULT_MAX_PORTS = 1024;
31
55
 
@@ -125,6 +149,8 @@ export function multiplexPort(
125
149
  * port wedged.
126
150
  */
127
151
  exact: boolean;
152
+ /** What both ends speak on this port; empty until the handshake says otherwise. */
153
+ caps: Set<PortCap>;
128
154
  outstanding: number;
129
155
  queue: { send: (whole?: boolean) => void; size: number }[];
130
156
  queued: number;
@@ -132,6 +158,18 @@ export function multiplexPort(
132
158
  waiters: { below: number; resolve: () => void }[];
133
159
  toGrant: number;
134
160
  grantScheduled: boolean;
161
+ /** This side's `end` is queued: anything posted afterwards is dropped. */
162
+ ending: boolean;
163
+ /** This side's `end` has left, behind the port's frames. */
164
+ endSent: boolean;
165
+ /** The peer's `end` has arrived. */
166
+ peerEnded: boolean;
167
+ /**
168
+ * Resolves `whenPortEnded`, once someone waits for it. Held weakly: the
169
+ * waiter's continuations would otherwise keep a dropped port reachable
170
+ * from the mux, and `onLeak` would never see it.
171
+ */
172
+ onPeerEnd?: WeakRef<() => void>;
135
173
  }
136
174
  /**
137
175
  * What the mux keeps of a port: never the consumer's `port` object itself,
@@ -202,6 +240,8 @@ export function multiplexPort(
202
240
  let muxClosed = false;
203
241
  /** This end counts encoded bytes and reassembles frames. */
204
242
  const exactCodec = codec.encode != null && codec.decode != null;
243
+ /** What this end offers on a windowed port. */
244
+ const caps = ownCaps(codec);
205
245
 
206
246
  const post = (envelope: PortEnvelope, transfer?: Transferable[]): void => {
207
247
  if (muxClosed) return;
@@ -277,6 +317,29 @@ export function multiplexPort(
277
317
  }
278
318
  };
279
319
 
320
+ /**
321
+ * Both directions have ended: free the id as a close would, with no `close`
322
+ * on the wire (each end frees it on its own once it has seen both ends).
323
+ * Aborting the close signal is how the port's consumer learns it is gone.
324
+ */
325
+ const freeIfEnded = (id: number, c: Credit): void => {
326
+ const entry = open.get(id);
327
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
328
+ freePartial(entry);
329
+ open.delete(id);
330
+ leaks?.unregister(entry.handle);
331
+ entry.handle.markClosed(new Error("webrun-rpc: the port ended in both directions"));
332
+ };
333
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
334
+ const failPort = (id: number, entry: Entry, error: Error, reason: string): void => {
335
+ freePartial(entry);
336
+ dropOutgoing(id);
337
+ open.delete(id);
338
+ releaseWaiters(entry.credit);
339
+ entry.handle.markClosed(error);
340
+ post({ type: "close", id, reason });
341
+ };
342
+
280
343
  const releaseWaiters = (c: Credit | undefined): void => {
281
344
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
282
345
  };
@@ -285,8 +348,10 @@ export function multiplexPort(
285
348
  while (c.queue.length) {
286
349
  // biome-ignore lint/style/noNonNullAssertion: the loop condition checks the queue is not empty.
287
350
  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;
351
+ // A message larger than the window goes out once nothing else is
352
+ // outstanding. An `end` (size 0) costs no window and never waits for credit.
353
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window)
354
+ break;
290
355
  c.queue.shift();
291
356
  c.queued -= next.size;
292
357
  c.outstanding += next.size;
@@ -339,6 +404,7 @@ export function multiplexPort(
339
404
  enforced: false,
340
405
  opener,
341
406
  exact,
407
+ caps: new Set(),
342
408
  outstanding: 0,
343
409
  queue: [],
344
410
  queued: 0,
@@ -346,10 +412,15 @@ export function multiplexPort(
346
412
  waiters: [],
347
413
  toGrant: 0,
348
414
  grantScheduled: false,
415
+ ending: false,
416
+ endSent: false,
417
+ peerEnded: false,
349
418
  }
350
419
  : undefined;
351
420
  const self = newVirtualPort(
352
421
  (payload, transfer) => {
422
+ // This side's direction has ended: nothing more goes out on it.
423
+ if (c?.ending) return;
353
424
  touch(id);
354
425
  const envelope: PortEnvelope = { type: "message", id, payload };
355
426
  // Without a window nothing frames or waits: post at once, so the
@@ -447,6 +518,40 @@ export function multiplexPort(
447
518
  else c.waiters.push({ below, resolve: () => nextTask(resolve) });
448
519
  }),
449
520
  });
521
+ setPortEndState(self.port, {
522
+ end: () => {
523
+ if (isClosed() || !c.caps.has("end")) return false;
524
+ if (c.ending) return true;
525
+ c.ending = true;
526
+ // Behind what this side already posted: first in the window queue
527
+ // (a size-0 item), then in the fair-framing ring behind its frames.
528
+ c.queue.push({
529
+ size: 0,
530
+ send: () =>
531
+ sendOnPort(id, () => {
532
+ // A port closed meanwhile was abandoned, not ended: its peer
533
+ // hears the close alone, never an end that looks like a normal finish.
534
+ if (open.get(id)?.credit !== c) return;
535
+ post({ type: "end", id });
536
+ c.endSent = true;
537
+ freeIfEnded(id, c);
538
+ }),
539
+ });
540
+ flush(c);
541
+ return true;
542
+ },
543
+ watchPeerEnd: (resolve) => {
544
+ if (!c.caps.has("end")) return false;
545
+ if (c.peerEnded) resolve();
546
+ else c.onPeerEnd = new WeakRef(resolve);
547
+ return true;
548
+ },
549
+ });
550
+ setPortAgreement(self.port, () => ({
551
+ window: c.window,
552
+ caps: c.caps,
553
+ enforced: c.enforced,
554
+ }));
450
555
  }
451
556
  const record: PortRecord = {
452
557
  deliver: self.deliver,
@@ -494,13 +599,18 @@ export function multiplexPort(
494
599
  // Acknowledge the window before `onPort` runs: what `onPort` posts then
495
600
  // reaches the opener after the ack, so both ends count it the same way
496
601
  // (exact bytes or the estimate). A rejection's close follows the ack.
602
+ // Only an opener that listed capabilities hears them answered; they
603
+ // hold from here for what it sends. This end's own sends still wait for
604
+ // the opener's answer to the ack before the window holds them: that
605
+ // answer is what shows this end's codec reads credit at all.
497
606
  const credit = open.get(envelope.id)?.credit;
607
+ const listed = sharedCaps(caps, envelope.caps);
498
608
  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
- );
609
+ const ack: PortEnvelope = credit.exact
610
+ ? { type: "credit", id: envelope.id, bytes: 0, exact: true }
611
+ : { type: "credit", id: envelope.id, bytes: 0 };
612
+ if (listed) credit.caps = listed;
613
+ post(listed ? { ...ack, caps: [...listed] } : ack);
504
614
  }
505
615
  let accepted = false;
506
616
  if (onPort) {
@@ -533,6 +643,7 @@ export function multiplexPort(
533
643
  c.exact = false;
534
644
  c.outstanding = 0;
535
645
  }
646
+ c.caps = sharedCaps(caps, envelope.caps) ?? new Set();
536
647
  // Show the responder its codec reads credit, so it may hold back too.
537
648
  post({ type: "credit", id: envelope.id, bytes: 0 });
538
649
  }
@@ -542,6 +653,19 @@ export function multiplexPort(
542
653
  return;
543
654
  }
544
655
 
656
+ // Nothing follows a peer's `end` on its direction but credit: an honest
657
+ // sender drops what is posted after it. Delivering more would hand the
658
+ // consumer data after it was told the input is over.
659
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
660
+ failPort(
661
+ envelope.id,
662
+ entry,
663
+ new Error("webrun-rpc: the peer sent on a port after its end"),
664
+ "protocol",
665
+ );
666
+ return;
667
+ }
668
+
545
669
  if (envelope.type === "message") {
546
670
  // Drop, never queue. An id that was rejected, never opened, or already
547
671
  // closed has no consumer, and holding its traffic would be exactly the
@@ -587,12 +711,12 @@ export function multiplexPort(
587
711
  : undefined;
588
712
  if (overrun) {
589
713
  // 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" });
714
+ failPort(
715
+ envelope.id,
716
+ entry,
717
+ new RangeError(`webrun-rpc: a framed message passed ${overrun}`),
718
+ "max-reassembled-bytes",
719
+ );
596
720
  return;
597
721
  }
598
722
  partial.bytes += size;
@@ -613,6 +737,27 @@ export function multiplexPort(
613
737
  return;
614
738
  }
615
739
 
740
+ if (envelope.type === "end") {
741
+ if (!entry) return;
742
+ const c = entry.credit;
743
+ // Only where `end` was agreed. Anywhere else the peer broke the protocol,
744
+ // as with a pipelined chunk where pipelining was not agreed.
745
+ if (!c?.caps.has("end")) {
746
+ failPort(
747
+ envelope.id,
748
+ entry,
749
+ new Error("webrun-rpc: the peer ended a port where end was not agreed"),
750
+ "protocol",
751
+ );
752
+ return;
753
+ }
754
+ touch(envelope.id);
755
+ c.peerEnded = true;
756
+ c.onPeerEnd?.deref()?.();
757
+ freeIfEnded(envelope.id, c);
758
+ return;
759
+ }
760
+
616
761
  // The peer closed the port: whatever it still had to receive is moot.
617
762
  dropOutgoing(envelope.id);
618
763
  if (!entry) return;
@@ -724,8 +869,8 @@ export function multiplexPort(
724
869
  post(
725
870
  window
726
871
  ? exactCodec
727
- ? { type: "open", id, meta, window, exact: true }
728
- : { type: "open", id, meta, window }
872
+ ? { type: "open", id, meta, window, exact: true, caps: [...caps] }
873
+ : { type: "open", id, meta, window, caps: [...caps] }
729
874
  : { type: "open", id, meta },
730
875
  );
731
876
  if (portSignal) {
@@ -59,3 +59,89 @@ export function portBufferedAmount(port: MessageTarget): number {
59
59
  export function whenPortReady(port: MessageTarget, below = 0): Promise<void> {
60
60
  return states.get(port)?.ready(below) ?? new Promise<void>((resolve) => nextTask(resolve));
61
61
  }
62
+
63
+ /** A capability two ends agree on for one windowed port, in its `open` and the ack. */
64
+ export type PortCap = "pipeline" | "end";
65
+
66
+ /**
67
+ * What two ends have agreed for one windowed port so far. `caps`: what both
68
+ * speak, and so what the peer may send; empty until the handshake settles it
69
+ * (the opener learns it from the ack, the responder from the `open`).
70
+ * `enforced`: the window holds this end's own sends — what this end needs
71
+ * before it relies on the window instead of a confirmation per message.
72
+ */
73
+ export interface PortAgreement {
74
+ window: number;
75
+ caps: ReadonlySet<PortCap>;
76
+ enforced: boolean;
77
+ }
78
+
79
+ const agreements = new WeakMap<MessageTarget, () => PortAgreement | undefined>();
80
+
81
+ /** @internal Registered by the multiplexer for each port with a window. */
82
+ export function setPortAgreement(port: MessageTarget, agreement: () => PortAgreement | undefined) {
83
+ agreements.set(port, agreement);
84
+ }
85
+
86
+ /**
87
+ * @internal What has been agreed with the peer on `port` so far; `undefined`
88
+ * on a port without a window. Not exported from the package.
89
+ */
90
+ export function portAgreement(port: MessageTarget): PortAgreement | undefined {
91
+ return agreements.get(port)?.();
92
+ }
93
+
94
+ /** @internal The half-close of one windowed port (capability `"end"`). */
95
+ export interface PortEndState {
96
+ /** Queue this side's `end`; `false` when `end` was not agreed. */
97
+ end(): boolean;
98
+ /**
99
+ * Calls `resolve` once the peer has ended its direction (at once if it has
100
+ * already); `false`, and `resolve` is never called, when `end` was not
101
+ * agreed. The mux holds `resolve` weakly, so a port dropped while someone
102
+ * waits on it can still be collected (`onLeak`).
103
+ */
104
+ watchPeerEnd(resolve: () => void): boolean;
105
+ }
106
+
107
+ const ends = new WeakMap<MessageTarget, PortEndState>();
108
+
109
+ /** @internal Registered by the multiplexer for each port with a window. */
110
+ export function setPortEndState(port: MessageTarget, state: PortEndState): void {
111
+ ends.set(port, state);
112
+ }
113
+
114
+ /**
115
+ * @internal End this side's direction of `port`: an `end` is queued behind
116
+ * everything already posted on it, and anything posted afterwards is dropped.
117
+ * `false`, and nothing changes, when `end` was not agreed (no window, the
118
+ * handshake not answered yet, or a peer that does not speak it): the caller
119
+ * then closes the port. Never waits on the peer. Not exported from the package.
120
+ */
121
+ export function endPort(port: MessageTarget): boolean {
122
+ return ends.get(port)?.end() ?? false;
123
+ }
124
+
125
+ /**
126
+ * @internal Resolves once the peer has ended its direction of `port`. It never
127
+ * settles otherwise: not on a port where `end` is not agreed when it is asked
128
+ * (no window, the handshake not answered yet, a peer or codec without it), and
129
+ * not when the port closes first, so a caller races it against the port's
130
+ * close signal. Not exported from the package.
131
+ */
132
+ export function whenPortEnded(port: MessageTarget): Promise<void> {
133
+ let waiting = peerEnds.get(port);
134
+ if (!waiting) {
135
+ let resolve: () => void = () => {};
136
+ const promise = new Promise<void>((r) => {
137
+ resolve = r;
138
+ });
139
+ if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
140
+ // Held from the port only: what waits on it lives as long as the port.
141
+ waiting = { promise, resolve };
142
+ peerEnds.set(port, waiting);
143
+ }
144
+ return waiting.promise;
145
+ }
146
+
147
+ const peerEnds = new WeakMap<MessageTarget, { promise: Promise<void>; resolve: () => void }>();
package/src/port-types.ts CHANGED
@@ -9,11 +9,18 @@ import type { MessageTarget } from "./message-target.js";
9
9
  * inspects, because stream semantics belong above this layer.
10
10
  */
11
11
  export type PortEnvelope =
12
- | { type: "open"; id: number; meta?: unknown; window?: number; exact?: boolean }
12
+ /** `caps` (with a window): the capabilities the opener speaks on this port. */
13
+ | { type: "open"; id: number; meta?: unknown; window?: number; exact?: boolean; caps?: string[] }
13
14
  | { type: "message"; id: number; payload: unknown }
14
15
  | { type: "close"; id: number; reason?: unknown }
15
- /** Per-port flow control (`window`): the receiver has consumed `bytes` more. */
16
- | { type: "credit"; id: number; bytes: number; exact?: boolean }
16
+ /**
17
+ * Per-port flow control (`window`): the receiver has consumed `bytes` more.
18
+ * The responder's zero-byte ack carries `caps`, the shared subset, when the
19
+ * opener listed some.
20
+ */
21
+ | { type: "credit"; id: number; bytes: number; exact?: boolean; caps?: string[] }
22
+ /** Half-close (capability `"end"`): the sender's direction of the port has ended. */
23
+ | { type: "end"; id: number }
17
24
  /** Fair framing (`frameSize`): one piece of an encoded `message` envelope. */
18
25
  | { type: "frame"; id: number; data: Uint8Array; last?: boolean }
19
26
  /** Keepalive (`keepalive`): the mux's own liveness probe and its answer, on an id no port takes. */
@@ -42,6 +49,15 @@ export interface PortCodec {
42
49
  encode?(envelope: PortEnvelope): Uint8Array;
43
50
  /** Optional, with `encode`: an envelope back from its bytes, or `undefined`. */
44
51
  decode?(data: unknown): PortEnvelope | undefined;
52
+ /**
53
+ * Optional: this codec's `read` (and `decode`) accept the `end` envelope.
54
+ * Only then does a windowed port offer the `end` capability. A codec that
55
+ * dropped `end` would leave a port that ended normally in the peer's table
56
+ * (`ports()`) until the mux closes, with no error anywhere. A codec spread
57
+ * from a built-in (`{ ...structuredCodec, read }`) inherits the flag: its
58
+ * own `read` must then accept `end` too, or it must drop the flag.
59
+ */
60
+ halfClose?: true;
45
61
  }
46
62
 
47
63
  export interface PortMuxOptions {
@@ -12,7 +12,8 @@ function isEnvelope(value: unknown): value is PortEnvelope {
12
12
  candidate.type === "credit" ||
13
13
  candidate.type === "frame" ||
14
14
  candidate.type === "ping" ||
15
- candidate.type === "pong"
15
+ candidate.type === "pong" ||
16
+ candidate.type === "end"
16
17
  );
17
18
  }
18
19
 
@@ -26,6 +27,9 @@ function isEnvelope(value: unknown): value is PortEnvelope {
26
27
  * not send anything a byte codec could not also carry.
27
28
  */
28
29
  export const structuredCodec: PortCodec = {
30
+ // `read` accepts `end`, so windowed ports over this codec offer it.
31
+ halfClose: true,
32
+
29
33
  post(port, envelope, transfer) {
30
34
  // An empty transfer list is not the same as no transfer list — some
31
35
  // implementations reject the former.