@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 CHANGED
@@ -78,7 +78,7 @@ Port multiplexing:
78
78
  | `PortMux` | `openPort(meta?, { signal }?)`, `close(reason?)`, `ports?()`, `maxMessageSize`. |
79
79
  | `portBufferedAmount(port)`, `whenPortReady(port, below?)` | A windowed port's queued bytes, and a promise for room. See below. |
80
80
  | `PortInfo`, `OpenPortOptions` | What `ports()` reports per port (`id`, `meta`, `openedAt`, `lastActivity`); `openPort`'s options. |
81
- | `PortCodec`, `PortEnvelope` | The wire seam: `post(port, envelope, transfer?)`, `read(event)`, and optionally `encode(envelope)` / `decode(data)` for byte codecs (exact window sizes, frames). |
81
+ | `PortCodec`, `PortEnvelope` | The wire seam: `post(port, envelope, transfer?)`, `read(event)`, and optionally `encode(envelope)` / `decode(data)` for byte codecs (exact window sizes, frames) and `halfClose: true` for a codec that reads `end`. |
82
82
  | `structuredCodec` | Envelopes passed through unencoded. |
83
83
  | `DEFAULT_MAX_PORTS` | `1024`. |
84
84
  | `PORT_TRANSFER` | `"webrun-rpc:port-transfer"`, the `type` of the message that carries a port. |
@@ -344,6 +344,44 @@ each end counts bytes but holds nothing back, so a port to an older peer, or ove
344
344
  keeps running without flow control, as it always did; `ports()` reports `window` only once it is
345
345
  in force. A peer's `window` that is not a positive integer is ignored.
346
346
 
