@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.
- package/README.md +156 -10
- package/dist/duplex-over-port.d.ts +3 -1
- package/dist/duplex-over-port.d.ts.map +1 -1
- package/dist/index.js +446 -95
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/port-credit.d.ts +51 -0
- package/dist/port-credit.d.ts.map +1 -1
- package/dist/port-types.d.ts +24 -2
- package/dist/port-types.d.ts.map +1 -1
- package/dist/structured-codec.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/duplex-over-port.ts +337 -40
- package/src/multiplex-port.ts +161 -16
- package/src/port-credit.ts +86 -0
- package/src/port-types.ts +19 -3
- package/src/structured-codec.ts +5 -1
package/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? }`,
|
|
419
|
-
`{ type: "
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
568
|
-
|
|
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
|
|
593
|
-
|
|
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;
|
|
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"}
|