347
+ **A windowed port agrees capabilities in its handshake.** A windowed `open` also lists
348
+ `caps: ["pipeline", "end"]` (`end` only over a codec that declares it, below), what this mux
349
+ speaks on the port, and the responder's ack answers with the
350
+ subset both speak, only to an opener that listed some: an older peer never receives a `caps` it
351
+ could misread. There is no option to offer fewer. `duplexOverPort` reads the agreement (see "Flow
352
+ control is one chunk, or the window where both ends pipeline" below); nothing else changes
353
+ meaning. The responder still holds its own sends back only once the opener has answered its ack,
354
+ as above: a responder over a codec that drops credit envelopes would otherwise wedge its own port
355
+ after one window.
356
+
357
+ **Where `end` is agreed, each direction of a port can end on its own.** An `end` envelope leaves
358
+ behind everything already posted on that port: it is a size-0 item in the port's window queue, so
359
+ it needs no credit, and with `frameSize` it takes its turn in the port's queue behind the frames of
360
+ the last message. So the peer has every byte before it sees the end, even of a last message larger
361
+ than the window. Anything posted on that side afterwards is dropped. A port closed while its `end`
362
+ still waits in the queue sends its `close` alone: an abandoned port never looks finished. The other
363
+ direction keeps sending, and both keep returning credit, until it ends too. Once both directions have ended, each
364
+ end frees the id on its own, with no `close` on the wire, and the port's close signal aborts with
365
+ `webrun-rpc: the port ended in both directions`. `duplexOverPort` sends it when a stream ends
366
+ normally (see "A stream that ends normally ends its port" below); no exported function sends it
367
+ directly. An `end` from a peer where it was not agreed (no window, or not in the handshake) is a
368
+ protocol violation: the port closes, with `close` reason `"protocol"`, and its close signal aborts
369
+ with `webrun-rpc: the peer ended a port where end was not agreed`. So is a `message` or `frame`
370
+ from a peer after its own `end` (`webrun-rpc: the peer sent on a port after its end`): an honest
371
+ sender drops whatever it posts after ending, and delivering it would hand the consumer data after
372
+ it was told its input was over.
373
+
374
+ **A codec must declare that it reads `end`, or `end` is never offered.** `PortCodec.halfClose:
375
+ true` says the codec's `read` (and `decode`) accept the `end` envelope; `structuredCodec` and
376
+ `msgpackCodec` set it. A mux over a codec without it offers only `pipeline` and agrees `end` with
377
+ nobody, so its ports close. The flag exists because the failure it prevents is silent: a codec
378
+ whose `read` accepts a fixed list of envelope types, without `end`, would let both muxes agree
379
+ `end` and then lose it on the way in. The sender frees the id; the receiver never sees the end,
380
+ and the port stays in its `ports()` after a normal finish until the mux closes, with no error,
381
+ until a full table refuses new ports with `maxPorts (<n>) reached`. A codec spread from a
382
+ built-in, `{ ...structuredCodec, read: myRead }`, inherits `halfClose: true`: `myRead` must then
383
+ accept `end`, or the codec must leave the flag out.
384
+
347
385
  **Closing does not wait for credit.** A port closed with messages still queued sends them at once,
348
386
  then its close, regardless of the window: a peer that stopped returning credit (a listener that
349
387
  never settles) must still learn the port is closed, and nothing is kept for a port nobody holds.
@@ -415,18 +453,20 @@ the consumer that filled it (`"?"` counts ports without a kind). A duplicate `op
415
453
  peer is ignored rather than replacing a live port, and a local `openPort` skips ids the peer
416
454
  already claimed with the wrong parity.
417
455
 
418
- `PortEnvelope` is `{ type: "open", id, meta?, window?, exact? }`, `{ type: "message", id, payload }`,
419
- `{ type: "close", id, reason? }`, with a window `{ type: "credit", id, bytes, exact? }`, with
456
+ `PortEnvelope` is `{ type: "open", id, meta?, window?, exact?, caps? }`,
457
+ `{ type: "message", id, payload }`, `{ type: "close", id, reason? }`, with a window
458
+ `{ type: "credit", id, bytes, exact?, caps? }` and, where agreed, `{ type: "end", id }`, with
420
459
  `frameSize` `{ type: "frame", id, data, last? }`, and with `keepalive` `{ type: "ping", id }` /
421
460
  `{ type: "pong", id }` (on `Number.MAX_SAFE_INTEGER`). The codecs' `read` accept only objects with a non-negative integer `id`
422
- and one of those seven types, so other traffic on a shared port is
461
+ and one of those eight types, so other traffic on a shared port is
423
462
  ignored.
424
463
  `structuredCodec` passes envelopes through unencoded, so `ArrayBuffer`s move zero-copy through the
425
464
  transfer list; it omits an empty transfer list because some implementations reject one.
426
465
 
427
466
  ### A port lives until its transport or its user lets go
428
467
 
429
- A port leaves the table only when one side closes it or the mux closes. Nothing else frees it, and
468
+ A port leaves the table only when one side closes it, both directions end (`end`, above) or the mux
469
+ closes. Nothing else frees it, and
430
470
  a full table refuses every new port. So both lifetimes are wired in explicitly:
431
471
 
432
472
  - **The transport.** A `MessagePort`, a terminated worker or a dead libp2p stream gives no close
@@ -548,7 +588,7 @@ caller declines inbound ports. Whichever side called the factory owns the mux an
548
588
  that is built and dropped costs the peer nothing. Every port it opens carries `meta`
549
589
  `{ kind: "stream" }`, so a peer running other things over the same mux can tell stream ports apart.
550
590
 
551
- ### Flow control is a window of one chunk
591
+ ### Flow control is one chunk, or the window where both ends pipeline
552
592
 
553
593
  `duplexOverPort` sends each direction as one `callPort` per chunk on its own channel (`"in"` for
554
594
  the caller's input, `"out"` for the handler's output). The reply to a chunk call is the
@@ -564,8 +604,64 @@ A peer that sends a second chunk before the first is confirmed gets `response:er
564
604
  closed. Other ports on the mux are untouched.
565
605
 
566
606
  The cost: single-stream throughput is `chunk ÷ RTT`. In-process that is negligible; over a 50 ms
567
- round trip, a 10 MiB body in 12 KiB chunks is 854 sequential round trips, about 43 s. Concurrency
568
- comes from running many streams, one port each, not from pipelining one.
607
+ round trip, a 10 MiB body in 12 KiB chunks is 854 sequential round trips, about 43 s.
608
+
609
+ **On a port whose window both ends speak, chunks are pipelined.** Once the port's handshake has
610
+ agreed `pipeline` (see "A windowed port agrees capabilities in its handshake" above) and the
611
+ window holds this end's sends, each chunk leaves as a plain message,
612
+ `{ type: "webrun-rpc:chunk", channelName, chunk }`, with no confirmation of its own; the sender
613
+ waits only for `whenPortReady`. The receiver's listener settles once its consumer pulls past the
614
+ chunk, so the window's credit is the confirmation, and a stalled reader holds about one window:
615
+ `tests/duplex-pipelining-stall.test.ts` measures the producer stopping about one window ahead,
616
+ and the memory held staying within twice the window above an unwindowed link. Nor does a stalled
617
+ stream slow the others on its link: `pnpm bench` (kept out of `pnpm test`, so that a loaded
618
+ machine cannot fail the suite) measures ping p99 beside a stalled reader with a 256 KiB window
619
+ and with none, in one run, median of 3 each, and fails when the window's p99 is above twice the
620
+ other's plus 1 ms.
621
+
622
+ Until the agreement is known (the opener learns it from the ack; the responder's own sends wait
623
+ for the opener's answer) chunks go as confirmed calls, so the handshake adds no round trip to the
624
+ first chunk. Chunks, results and errors are the same either way, an error still in the last
625
+ chunk. Over `overPipe` without `window`, over `transferPortMux` and with a peer that answers no
626
+ capabilities, every chunk is a confirmed call.
627
+
628
+ **A chunk travels as its own bytes.** Structured clone carries a typed array's whole
629
+ `ArrayBuffer`, while the window counts only the view. A producer yielding `subarray`s of one large
630
+ buffer (`toChunks` does, and so do Node `Buffer` slices) would otherwise ship, and a stalled
631
+ reader hold, the whole buffer once per chunk: 4 KiB views of a 4 MiB buffer behind a 256 KiB
632
+ window held about 248 MiB, which looks like a leak and is not one. So every chunk is sent as a
633
+ plain `Uint8Array` that owns its buffer, in both modes: a view on a larger buffer, a Node
634
+ `Buffer`, another typed array or a `DataView` is copied, and an `ArrayBuffer` is wrapped. The
635
+ copy is made with the `Uint8Array` constructor, because a `Buffer`'s `slice()` is another view on
636
+ the same memory. A plain `Uint8Array` that covers its whole buffer is sent as it is. Anything else
637
+ ends the stream with the producer's own error, which reaches the peer like a handler's:
638
+ `webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got
639
+ <type>`.
640
+
641
+ The receiver copies again, confirmed or pipelined, whatever the peer sent: what is held is what is
642
+ counted. Over `msgpackCodec` that is one copy of every received chunk, because its decoder returns
643
+ views of the frame (and over a Node transport, views of a frame `Buffer`). The copy stays: without
644
+ it, a chunk would keep its whole frame alive, and a hostile frame of one byte of chunk beside 1 MiB
645
+ of padding would hold that 1 MiB while counting 33 bytes.
646
+
647
+ A pipelining peer is held to these rules, each failing that stream and closing its port, holding
648
+ nothing more:
649
+
650
+ - a pipelined chunk where `pipeline` was not agreed:
651
+ `webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed`;
652
+ - one that arrives while the receiver already holds more than the window unconsumed (each chunk
653
+ counted as its bytes plus 32, less than either codec's window counts for it, so an honest sender
654
+ never trips it): `webrun-rpc: peer sent past the window (<n> bytes) without waiting for credit;
655
+ the stream port is closed`;
656
+ - a confirmed chunk while pipelined ones are held, or a pipelined one while a confirmed one is:
657
+ `webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed` (an honest
658
+ sender switches to pipelining once, after its last confirmed chunk was answered);
659
+ - a pipelined chunk whose value is not a typed array or `DataView`:
660
+ `webrun-rpc: peer sent a pipelined chunk whose value is not bytes`.
661
+
662
+ So the receiver holds at most the window plus one chunk of a stream's bytes. A hostile peer can
663
+ still make it receive a large buffer per message, briefly: the copy on receipt frees it once that
664
+ message is handled, so it costs time, not memory held.
569
665
 
570
666
  **One stream per port.** Nothing enforces it: invoking the same `duplexOverPort` result twice on
571
667
  one port makes both invocations cross-talk on the same two channel names. Open a port per call.
@@ -589,8 +685,58 @@ direction resets it, and elapsing aborts the stream with `webrun-rpc: stream idl
589
685
  Unset, zero or non-finite means none.
590
686
 
591
687
  Know what you buy if you set it. The clock resets only when a chunk call returns, and that reply
592
- is withheld until the consumer has pulled past the value. The clock cannot tell "slow" from "dead":
593
- a consumer slower than the timeout **is** failed.
688
+ is withheld until the consumer has pulled past the value (a pipelined chunk resets it when sent
689
+ and when received, and the window holds the next one back the same way). The clock cannot tell
690
+ "slow" from "dead": a consumer slower than the timeout **is** failed.
691
+
692
+ ### A stream that ends normally ends its port
693
+
694
+ Where both ends agreed `end`, a stream that ends normally in both directions frees its port on
695
+ both ends with no `close` on the wire, and `ports()` no longer lists it. So the port's life
696
+ follows the stream's own end, not a `close()` someone has to remember: a side that forgets it, or
697
+ a relay that sees the stream end and still holds the port, no longer keeps an id until the mux
698
+ closes. Each side posts its last chunk (`done`), then ends its direction:
699
+
700
+ - **The caller** ends once the handler's last chunk arrived without an error and its own last
701
+ chunk left without one: posted when pipelined, confirmed when not.
702
+ - **The serving side** ends once the handler's last chunk left without an error and no more input
703
+ is coming: the caller's last chunk arrived without an error, or the caller ended its direction.
704
+ The second is a handler that returned without reading all its pipelined input; that input is
705
+ dropped, as a close would drop it. A confirmed chunk the handler never read is never confirmed,
706
+ so the caller's own end never left: that stream is closed (below). It happens to input sent
707
+ before the handshake settled, so a short request whose handler ignores its body closes.
708
+
709
+ Nothing either side still owes the other is lost behind its `end`, after which anything it posts
710
+ is dropped: each side's last confirmation leaves before its `end`, and the caller ends only once
711
+ its own last chunk was confirmed, so the serving side owes it nothing more. Freeing the port
712
+ aborts its close signal with `webrun-rpc: the port ended in both directions` once both sides have
713
+ finished, so no result or chunk in flight fails, and neither the caller nor the handler sees an
714
+ error.
715
+
716
+ A side that only waits for its peer's `end` (a handler that returned without reading all its
717
+ input, its last chunk already out) keeps its port by itself: the watcher that waits for that `end`
718
+ is a listener of the port, and so reachable from the mux. Nothing else of the stream may still
719
+ hold the port then, and a port nobody holds is collected and reported by `onLeak` instead of
720
+ ending (`tests/duplex-end.test.ts` forces a collection there). A close drops the watcher with the
721
+ port's other listeners, so an abandoned stream leaves nothing waiting.
722
+
723
+ A peer that ends its direction without its last chunk, or behind a last chunk that carries an
724
+ error, is breaking the stream: its consumer would wait for a chunk that cannot come, or take an
725
+ input that failed for one that finished. The input fails with `webrun-rpc: the peer ended its
726
+ direction without finishing it; the stream port is closed`, and this side posts its notice and
727
+ closes the port. The caller's side learns of the agreement from the ack, so it watches for this
728
+ from the handler's first chunk on: a handler that ends having sent no chunk at all leaves the
729
+ caller waiting, as any silent peer does, until its `timeout`.
730
+
731
+ Anything else is an abandonment, closed with the notice first (below): the caller returning
732
+ early, a timeout, a teardown, and a last chunk that carries an error, from the handler or from the
733
+ caller's input. Without `end` (no window, a peer or a codec that does not offer it) the caller
734
+ closes a normally finished stream too, notice and then `close()`, and the serving side waits for
735
+ that close.
736
+
737
+ A peer that stops before it ends its own direction keeps the port in both ends' `ports()` until
738
+ the port or a mux closes. A stream's `timeout` has stopped on the side that already ended, so it
739
+ takes the transport's `signal` or `keepalive` to notice, as for any peer that goes silent.
594
740
 
595
741
  ### How an abandoned stream is reported
596
742
 
@@ -35,7 +35,9 @@ export interface DuplexOverPortOptions {
35
35
  * same way on the other channel. Within each direction the next chunk is never
36
36
  * sent until the previous one has been delivered *and* pulled past by the
37
37
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
38
- * `listenPort` withholds it until then.
38
+ * `listenPort` withholds it until then. On a port whose window both ends
39
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
40
+ * the window's credit is the confirmation: up to a window is in flight.
39
41
  *
40
42
  * A stream port carries exactly one invocation. To make several calls, open
41
43
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -1 +1 @@
1
- {"version":3,"file":"duplex-over-port.d.ts","sourceRoot":"","sources":["../src/duplex-over-port.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EAOZ,MAAM,6BAA6B,CAAC;AAIrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIzD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,4BAA4B,CAAC;AActD,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAE/F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,qBAA0B,GAClC,MAAM,IAAI,CA8BZ"}
1
+ {"version":3,"file":"duplex-over-port.d.ts","sourceRoot":"","sources":["../src/duplex-over-port.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EAOZ,MAAM,6BAA6B,CAAC;AAIrC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAWzD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,4BAA4B,CAAC;AA2FtD,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAE/F;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,qBAA0B,GAClC,MAAM,IAAI,CA4CZ"}