@norskvideo/moq-net 0.1.8 → 0.2.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 +2 -2
- package/announce.d.ts +7 -0
- package/announce.d.ts.map +1 -0
- package/announce.js +8 -0
- package/announce.js.map +1 -0
- package/announced.d.ts +49 -91
- package/announced.d.ts.map +1 -1
- package/announced.js +21 -156
- package/announced.js.map +1 -1
- package/bandwidth.d.ts +163 -0
- package/bandwidth.d.ts.map +1 -0
- package/bandwidth.js +304 -0
- package/bandwidth.js.map +1 -0
- package/bandwidth_api.d.ts +7 -0
- package/bandwidth_api.d.ts.map +1 -0
- package/bandwidth_api.js +8 -0
- package/bandwidth_api.js.map +1 -0
- package/broadcast.d.ts +44 -35
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +104 -60
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +16 -1
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +52 -28
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts.map +1 -1
- package/connection/browser.js +9 -7
- package/connection/browser.js.map +1 -1
- package/connection/connect.d.ts +30 -6
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +110 -54
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +17 -21
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/forward.d.ts +2 -0
- package/connection/forward.d.ts.map +1 -0
- package/connection/forward.js +173 -0
- package/connection/forward.js.map +1 -0
- package/connection/handshake.d.ts +1 -0
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +5 -2
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +5 -5
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +4 -5
- package/connection/index.js.map +1 -1
- package/connection/pool.d.ts +186 -0
- package/connection/pool.d.ts.map +1 -0
- package/connection/pool.js +361 -0
- package/connection/pool.js.map +1 -0
- package/connection/reload.d.ts +14 -93
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +217 -81
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +3 -26
- package/connection/stats.d.ts.map +1 -1
- package/connection/stats.js.map +1 -1
- package/connection/transport.d.ts +0 -7
- package/connection/transport.d.ts.map +1 -1
- package/consume.d.ts +1 -43
- package/consume.d.ts.map +1 -1
- package/consume.js +1 -1
- package/consume.js.map +1 -1
- package/error.d.ts +180 -29
- package/error.d.ts.map +1 -1
- package/error.js +331 -16
- package/error.js.map +1 -1
- package/errors.d.ts +7 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +8 -0
- package/errors.js.map +1 -0
- package/group.d.ts +9 -43
- package/group.d.ts.map +1 -1
- package/group.js +284 -69
- package/group.js.map +1 -1
- package/hop.d.ts +115 -0
- package/hop.d.ts.map +1 -0
- package/hop.js +119 -0
- package/hop.js.map +1 -0
- package/ietf/adapter.d.ts +5 -1
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +105 -60
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +1 -78
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/cluster.d.ts +9 -123
- package/ietf/cluster.d.ts.map +1 -1
- package/ietf/cluster.js +84 -44
- package/ietf/cluster.js.map +1 -1
- package/ietf/connection.d.ts +6 -72
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +62 -56
- package/ietf/connection.js.map +1 -1
- package/ietf/error.d.ts +11 -0
- package/ietf/error.d.ts.map +1 -0
- package/ietf/error.js +193 -0
- package/ietf/error.js.map +1 -0
- package/ietf/fetch.d.ts +7 -20
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +52 -22
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +2 -0
- package/ietf/filter.d.ts.map +1 -1
- package/ietf/filter.js +10 -1
- package/ietf/filter.js.map +1 -1
- package/ietf/goaway.d.ts.map +1 -1
- package/ietf/goaway.js +22 -5
- package/ietf/goaway.js.map +1 -1
- package/ietf/hidden.d.ts +2 -0
- package/ietf/hidden.d.ts.map +1 -0
- package/ietf/hidden.js +30 -0
- package/ietf/hidden.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +2 -0
- package/ietf/index.js.map +1 -1
- package/ietf/object.d.ts +15 -8
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +51 -36
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +14 -2
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +97 -29
- package/ietf/parameters.js.map +1 -1
- package/ietf/properties.d.ts +1 -0
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +14 -0
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts +19 -2
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +40 -6
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +25 -0
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +63 -0
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +1 -82
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +483 -235
- package/ietf/publisher.js.map +1 -1
- package/ietf/solicit.d.ts +1 -40
- package/ietf/solicit.d.ts.map +1 -1
- package/ietf/subscribe.d.ts +8 -6
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +33 -27
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +8 -2
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +18 -8
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +1 -65
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +337 -123
- package/ietf/subscriber.js.map +1 -1
- package/ietf/token.d.ts +2 -0
- package/ietf/token.d.ts.map +1 -0
- package/ietf/token.js +99 -0
- package/ietf/token.js.map +1 -0
- package/ietf/track.d.ts +4 -0
- package/ietf/track.d.ts.map +1 -1
- package/ietf/track.js +6 -20
- package/ietf/track.js.map +1 -1
- package/ietf/version.d.ts +12 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +13 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +12 -7
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +115 -1
- package/internal.d.ts.map +1 -1
- package/internal.js +108 -0
- package/internal.js.map +1 -1
- package/lite/announce.d.ts +60 -10
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +176 -31
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +9 -59
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +34 -35
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +3 -2
- package/lite/datagram.d.ts.map +1 -1
- package/lite/datagram.js +7 -8
- package/lite/datagram.js.map +1 -1
- package/lite/fetch.d.ts +15 -1
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +39 -7
- package/lite/fetch.js.map +1 -1
- package/lite/goaway.d.ts.map +1 -1
- package/lite/goaway.js +7 -1
- package/lite/goaway.js.map +1 -1
- package/lite/group.d.ts +30 -12
- package/lite/group.d.ts.map +1 -1
- package/lite/group.js +68 -26
- package/lite/group.js.map +1 -1
- package/lite/message.d.ts +2 -2
- package/lite/message.d.ts.map +1 -1
- package/lite/message.js +14 -5
- package/lite/message.js.map +1 -1
- package/lite/priority.d.ts +1 -61
- package/lite/priority.d.ts.map +1 -1
- package/lite/priority.js +4 -5
- package/lite/priority.js.map +1 -1
- package/lite/publisher.d.ts +1 -69
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +630 -262
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +8 -8
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +33 -30
- package/lite/setup.js.map +1 -1
- package/lite/subscribe.d.ts +71 -17
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +205 -53
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +15 -56
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +451 -247
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +4 -10
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +34 -29
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +42 -6
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +126 -10
- package/lite/version.js.map +1 -1
- package/origin.d.ts +256 -29
- package/origin.d.ts.map +1 -1
- package/origin.js +1427 -37
- package/origin.js.map +1 -1
- package/package.json +8 -3
- package/path.d.ts +25 -7
- package/path.d.ts.map +1 -1
- package/path.js +5 -3
- package/path.js.map +1 -1
- package/stream.d.ts +73 -14
- package/stream.d.ts.map +1 -1
- package/stream.js +372 -141
- package/stream.js.map +1 -1
- package/tail.d.ts +18 -0
- package/tail.d.ts.map +1 -0
- package/tail.js +167 -0
- package/tail.js.map +1 -0
- package/time.d.ts +15 -2
- package/time.d.ts.map +1 -1
- package/time.js +28 -9
- package/time.js.map +1 -1
- package/track.d.ts +211 -83
- package/track.d.ts.map +1 -1
- package/track.js +816 -205
- package/track.js.map +1 -1
- package/util/abort.d.ts +2 -0
- package/util/abort.d.ts.map +1 -0
- package/util/abort.js +20 -0
- package/util/abort.js.map +1 -0
- package/util/log.d.ts +5 -0
- package/util/log.d.ts.map +1 -0
- package/util/log.js +17 -0
- package/util/log.js.map +1 -0
- package/util/u64.d.ts +39 -0
- package/util/u64.d.ts.map +1 -0
- package/util/u64.js +83 -0
- package/util/u64.js.map +1 -0
- package/util/varint.d.ts +29 -0
- package/util/varint.d.ts.map +1 -0
- package/util/varint.js +198 -0
- package/util/varint.js.map +1 -0
- package/varint.d.ts +10 -6
- package/varint.d.ts.map +1 -1
- package/varint.js +40 -237
- package/varint.js.map +1 -1
- package/wire.d.ts +80 -0
- package/wire.d.ts.map +1 -0
- package/wire.js +32 -0
- package/wire.js.map +1 -0
- package/zod.d.ts +1 -1
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/mock.d.ts +0 -66
- package/mock.d.ts.map +0 -1
- package/mock.js +0 -243
- package/mock.js.map +0 -1
package/lite/subscriber.js
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
/* @ts-self-types="./subscriber.d.ts" */
|
|
2
|
-
import { Signal } from "@norskvideo/moq-signals";
|
|
2
|
+
import { race, Signal } from "@norskvideo/moq-signals";
|
|
3
3
|
import * as announce from "../announced.js";
|
|
4
4
|
import * as broadcast from "../broadcast.js";
|
|
5
5
|
import { BroadcastCache } from "../consume.js";
|
|
6
|
-
import { error, ProtocolViolation, reason } from "../error.js";
|
|
6
|
+
import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError, sessionCause } from "../error.js";
|
|
7
7
|
import * as netGroup from "../group.js";
|
|
8
|
-
import {
|
|
8
|
+
import { Cost, MAX_HOPS, randomHop, routesEqual, stampHops, UNKNOWN_HOP } from "../hop.js";
|
|
9
|
+
import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.js";
|
|
9
10
|
import * as Path from "../path.js";
|
|
10
11
|
import { Stream } from "../stream.js";
|
|
12
|
+
import { TAIL_GRACE_MS, Tail } from "../tail.js";
|
|
11
13
|
import * as Time from "../time.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
+
import { untilAborted } from "../util/abort.js";
|
|
15
|
+
import { TimeoutError, withTimeout } from "../util/timeout.js";
|
|
16
|
+
import { overrideBroadcastWire, wireOf } from "../wire.js";
|
|
17
|
+
import { AnnounceHistory, AnnounceInit, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcastMaybe, } from "./announce.js";
|
|
14
18
|
import { Datagram as DatagramMessage } from "./datagram.js";
|
|
15
19
|
import * as DatagramStream from "./datagram_stream.js";
|
|
16
20
|
import { Fetch as FetchMessage } from "./fetch.js";
|
|
21
|
+
import { frameDecoder, readFrames } from "./group.js";
|
|
17
22
|
import { sendOrder } from "./priority.js";
|
|
18
23
|
import { Probe } from "./probe.js";
|
|
19
24
|
import { ProbeLevel } from "./setup.js";
|
|
20
25
|
import { StreamId } from "./stream.js";
|
|
21
|
-
import { decodeSubscribeResponse, decodeSubscribeResponseMaybe, Subscribe, SubscribeUpdate } from "./subscribe.js";
|
|
26
|
+
import { decodeSubscribeResponse, decodeSubscribeResponseMaybe, EMPTY_RANGE, emptyRange, exclusiveGroupEnd, inclusiveGroupEnd, Subscribe, SubscribeUpdate, } from "./subscribe.js";
|
|
22
27
|
import { TrackInfo, Track as TrackMessage } from "./track.js";
|
|
23
|
-
import { hasAnnounceId, hasAnnounceOk, hasDatagrams,
|
|
28
|
+
import { hasAnnounceId, hasAnnounceOk, hasDatagrams, hasProbeRtt, hasStreamCount, restartSupported, Version, } from "./version.js";
|
|
24
29
|
// Bound on how long stream-open plus the first response (SUBSCRIBE_OK on older
|
|
25
30
|
// drafts, or TRACK_INFO on lite-05+) may take. Browsers cap concurrent QUIC streams
|
|
26
31
|
// (Chrome ~100) and we open with waitUntilAvailable, so past the cap the open blocks
|
|
27
32
|
// until the peer frees a slot. The timeout turns a stall into a clear error.
|
|
28
33
|
const SUBSCRIBE_SETUP_TIMEOUT_MS = 10_000;
|
|
29
|
-
/** Decode an unsigned zigzag varint back to a signed delta (mirrors Rust `VarInt::to_zigzag`). */
|
|
30
|
-
function unzigzag(v) {
|
|
31
|
-
return (v >> 1n) ^ -(v & 1n);
|
|
32
|
-
}
|
|
33
34
|
// The TRACK stream and implicit SUBSCRIBE acceptance are lite-05+.
|
|
34
35
|
function supportsTrackStream(version) {
|
|
35
36
|
switch (version) {
|
|
@@ -69,9 +70,8 @@ export class Subscriber {
|
|
|
69
70
|
#quic;
|
|
70
71
|
// The version of the connection.
|
|
71
72
|
version;
|
|
72
|
-
// Shared with the Publisher so
|
|
73
|
-
|
|
74
|
-
origin;
|
|
73
|
+
// Shared with the Publisher so reflected announces can be dropped on receipt.
|
|
74
|
+
hop;
|
|
75
75
|
// Our subscribed tracks. `timescale` resolves once known (from TRACK_INFO on
|
|
76
76
|
// lite-05+, or implicit defaults on older drafts); group streams block on it
|
|
77
77
|
// before decoding any frame, since a group's QUIC stream can race ahead.
|
|
@@ -79,6 +79,9 @@ export class Subscriber {
|
|
|
79
79
|
#subscribeNext = 0n;
|
|
80
80
|
// Dedup consumed broadcasts per path: repeat consume() calls share one subscription.
|
|
81
81
|
#consumes = new BroadcastCache();
|
|
82
|
+
// A random Hop ID of this connection's own, written as the first hop of any chain that
|
|
83
|
+
// names no publisher, so a publisher that reconnects reads as a new one.
|
|
84
|
+
#stamp;
|
|
82
85
|
// Dedup in-flight one-shot fetches, keyed by [broadcast, track, sequence]. Concurrent (or
|
|
83
86
|
// repeat, while still open) fetchGroup() calls for the same group share one FETCH stream and
|
|
84
87
|
// each get an independent mirror; the entry is evicted once the group closes.
|
|
@@ -94,48 +97,51 @@ export class Subscriber {
|
|
|
94
97
|
* Creates a new Subscriber instance.
|
|
95
98
|
* @param quic - The WebTransport session to use
|
|
96
99
|
* @param version - The protocol version
|
|
97
|
-
* @param origin -
|
|
100
|
+
* @param origin - Hop id shared with the Publisher
|
|
98
101
|
* @param probe - Optional sink for the peer's PROBE estimates
|
|
99
102
|
* @param peerSetup - Optional peer SETUP slot for capability gating (lite-05+)
|
|
100
103
|
*
|
|
101
104
|
* @internal
|
|
102
105
|
*/
|
|
103
|
-
constructor(quic, version,
|
|
106
|
+
constructor(quic, version, hop, probe, peerSetup) {
|
|
104
107
|
this.#quic = quic;
|
|
105
108
|
this.version = version;
|
|
106
|
-
this.
|
|
109
|
+
this.hop = hop;
|
|
110
|
+
this.#stamp = randomHop();
|
|
107
111
|
this.#probe = probe;
|
|
108
112
|
this.#peerSetup = peerSetup;
|
|
109
113
|
}
|
|
110
114
|
/**
|
|
111
|
-
* Subscribe to broadcast announcements
|
|
115
|
+
* Subscribe to broadcast announcements matching `scope`. Paths are relative
|
|
116
|
+
* to the session, not the scope.
|
|
117
|
+
*
|
|
118
|
+
* Reflected announces (those whose hop chain already includes this
|
|
119
|
+
* connection) are always dropped: moq-lite-06 has none to keep, and older
|
|
120
|
+
* versions stay consistent with that.
|
|
112
121
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
122
|
+
* Hidden routes (a `.`-prefixed segment below the scope's head) are left out unless
|
|
123
|
+
* `options.hidden` opts in. The opt-in rides the request on lite-07+; an older peer
|
|
124
|
+
* never hides anything, so the rule is also applied here.
|
|
115
125
|
*/
|
|
116
|
-
announced(
|
|
117
|
-
const announced = new announce.Producer(
|
|
118
|
-
|
|
126
|
+
announced(scope = Path.Pattern.all(), options) {
|
|
127
|
+
const announced = new announce.Producer();
|
|
128
|
+
// The wire speaks announce interest by prefix, and echoes suffixes beneath it.
|
|
129
|
+
void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false);
|
|
119
130
|
return announced.consume();
|
|
120
131
|
}
|
|
121
|
-
async #runAnnounced(announced, prefix,
|
|
132
|
+
async #runAnnounced(announced, prefix, scope, hidden) {
|
|
122
133
|
console.debug(`announced: prefix=${prefix}`);
|
|
123
|
-
// Lite04/05: send our own session-level
|
|
134
|
+
// Lite04/05: send our own session-level Hop ID so the peer can skip announces
|
|
124
135
|
// whose hop chain already passed through us. Encoding drops it on every other
|
|
125
136
|
// version, where we drop the reflected announce on receipt instead. Matches the
|
|
126
137
|
// Rust subscriber's `exclude_hop: self.self_origin.id` in `run_announce_prefix`.
|
|
127
|
-
const msg = new AnnounceRequest(prefix, this.
|
|
128
|
-
|
|
129
|
-
// don't re-see their own publishes. A caller can always ask for this, and it is
|
|
130
|
-
// required on versions that don't carry excludeHop above: there the peer isn't
|
|
131
|
-
// filtering them out for us, so filtering here keeps what the app sees the same
|
|
132
|
-
// as lite-05. Lite01-03 carry no real hop ids, so the check never matches there.
|
|
133
|
-
const dropReflected = options.ignoreSelf || !hasExcludeHop(this.version);
|
|
138
|
+
const msg = new AnnounceRequest(prefix, this.hop, hidden);
|
|
139
|
+
const visible = (path) => scopeOverlaps(scope, path) && (hidden || !hiddenBelow(prefix, path));
|
|
134
140
|
// Opened outside the try so the catch can reach it: a protocol violation below has
|
|
135
141
|
// to reset the stream, not just close our side of it.
|
|
136
142
|
let stream;
|
|
137
143
|
try {
|
|
138
|
-
stream = await Stream.open(this.#quic);
|
|
144
|
+
stream = await Stream.open(this.#quic, { version: this.version });
|
|
139
145
|
}
|
|
140
146
|
catch (err) {
|
|
141
147
|
announced.close(error(err));
|
|
@@ -145,17 +151,16 @@ export class Subscriber {
|
|
|
145
151
|
// Send the announce interest.
|
|
146
152
|
await stream.writer.u53(StreamId.Announce);
|
|
147
153
|
await msg.encode(stream.writer, this.version);
|
|
148
|
-
// Lite05+: the publisher reports its own
|
|
154
|
+
// Lite05+: the publisher reports its own Hop ID before any announces.
|
|
149
155
|
// It no longer stamps itself onto each hop chain, so we append it here to
|
|
150
|
-
// keep the
|
|
156
|
+
// keep the reflected-announce loop check seeing the full chain.
|
|
151
157
|
let responderOrigin;
|
|
152
158
|
if (hasAnnounceOk(this.version)) {
|
|
153
159
|
const ok = await AnnounceOk.decode(stream.reader, this.version);
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
responderOrigin = ok.origin === UNKNOWN_ORIGIN ? undefined : ok.origin;
|
|
160
|
+
// Keep a withheld 0: it names nobody for loop detection, but it is the
|
|
161
|
+
// anonymous mark and must travel the reconstructed chain. Assigned identities
|
|
162
|
+
// stay off this hop and are never forwarded.
|
|
163
|
+
responderOrigin = ok.hop;
|
|
159
164
|
}
|
|
160
165
|
const advertised = new Map();
|
|
161
166
|
switch (this.version) {
|
|
@@ -167,15 +172,20 @@ export class Subscriber {
|
|
|
167
172
|
// they go on record and obey the same one-per-path rule: the initial set
|
|
168
173
|
// naming a path twice is the same violation as two ANNOUNCE_STARTs for it,
|
|
169
174
|
// and the record is what catches either. Draft01/02 carry no hop ids and no
|
|
170
|
-
// ANNOUNCE_OK, so
|
|
175
|
+
// ANNOUNCE_OK, so this connection's stamp names the publisher.
|
|
171
176
|
for (const suffix of init.suffixes) {
|
|
172
177
|
const path = Path.join(prefix, suffix);
|
|
173
|
-
if (advertised.has(
|
|
178
|
+
if (advertised.has(path)) {
|
|
174
179
|
throw new ProtocolViolation(`duplicate announce for ${path}`);
|
|
175
180
|
}
|
|
176
|
-
|
|
181
|
+
const route = { hops: [this.#stamp, UNKNOWN_HOP], cost: Cost.zero };
|
|
182
|
+
const live = visible(path);
|
|
183
|
+
const captures = scopeCaptures(scope, path);
|
|
184
|
+
advertised.set(path, { publisher: this.#stamp, live, route, captures });
|
|
185
|
+
if (!live)
|
|
186
|
+
continue;
|
|
177
187
|
console.debug(`announced: broadcast=${path} active=true`);
|
|
178
|
-
announced.append({
|
|
188
|
+
announced.append({ prefix: path, captures, kind: "announced", route });
|
|
179
189
|
}
|
|
180
190
|
break;
|
|
181
191
|
}
|
|
@@ -184,13 +194,13 @@ export class Subscriber {
|
|
|
184
194
|
break;
|
|
185
195
|
}
|
|
186
196
|
// Lite06+: announce ids. Each received `active` implicitly assigns the next
|
|
187
|
-
// per-stream ordinal; `endedId`/`restart` reference it
|
|
188
|
-
// announces we skip
|
|
189
|
-
|
|
190
|
-
const
|
|
197
|
+
// per-stream ordinal; `endedId`/`restart` reference it, and lite-07 bases copy
|
|
198
|
+
// from it. Tracked even for announces we skip as reflected, since the sender
|
|
199
|
+
// doesn't know we skipped.
|
|
200
|
+
const history = new AnnounceHistory();
|
|
191
201
|
// Receive announce updates (for Draft03, this includes initial state)
|
|
192
202
|
for (;;) {
|
|
193
|
-
const announce = await
|
|
203
|
+
const announce = await race([
|
|
194
204
|
decodeAnnounceBroadcastMaybe(stream.reader, this.version),
|
|
195
205
|
announced.closed,
|
|
196
206
|
]);
|
|
@@ -199,45 +209,43 @@ export class Subscriber {
|
|
|
199
209
|
break;
|
|
200
210
|
if (announce instanceof Error)
|
|
201
211
|
throw announce;
|
|
202
|
-
let
|
|
212
|
+
let path;
|
|
203
213
|
let active;
|
|
204
214
|
// Present on active/restart; ended messages never carry hops worth checking.
|
|
205
215
|
let hops;
|
|
216
|
+
let cost;
|
|
206
217
|
switch (announce.status) {
|
|
207
|
-
case "active":
|
|
208
|
-
|
|
218
|
+
case "active": {
|
|
219
|
+
const resolved = hasAnnounceId(this.version) ? history.start(announce) : announce;
|
|
220
|
+
// The wire names the suffix beneath the interest prefix; the consumer
|
|
221
|
+
// sees the covered path from the session root.
|
|
222
|
+
path = Path.join(prefix, resolved.suffix);
|
|
209
223
|
active = true;
|
|
210
|
-
hops =
|
|
211
|
-
|
|
212
|
-
announcedById.set(nextAnnounceId++, announce.suffix);
|
|
213
|
-
}
|
|
224
|
+
hops = resolved.hops;
|
|
225
|
+
cost = announce.cost;
|
|
214
226
|
break;
|
|
227
|
+
}
|
|
215
228
|
case "ended":
|
|
216
|
-
|
|
229
|
+
path = Path.join(prefix, announce.suffix);
|
|
217
230
|
active = false;
|
|
218
231
|
break;
|
|
219
|
-
case "endedId":
|
|
232
|
+
case "endedId":
|
|
220
233
|
// Resolve and retire the id; an unknown or retired id is a protocol violation.
|
|
221
|
-
|
|
222
|
-
if (path === undefined)
|
|
223
|
-
throw new ProtocolViolation(`unknown announce id: ${announce.id}`);
|
|
224
|
-
announcedById.delete(announce.id);
|
|
225
|
-
suffix = path;
|
|
234
|
+
path = Path.join(prefix, history.end(announce.id));
|
|
226
235
|
active = false;
|
|
227
236
|
break;
|
|
228
|
-
}
|
|
229
237
|
case "restart": {
|
|
230
238
|
// Resolve the id; it stays live (the replacement reuses it).
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
throw new ProtocolViolation(`unknown announce id: ${announce.id}`);
|
|
234
|
-
suffix = path;
|
|
239
|
+
const resolved = history.update(announce);
|
|
240
|
+
path = Path.join(prefix, resolved.suffix);
|
|
235
241
|
active = true;
|
|
236
|
-
hops =
|
|
242
|
+
hops = resolved.hops;
|
|
243
|
+
cost = announce.cost;
|
|
237
244
|
break;
|
|
238
245
|
}
|
|
246
|
+
case "skipped":
|
|
247
|
+
continue;
|
|
239
248
|
}
|
|
240
|
-
const path = Path.join(prefix, suffix);
|
|
241
249
|
// One current advertisement per path per stream, decided before anything below
|
|
242
250
|
// can skip this announcement. A second ANNOUNCE_START for a path the peer
|
|
243
251
|
// already advertised is a violation whether or not its route would be usable
|
|
@@ -250,7 +258,7 @@ export class Subscriber {
|
|
|
250
258
|
// a duplicate means the same thing on both sides of it. Mirrors the branch the
|
|
251
259
|
// Rust announce loop takes before `start_announce`.
|
|
252
260
|
const duplicateIsRestart = restartSupported(this.version) && !hasAnnounceId(this.version);
|
|
253
|
-
if (announce.status === "active" && !duplicateIsRestart && advertised.has(
|
|
261
|
+
if (announce.status === "active" && !duplicateIsRestart && advertised.has(path)) {
|
|
254
262
|
throw new ProtocolViolation(`duplicate announce for ${path}`);
|
|
255
263
|
}
|
|
256
264
|
// Retract the path: forget the advertisement, drop the shared consume entry so a
|
|
@@ -258,63 +266,87 @@ export class Subscriber {
|
|
|
258
266
|
// and tell the consumer. A no-op for an advertisement never surfaced, which is
|
|
259
267
|
// what an id retiring a skipped announce resolves to.
|
|
260
268
|
const retract = () => {
|
|
261
|
-
const previous = advertised.get(
|
|
262
|
-
advertised.delete(
|
|
269
|
+
const previous = advertised.get(path);
|
|
270
|
+
advertised.delete(path);
|
|
263
271
|
if (!previous?.live)
|
|
264
272
|
return;
|
|
265
273
|
this.#consumes.evict(path);
|
|
266
274
|
console.debug(`announced: broadcast=${path} active=false`);
|
|
267
|
-
announced.append({
|
|
275
|
+
announced.append({
|
|
276
|
+
prefix: path,
|
|
277
|
+
captures: previous.captures,
|
|
278
|
+
kind: "retracted",
|
|
279
|
+
route: previous.route,
|
|
280
|
+
});
|
|
268
281
|
};
|
|
269
282
|
// In Lite05+ the sender's origin arrives via AnnounceOk, not in each hop
|
|
270
283
|
// list, so fold it back in before checking.
|
|
271
|
-
if (hops !== undefined
|
|
284
|
+
if (hops !== undefined) {
|
|
272
285
|
const full = responderOrigin !== undefined ? [...hops, responderOrigin] : hops;
|
|
273
|
-
if (full.includes(this.
|
|
286
|
+
if (full.includes(this.hop)) {
|
|
274
287
|
// A reflected restart means the peer's remaining route loops back through
|
|
275
288
|
// us, so the route is gone even though the message says active. The
|
|
276
289
|
// advertisement stays live: the peer still holds the path and its id still
|
|
277
290
|
// resolves here.
|
|
278
291
|
retract();
|
|
279
|
-
advertised.set(
|
|
292
|
+
advertised.set(path, {
|
|
293
|
+
publisher: undefined,
|
|
294
|
+
live: false,
|
|
295
|
+
route: { hops: full, cost: Cost.zero },
|
|
296
|
+
captures: undefined,
|
|
297
|
+
});
|
|
280
298
|
continue;
|
|
281
299
|
}
|
|
282
300
|
}
|
|
283
|
-
if (active) {
|
|
284
|
-
// The first hop identifies the original publisher; an empty chain means the
|
|
285
|
-
// peer itself originated it. See `restart_announce` in the Rust subscriber.
|
|
286
|
-
const publisher = hops?.[0] ?? responderOrigin;
|
|
287
|
-
// A publisher with no identity (an empty chain from a peer that withheld its
|
|
288
|
-
// own id, or a lite-03 UNKNOWN placeholder) never proves continuity: two such
|
|
289
|
-
// advertisements can be unrelated publishers. Mirrors the
|
|
290
|
-
// `publisher == Origin::UNKNOWN` arm of the Rust `restart_announce`.
|
|
291
|
-
const identified = publisher !== undefined && publisher !== UNKNOWN_ORIGIN;
|
|
292
|
-
// A second advertisement for a path we already carry is a restart: either an
|
|
293
|
-
// explicit ANNOUNCE_UPDATE, or (lite-05) a duplicate ANNOUNCE.
|
|
294
|
-
const previous = advertised.get(suffix);
|
|
295
|
-
if (previous?.live) {
|
|
296
|
-
if (identified && previous.publisher === publisher) {
|
|
297
|
-
// Same publisher, new route. In-flight subscriptions resume across it,
|
|
298
|
-
// so there is nothing for a consumer to react to. An unidentified
|
|
299
|
-
// publisher falls through to the replacement path below instead.
|
|
300
|
-
console.debug(`announced: broadcast=${path} rerouted`);
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
// A different publisher took the path, so cached track info and existing
|
|
304
|
-
// subscriptions must not carry over. Surface a real end before the start.
|
|
305
|
-
retract();
|
|
306
|
-
}
|
|
307
|
-
// After `retract()`, which clears the entry: the path is advertised again, by
|
|
308
|
-
// whoever just took it over. Recording it before would leave nothing behind, so
|
|
309
|
-
// the *next* takeover would read as a first announcement and skip its own end.
|
|
310
|
-
advertised.set(suffix, { publisher, live: true });
|
|
311
|
-
}
|
|
312
|
-
else {
|
|
301
|
+
if (!active) {
|
|
313
302
|
retract();
|
|
314
303
|
continue;
|
|
315
304
|
}
|
|
305
|
+
// The first hop identifies the original publisher; an empty chain means the
|
|
306
|
+
// peer itself originated it. One that names nobody (lite-01..03, or a peer
|
|
307
|
+
// reporting 0) gets this connection's stamp in front of its 0.
|
|
308
|
+
const fullHops = stampHops(hops !== undefined && responderOrigin !== undefined
|
|
309
|
+
? [...hops, responderOrigin]
|
|
310
|
+
: [...(hops ?? [])], this.#stamp);
|
|
311
|
+
// Appending a withheld AnnounceOk(0) onto a 32-entry list is the same
|
|
312
|
+
// drop Rust's Hops::push makes: do not expose an overlong chain.
|
|
313
|
+
if (fullHops === undefined || fullHops.length > MAX_HOPS) {
|
|
314
|
+
console.debug(`announced: broadcast=${path} dropped (hop chain at MAX_HOPS)`);
|
|
315
|
+
advertised.set(path, {
|
|
316
|
+
publisher: undefined,
|
|
317
|
+
live: false,
|
|
318
|
+
route: { hops: [], cost: Cost.zero },
|
|
319
|
+
captures: undefined,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const publisher = fullHops[0];
|
|
324
|
+
const route = { hops: fullHops, cost: cost ?? Cost.zero };
|
|
325
|
+
const captures = scopeCaptures(scope, path);
|
|
326
|
+
if (!visible(path)) {
|
|
327
|
+
advertised.set(path, { publisher, live: false, route, captures });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// A second advertisement for a path we already carry is a restart: either an
|
|
331
|
+
// explicit ANNOUNCE_UPDATE, or (lite-05) a duplicate ANNOUNCE. It updates the
|
|
332
|
+
// route in place, so a forwarder re-prices without retracting.
|
|
333
|
+
const previous = advertised.get(path);
|
|
334
|
+
if (previous?.live) {
|
|
335
|
+
// A different publisher took the path. Subscriptions already open drain
|
|
336
|
+
// the old copy, but the next consume starts fresh rather than reusing the
|
|
337
|
+
// old publisher's cached track info.
|
|
338
|
+
if (previous.publisher !== publisher)
|
|
339
|
+
this.#consumes.evict(path);
|
|
340
|
+
advertised.set(path, { publisher, live: true, route, captures });
|
|
341
|
+
console.debug(`announced: broadcast=${path} rerouted`);
|
|
342
|
+
if (!routesEqual(previous.route, route)) {
|
|
343
|
+
announced.append({ prefix: path, captures, kind: "updated", route });
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
advertised.set(path, { publisher, live: true, route, captures });
|
|
316
348
|
console.debug(`announced: broadcast=${path} active=true`);
|
|
317
|
-
announced.append({
|
|
349
|
+
announced.append({ prefix: path, captures, kind: "announced", route });
|
|
318
350
|
}
|
|
319
351
|
announced.close();
|
|
320
352
|
}
|
|
@@ -354,7 +386,7 @@ export class Subscriber {
|
|
|
354
386
|
const consumer = new ConsumeBroadcast(this, path);
|
|
355
387
|
void (async () => {
|
|
356
388
|
for (;;) {
|
|
357
|
-
const request = await consumer.requested();
|
|
389
|
+
const request = await wireOf(consumer).requested();
|
|
358
390
|
if (!request)
|
|
359
391
|
break;
|
|
360
392
|
void this.#runSubscribe(path, request);
|
|
@@ -365,19 +397,24 @@ export class Subscriber {
|
|
|
365
397
|
async #runSubscribe(broadcast, request) {
|
|
366
398
|
const id = this.#subscribeNext++;
|
|
367
399
|
const subscription = request.subscription;
|
|
400
|
+
const initialBounds = groupBounds(subscription.groups);
|
|
401
|
+
if (emptyRange({ startGroup: initialBounds.start, endGroup: initialBounds.end })) {
|
|
402
|
+
request.reject(new Error(EMPTY_RANGE));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
368
405
|
// `timescale` stays undefined until TRACK_INFO (or, on older drafts,
|
|
369
406
|
// implicit defaults) resolves it; runGroup blocks on it before decoding.
|
|
370
407
|
const timescale = new Signal(undefined);
|
|
371
408
|
console.debug(`subscribe start: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
409
|
+
const bounds = groupBounds(subscription.groups);
|
|
372
410
|
const msg = new Subscribe({
|
|
373
411
|
id,
|
|
374
412
|
broadcast,
|
|
375
413
|
track: request.name,
|
|
376
414
|
priority: subscription.priority ?? 0,
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
endGroup: subscription.endGroup,
|
|
415
|
+
maxAge: subscription.maxAge,
|
|
416
|
+
startGroup: subscription.groups?.start === undefined ? undefined : bounds.start,
|
|
417
|
+
endGroup: inclusiveGroupEnd(bounds.end),
|
|
381
418
|
});
|
|
382
419
|
// Open the stream under a timeout. The stream handle flows back via `state`
|
|
383
420
|
// so the timeout path can abort it if it finishes opening after the deadline.
|
|
@@ -389,7 +426,9 @@ export class Subscriber {
|
|
|
389
426
|
console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
390
427
|
}
|
|
391
428
|
catch (err) {
|
|
392
|
-
|
|
429
|
+
// The setup outlived its deadline waiting for the first response: a control
|
|
430
|
+
// timeout, not content that arrived late.
|
|
431
|
+
const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err);
|
|
393
432
|
request.reject(e);
|
|
394
433
|
this.#subscribes.delete(id);
|
|
395
434
|
console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
@@ -399,31 +438,40 @@ export class Subscriber {
|
|
|
399
438
|
setup.then(() => state.stream?.abort(e), () => state.stream?.abort(e));
|
|
400
439
|
return;
|
|
401
440
|
}
|
|
402
|
-
const { stream,
|
|
441
|
+
const { stream, entry } = opened;
|
|
442
|
+
const producer = entry.track;
|
|
403
443
|
try {
|
|
404
444
|
// Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02
|
|
405
445
|
// don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there
|
|
406
446
|
// and just wait on the stream/track like before.
|
|
407
447
|
//
|
|
408
|
-
// On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
448
|
+
// On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until
|
|
449
|
+
// its FIN; older drafts just close it. Either way group streams can still be in
|
|
450
|
+
// flight, so the track ends only once the tail is accounted for. A reset rejects
|
|
451
|
+
// instead, so the track ends with that error rather than a clean tail.
|
|
452
|
+
const responses = supportsTrackStream(this.version)
|
|
453
|
+
? this.#runResponses(stream, entry)
|
|
454
|
+
: stream.reader.closed;
|
|
455
|
+
const closed = responses.then(() => this.#settleTail(entry));
|
|
456
|
+
// A reset that lands after the race below settled is moot; the race observes one before.
|
|
457
|
+
closed.catch(() => { });
|
|
412
458
|
const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02
|
|
413
459
|
? undefined
|
|
414
|
-
: this.#runSubscriptionUpdates(id, broadcast,
|
|
460
|
+
: this.#runSubscriptionUpdates(id, broadcast, entry, msg, stream);
|
|
415
461
|
// Terminal conditions (stream end, track close, a failed subscription update) settle at most
|
|
416
462
|
// once; race them into one stable promise so the demand loop doesn't re-subscribe each pass.
|
|
463
|
+
// Updates stop quietly at the FIN, which can land before the responses ahead of it are
|
|
464
|
+
// decoded, so only their failure is terminal on its own.
|
|
417
465
|
const terminal = [closed, producer.closed];
|
|
418
466
|
if (subscriptionUpdates !== undefined)
|
|
419
|
-
terminal.push(subscriptionUpdates);
|
|
420
|
-
const done =
|
|
467
|
+
terminal.push(subscriptionUpdates.then(() => closed));
|
|
468
|
+
const done = race(terminal);
|
|
421
469
|
// Serve until a terminal condition fires or the last local subscriber leaves. The unused
|
|
422
470
|
// wake is level-triggered: re-check demand so a subscriber that returns before we tear
|
|
423
471
|
// down (e.g. a quickly unmuted tile) resumes on the same subscription.
|
|
424
472
|
const idle = Symbol("idle");
|
|
425
473
|
for (;;) {
|
|
426
|
-
const reason = await
|
|
474
|
+
const reason = await race([done, producer.unused().then(() => idle)]);
|
|
427
475
|
if (reason === idle && producer.closed.peek() === undefined && producer.used.peek())
|
|
428
476
|
continue;
|
|
429
477
|
break;
|
|
@@ -433,7 +481,7 @@ export class Subscriber {
|
|
|
433
481
|
console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`);
|
|
434
482
|
}
|
|
435
483
|
catch (err) {
|
|
436
|
-
const e =
|
|
484
|
+
const e = await sessionCause(this.#quic, err);
|
|
437
485
|
producer.close(e);
|
|
438
486
|
console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
439
487
|
stream.abort(e);
|
|
@@ -467,8 +515,22 @@ export class Subscriber {
|
|
|
467
515
|
drainOk = true;
|
|
468
516
|
}
|
|
469
517
|
// Register before opening SUBSCRIBE so a racing GROUP stream finds the entry.
|
|
470
|
-
|
|
471
|
-
|
|
518
|
+
const entry = {
|
|
519
|
+
track: producer,
|
|
520
|
+
timescale,
|
|
521
|
+
// The effective max age is the stopgap grace: the wrong clock (it bounds
|
|
522
|
+
// presentation-time drift), but it is how long the subscriber was willing to wait
|
|
523
|
+
// for a late group anyway. Already the smaller of the subscriber's and the track's.
|
|
524
|
+
tail: new Tail({
|
|
525
|
+
grace: () => {
|
|
526
|
+
const maxAge = producer.subscription.peek()?.maxAge ?? Time.Milli.zero;
|
|
527
|
+
return maxAge > 0 ? maxAge : TAIL_GRACE_MS;
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
requested: msg.startGroup,
|
|
531
|
+
};
|
|
532
|
+
this.#subscribes.set(id, entry);
|
|
533
|
+
state.stream = await Stream.open(this.#quic, { version: this.version });
|
|
472
534
|
await state.stream.writer.u53(StreamId.Subscribe);
|
|
473
535
|
await msg.encode(state.stream.writer, this.version);
|
|
474
536
|
if (drainOk) {
|
|
@@ -478,37 +540,52 @@ export class Subscriber {
|
|
|
478
540
|
throw new Error("first subscribe response must be SUBSCRIBE_OK");
|
|
479
541
|
}
|
|
480
542
|
}
|
|
481
|
-
return { stream: state.stream,
|
|
543
|
+
return { stream: state.stream, entry };
|
|
482
544
|
}
|
|
483
545
|
// Opens a TRACK stream, reads the single TRACK_INFO, and FINs. Lite-05+ only.
|
|
484
546
|
async #trackInfo(broadcast, track) {
|
|
485
|
-
|
|
486
|
-
try {
|
|
547
|
+
return this.#exchange({ version: this.version }, async (stream) => {
|
|
487
548
|
await stream.writer.u53(StreamId.Track);
|
|
488
549
|
await new TrackMessage(broadcast, track).encode(stream.writer, this.version);
|
|
489
550
|
const info = await TrackInfo.decode(stream.reader, this.version);
|
|
490
551
|
// The publisher FINs after TRACK_INFO; FIN our side too.
|
|
491
552
|
stream.close();
|
|
492
553
|
return info;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
// Opens a stream and runs a request/response exchange on it, resetting the stream if `run`
|
|
557
|
+
// fails. Subscriber.close() also resets it while `run` is pending, so a peer that never
|
|
558
|
+
// answers cannot hold it open, and a stream that opens after the close is reset at once.
|
|
559
|
+
async #exchange(options, run) {
|
|
560
|
+
const closed = this.#closed.signal;
|
|
561
|
+
closed.throwIfAborted();
|
|
562
|
+
const stream = await Stream.open(this.#quic, options);
|
|
563
|
+
const abort = () => stream.abort(error(closed.reason));
|
|
564
|
+
closed.addEventListener("abort", abort);
|
|
565
|
+
try {
|
|
566
|
+
closed.throwIfAborted();
|
|
567
|
+
return await run(stream);
|
|
493
568
|
}
|
|
494
569
|
catch (err) {
|
|
495
570
|
stream.abort(error(err));
|
|
496
571
|
throw err;
|
|
497
572
|
}
|
|
573
|
+
finally {
|
|
574
|
+
closed.removeEventListener("abort", abort);
|
|
575
|
+
}
|
|
498
576
|
}
|
|
499
577
|
// Map the wire TRACK_INFO onto the model track.Info a producer/consumer holds.
|
|
500
578
|
#toModelInfo(info) {
|
|
501
579
|
return {
|
|
502
580
|
timescale: Time.Timescale(info.timescale),
|
|
503
|
-
// Publisher Max
|
|
581
|
+
// Publisher Max Age rides on the wire, so the local retention window
|
|
504
582
|
// matches what the upstream advertises (relays re-serve with the same bound).
|
|
505
|
-
|
|
583
|
+
maxAge: Time.Milli(info.maxAge),
|
|
506
584
|
priority: info.priority,
|
|
507
|
-
ordered: info.ordered,
|
|
508
585
|
};
|
|
509
586
|
}
|
|
510
587
|
// Resolve a track's immutable model info via a TRACK stream (lite-05+), for the
|
|
511
|
-
// ConsumeBroadcast backing track.Consumer.
|
|
588
|
+
// ConsumeBroadcast backing track.Consumer.query(). On older drafts there's no TRACK
|
|
512
589
|
// stream, so this rejects rather than fabricating defaults.
|
|
513
590
|
async resolveTrackInfo(broadcast, track) {
|
|
514
591
|
if (!supportsTrackStream(this.version)) {
|
|
@@ -518,83 +595,115 @@ export class Subscriber {
|
|
|
518
595
|
}
|
|
519
596
|
// Open a FETCH stream for one group and stream its bare frames into a group, for the
|
|
520
597
|
// ConsumeBroadcast backing track.Consumer.fetchGroup() (lite-05+).
|
|
521
|
-
fetchGroup(broadcast, track, sequence, options = {}) {
|
|
598
|
+
async fetchGroup(broadcast, track, sequence, options = {}) {
|
|
599
|
+
options.signal?.throwIfAborted();
|
|
522
600
|
// Coalesce onto a still-open fetch of the same group so we don't open a second FETCH
|
|
523
601
|
// stream (and re-download it); each caller reads an independent mirror.
|
|
602
|
+
//
|
|
603
|
+
// Reserve each caller's mirror before the fetch starts or is awaited: the fetch watches
|
|
604
|
+
// demand from the start, and a fast FIN cannot discard frames before these callers
|
|
605
|
+
// receive their handles. An abort closes only this caller's mirror, so the stream is
|
|
606
|
+
// cancelled once the last one leaves.
|
|
524
607
|
const key = JSON.stringify([broadcast, track, sequence]);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
608
|
+
let entry = this.#fetches.get(key);
|
|
609
|
+
let consumer;
|
|
610
|
+
if (entry && !entry.group.isClosed) {
|
|
611
|
+
consumer = entry.group.mirror();
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
const group = new netGroup.Producer(sequence);
|
|
615
|
+
consumer = group.mirror();
|
|
616
|
+
entry = { group, accepted: this.#runFetch(broadcast, track, sequence, options.priority ?? 0, group) };
|
|
617
|
+
this.#fetches.set(key, entry);
|
|
618
|
+
void group.closed.then(() => {
|
|
619
|
+
if (this.#fetches.get(key)?.group === group)
|
|
620
|
+
this.#fetches.delete(key);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
try {
|
|
624
|
+
await untilAborted(entry.accepted, options.signal);
|
|
625
|
+
return consumer;
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
consumer.close();
|
|
629
|
+
throw err;
|
|
630
|
+
}
|
|
537
631
|
}
|
|
538
632
|
// Open the FETCH stream and pump the response into the shared group. Setup errors close the
|
|
539
|
-
// group
|
|
540
|
-
|
|
633
|
+
// group, evict the entry, and reject every caller waiting for acceptance. A setup every caller
|
|
634
|
+
// has abandoned is cancelled the same way.
|
|
635
|
+
async #runFetch(broadcast, track, sequence, priority, group) {
|
|
541
636
|
try {
|
|
542
637
|
if (!supportsTrackStream(this.version)) {
|
|
543
638
|
throw new Error("fetch group requires moq-lite-05 or newer");
|
|
544
639
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
640
|
+
// Lite has no FETCH_OK, so a publisher that never answers would hold the setup forever.
|
|
641
|
+
// Subscriber.close() closing the group releases every caller at any stage, and resets
|
|
642
|
+
// the streams the setup opened.
|
|
643
|
+
const setup = this.#fetchSetup(broadcast, track, sequence, priority, group);
|
|
644
|
+
let accepted;
|
|
548
645
|
try {
|
|
549
|
-
await
|
|
550
|
-
await new FetchMessage(broadcast, track, priority, sequence).encode(stream.writer, this.version);
|
|
646
|
+
accepted = await untilAbandoned(group, setup);
|
|
551
647
|
}
|
|
552
648
|
catch (err) {
|
|
553
|
-
stream.
|
|
649
|
+
// A setup that finishes just after the close hands back a stream nobody will read.
|
|
650
|
+
void setup.then(({ stream }) => stream.abort(error(err)), () => void 0);
|
|
554
651
|
throw err;
|
|
555
652
|
}
|
|
556
|
-
|
|
557
|
-
// pump begins watching it (an abandoned fetch cancels once every reader has left).
|
|
558
|
-
const consumer = group.mirror();
|
|
559
|
-
void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale));
|
|
560
|
-
return consumer;
|
|
653
|
+
void this.#runFetchResponse(accepted.stream, group, Time.Timescale(accepted.info.timescale));
|
|
561
654
|
}
|
|
562
655
|
catch (err) {
|
|
563
656
|
group.close(error(err));
|
|
564
657
|
throw err;
|
|
565
658
|
}
|
|
566
659
|
}
|
|
660
|
+
// Resolve the track's timescale, then open the FETCH stream and wait for it to be accepted.
|
|
661
|
+
// Closing the group during that wait resets the stream.
|
|
662
|
+
async #fetchSetup(broadcast, track, sequence, priority, group) {
|
|
663
|
+
const info = await untilClosed(group, this.#trackInfo(broadcast, track));
|
|
664
|
+
return this.#exchange({ sendOrder: sendOrder({ priority }), version: this.version }, async (stream) => {
|
|
665
|
+
await stream.writer.u53(StreamId.Fetch);
|
|
666
|
+
await new FetchMessage({ broadcast, track, priority, group: sequence }).encode(stream.writer, this.version);
|
|
667
|
+
// A byte or an empty-group FIN accepts the fetch; a reset rejects it.
|
|
668
|
+
// done() buffers that byte so the response pump can decode it normally.
|
|
669
|
+
await untilClosed(group, stream.reader.done());
|
|
670
|
+
return { stream, info };
|
|
671
|
+
});
|
|
672
|
+
}
|
|
567
673
|
// Read the FETCH response (bare zigzag-delta-timestamped frames) into the group, then
|
|
568
674
|
// FIN. A stream-level failure aborts the group so its reader observes the gap.
|
|
569
675
|
async #runFetchResponse(stream, group, timescale) {
|
|
570
676
|
try {
|
|
571
|
-
|
|
677
|
+
const decode = frameDecoder(timescale);
|
|
572
678
|
// Serve until the stream FINs, the group closes, or every reader leaves. A group can
|
|
573
679
|
// stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by
|
|
574
|
-
// demand, not by the stream ending. `
|
|
575
|
-
//
|
|
576
|
-
//
|
|
680
|
+
// demand, not by the stream ending. `unused` is watched across frames as one stable
|
|
681
|
+
// promise; the check is level-triggered, so a coalesced fetch that arrives before we
|
|
682
|
+
// cancel re-arms and resumes.
|
|
577
683
|
const idle = Symbol("idle");
|
|
578
|
-
const closed = Promise.resolve(group.closed);
|
|
579
684
|
let unused = group.unused().then(() => idle);
|
|
685
|
+
// A decode consumes its frame whenever it lands, so one outstanding across a re-arm is
|
|
686
|
+
// kept and awaited again rather than abandoned with its frame.
|
|
687
|
+
let pending;
|
|
580
688
|
for (;;) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
689
|
+
// Buffered frames are written without an await, as in a group stream.
|
|
690
|
+
let frame = pending === undefined ? stream.reader.tryDecode(decode) : undefined;
|
|
691
|
+
if (!frame) {
|
|
692
|
+
pending ??= stream.reader.decodeMaybe(decode);
|
|
693
|
+
const next = await race([pending, group.closed, unused]);
|
|
694
|
+
if (next === idle) {
|
|
695
|
+
if (!group.isClosed && group.used.peek()) {
|
|
696
|
+
unused = group.unused().then(() => idle);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
break;
|
|
586
700
|
}
|
|
587
|
-
|
|
701
|
+
pending = undefined;
|
|
702
|
+
if (!next || next instanceof Error)
|
|
703
|
+
break;
|
|
704
|
+
frame = next;
|
|
588
705
|
}
|
|
589
|
-
|
|
590
|
-
break;
|
|
591
|
-
prevTs += unzigzag(await stream.reader.u62());
|
|
592
|
-
const timestamp = new Time.Timestamp(Number(prevTs), timescale);
|
|
593
|
-
const size = await stream.reader.u53();
|
|
594
|
-
const payload = await stream.reader.read(size);
|
|
595
|
-
if (!payload)
|
|
596
|
-
break;
|
|
597
|
-
group.writeFrame({ payload, timestamp });
|
|
706
|
+
group.writeFrame(frame);
|
|
598
707
|
}
|
|
599
708
|
group.close();
|
|
600
709
|
stream.close();
|
|
@@ -605,57 +714,128 @@ export class Subscriber {
|
|
|
605
714
|
stream.abort(e);
|
|
606
715
|
}
|
|
607
716
|
}
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
async #
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (
|
|
617
|
-
|
|
717
|
+
// Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording
|
|
718
|
+
// the range the tail is accounted against. SUBSCRIBE_END declares the track's end right
|
|
719
|
+
// away, so a consumer learns it before the last groups arrive. The publisher must declare
|
|
720
|
+
// the end before FIN; resets and malformed responses reject with their failure.
|
|
721
|
+
async #runResponses(stream, entry) {
|
|
722
|
+
for (;;) {
|
|
723
|
+
const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version);
|
|
724
|
+
if (!resp) {
|
|
725
|
+
if (entry.end === undefined)
|
|
726
|
+
throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if ("start" in resp) {
|
|
730
|
+
entry.start = resp.start.group;
|
|
731
|
+
// The groups the SUBSCRIBE asked for below it are unavailable, whatever the
|
|
732
|
+
// demand asks later.
|
|
733
|
+
if (entry.requested !== undefined)
|
|
734
|
+
entry.tail.account(entry.requested, entry.start);
|
|
735
|
+
}
|
|
736
|
+
else if ("end" in resp) {
|
|
737
|
+
if (entry.end !== undefined)
|
|
738
|
+
throw new ProtocolViolation("duplicate SUBSCRIBE_END");
|
|
739
|
+
entry.end = resp.end.group;
|
|
740
|
+
if (hasStreamCount(this.version))
|
|
741
|
+
entry.streams = resp.end.streams;
|
|
742
|
+
// A local close can win the race with the response; there is nothing left to end.
|
|
743
|
+
if (entry.track.closed.peek() !== undefined)
|
|
744
|
+
continue;
|
|
745
|
+
try {
|
|
746
|
+
entry.track.finishAt(entry.end);
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
// lite-05 specified an inclusive end, and @moq/net 0.1.3 to 0.1.9 sent one, so
|
|
750
|
+
// there an end below a received group only costs the early boundary: the FIN
|
|
751
|
+
// still finishes the track. Later drafts made it exclusive.
|
|
752
|
+
if (this.version !== Version.DRAFT_05) {
|
|
753
|
+
throw new ProtocolViolation(`invalid SUBSCRIBE_END: ${reason(error(err))}`);
|
|
754
|
+
}
|
|
755
|
+
console.warn(`invalid SUBSCRIBE_END: ${reason(error(err))}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
else if ("drop" in resp) {
|
|
759
|
+
entry.tail.account(resp.drop.start, resp.drop.end + 1);
|
|
618
760
|
}
|
|
619
|
-
}
|
|
620
|
-
catch {
|
|
621
|
-
// Stream closed or reset; nothing more to drain.
|
|
622
761
|
}
|
|
623
762
|
}
|
|
763
|
+
// Wait for the group streams the publisher still owes once it has ended the subscription.
|
|
764
|
+
//
|
|
765
|
+
// lite-07 counts streams, so skipped sequences owe nothing. Older drafts account for
|
|
766
|
+
// the range using received headers and SUBSCRIBE_DROP. A counted stream reset before
|
|
767
|
+
// its header leaves no trace, so the grace still bounds that wait. Streams whose
|
|
768
|
+
// headers arrived keep reading until their own FIN or reset.
|
|
769
|
+
#settleTail(entry) {
|
|
770
|
+
const { tail, track } = entry;
|
|
771
|
+
const complete = () => {
|
|
772
|
+
if (entry.streams !== undefined)
|
|
773
|
+
return tail.streams >= entry.streams;
|
|
774
|
+
// Without SUBSCRIBE_END (older drafts) nothing says which groups are owed.
|
|
775
|
+
if (entry.end === undefined)
|
|
776
|
+
return false;
|
|
777
|
+
// Without SUBSCRIBE_START the publisher served no group at all.
|
|
778
|
+
if (entry.start === undefined)
|
|
779
|
+
return true;
|
|
780
|
+
// Owed from the floor the demand last asked for, which an update can move either
|
|
781
|
+
// way, or where SUBSCRIBE_START resolved a live-edge one. The groups the SUBSCRIBE
|
|
782
|
+
// asked for below its SUBSCRIBE_START were accounted for when it arrived.
|
|
783
|
+
const groups = track.subscription.peek()?.groups;
|
|
784
|
+
const bounds = groupBounds(groups ?? {});
|
|
785
|
+
const start = groups?.start === undefined ? entry.start : bounds.start;
|
|
786
|
+
const end = bounds.end === undefined ? entry.end : Math.min(entry.end, bounds.end);
|
|
787
|
+
return tail.covers(start, end);
|
|
788
|
+
};
|
|
789
|
+
return tail.settle(complete, track.closed);
|
|
790
|
+
}
|
|
624
791
|
/**
|
|
625
792
|
* Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes.
|
|
626
793
|
*
|
|
627
794
|
* Resolves cleanly when the stream or track closes, so the caller can include
|
|
628
|
-
* this in
|
|
795
|
+
* this in a race without leaving a dangling pending write that would
|
|
629
796
|
* become an unhandled rejection if the user calls update after close.
|
|
630
797
|
*
|
|
631
798
|
* Peeks the signal at the top of every iteration so that updates which landed
|
|
632
799
|
* before SubscribeOk arrived (or between iterations, before .next() registered
|
|
633
800
|
* its listener) aren't lost.
|
|
634
801
|
*/
|
|
635
|
-
async #runSubscriptionUpdates(id, broadcast,
|
|
636
|
-
const
|
|
802
|
+
async #runSubscriptionUpdates(id, broadcast, entry, msg, stream) {
|
|
803
|
+
const track = entry.track;
|
|
804
|
+
const stopped = race([track.closed, stream.reader.closed]).then(() => null);
|
|
637
805
|
let lastSent = {
|
|
638
806
|
priority: msg.priority,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
807
|
+
maxAge: Time.Milli(msg.maxAge),
|
|
808
|
+
groups: {
|
|
809
|
+
start: msg.startGroup === undefined ? undefined : { included: msg.startGroup },
|
|
810
|
+
end: msg.endGroup === undefined ? undefined : { excluded: exclusiveGroupEnd(msg.endGroup) ?? 0 },
|
|
811
|
+
},
|
|
643
812
|
};
|
|
644
813
|
for (;;) {
|
|
645
814
|
const current = track.subscription.peek();
|
|
646
815
|
if (current === undefined || this.#sameSubscription(current, lastSent)) {
|
|
647
816
|
// Nothing new to send; wait for a change or termination.
|
|
648
|
-
const next = await
|
|
817
|
+
const next = await race([track.subscription.changed(), stopped]);
|
|
649
818
|
if (next === null)
|
|
650
819
|
return;
|
|
651
820
|
continue;
|
|
652
821
|
}
|
|
822
|
+
// Demand collapsing to nothing is refused the same way an initial empty
|
|
823
|
+
// request is: the error closes the track, so every local subscriber sees it.
|
|
824
|
+
const bounds = groupBounds(current.groups);
|
|
825
|
+
if (emptyRange({ startGroup: bounds.start, endGroup: bounds.end }))
|
|
826
|
+
throw new Error(EMPTY_RANGE);
|
|
827
|
+
// A lowered floor owes groups nobody asked for until now.
|
|
828
|
+
if (current.groups?.start !== undefined) {
|
|
829
|
+
const floor = lastSent.groups?.start === undefined ? entry.start : groupBounds(lastSent.groups).start;
|
|
830
|
+
entry.tail.demand(bounds.start, floor ?? Number.POSITIVE_INFINITY);
|
|
831
|
+
}
|
|
832
|
+
// Round-trip the other Subscribe parameters so the publisher doesn't
|
|
833
|
+
// interpret SUBSCRIBE_UPDATE as a reset of ordered/maxAge/etc.
|
|
653
834
|
const update = new SubscribeUpdate({
|
|
654
835
|
priority: current.priority ?? 0,
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
endGroup: current.endGroup,
|
|
836
|
+
maxAge: current.maxAge,
|
|
837
|
+
startGroup: current.groups?.start === undefined ? undefined : bounds.start,
|
|
838
|
+
endGroup: inclusiveGroupEnd(bounds.end),
|
|
659
839
|
});
|
|
660
840
|
await update.encode(stream.writer, this.version);
|
|
661
841
|
lastSent = { ...current };
|
|
@@ -663,11 +843,12 @@ export class Subscriber {
|
|
|
663
843
|
}
|
|
664
844
|
}
|
|
665
845
|
#sameSubscription(a, b) {
|
|
846
|
+
const ag = groupBounds(a.groups);
|
|
847
|
+
const bg = groupBounds(b.groups);
|
|
666
848
|
return ((a.priority ?? 0) === (b.priority ?? 0) &&
|
|
667
|
-
(a.
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
a.endGroup === b.endGroup);
|
|
849
|
+
(a.maxAge ?? 0) === (b.maxAge ?? 0) &&
|
|
850
|
+
ag.start === bg.start &&
|
|
851
|
+
ag.end === bg.end);
|
|
671
852
|
}
|
|
672
853
|
/**
|
|
673
854
|
* Handles a group message.
|
|
@@ -684,10 +865,19 @@ export class Subscriber {
|
|
|
684
865
|
}
|
|
685
866
|
return;
|
|
686
867
|
}
|
|
687
|
-
const { track, timescale } = entry;
|
|
868
|
+
const { track, timescale, tail } = entry;
|
|
688
869
|
const producer = new netGroup.Producer(group.sequence);
|
|
689
|
-
|
|
870
|
+
const read = tail.open(group.sequence);
|
|
690
871
|
try {
|
|
872
|
+
// The publisher contradicted its own end, which no later group can repair. lite-05
|
|
873
|
+
// specified an inclusive end, so its last group lands on it: the write below drops
|
|
874
|
+
// only that group there.
|
|
875
|
+
if (entry.end !== undefined && group.sequence >= entry.end && this.version !== Version.DRAFT_05) {
|
|
876
|
+
const violation = new ProtocolViolation(`group ${group.sequence} is at or past the declared end ${entry.end}`);
|
|
877
|
+
track.close(violation);
|
|
878
|
+
throw violation;
|
|
879
|
+
}
|
|
880
|
+
track.writeGroup(producer);
|
|
691
881
|
// Block until the timescale is known; the group's stream can arrive before
|
|
692
882
|
// TRACK_INFO (or implicit defaults) resolves it on the subscribe stream.
|
|
693
883
|
let scale = timescale.peek();
|
|
@@ -695,42 +885,24 @@ export class Subscriber {
|
|
|
695
885
|
if (track.closed.peek() !== undefined) {
|
|
696
886
|
// Subscription ended before the scale resolved; nothing to decode.
|
|
697
887
|
producer.close();
|
|
698
|
-
stream.stop(new
|
|
888
|
+
stream.stop(new StreamError(StreamCode.Cancel, { message: "cancel" }));
|
|
699
889
|
return;
|
|
700
890
|
}
|
|
701
891
|
await Signal.race(timescale, track.closed);
|
|
702
892
|
scale = timescale.peek();
|
|
703
893
|
}
|
|
704
|
-
|
|
705
|
-
// (the lite-05 FRAME format), which we decode into a Timestamp at that scale.
|
|
706
|
-
// Scale 0 (pre-lite-05) carries no timestamp, so we wall-clock-stamp.
|
|
707
|
-
let prevTs = 0n;
|
|
708
|
-
for (;;) {
|
|
709
|
-
const done = await Promise.race([stream.done(), track.closed, producer.closed]);
|
|
710
|
-
if (done !== false)
|
|
711
|
-
break;
|
|
712
|
-
let timestamp;
|
|
713
|
-
if (scale !== 0) {
|
|
714
|
-
prevTs += unzigzag(await stream.u62());
|
|
715
|
-
timestamp = new Time.Timestamp(Number(prevTs), Time.Timescale(scale));
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
timestamp = Time.Timestamp.now();
|
|
719
|
-
}
|
|
720
|
-
const size = await stream.u53();
|
|
721
|
-
const payload = await stream.read(size);
|
|
722
|
-
if (!payload)
|
|
723
|
-
break;
|
|
724
|
-
producer.writeFrame({ payload, timestamp });
|
|
725
|
-
}
|
|
894
|
+
await readFrames(stream, producer, scale);
|
|
726
895
|
producer.close();
|
|
727
|
-
stream.stop(new
|
|
896
|
+
stream.stop(new StreamError(StreamCode.Cancel, { message: "cancel" }));
|
|
728
897
|
}
|
|
729
898
|
catch (err) {
|
|
730
899
|
const e = error(err);
|
|
731
900
|
producer.close(e);
|
|
732
901
|
stream.stop(e);
|
|
733
902
|
}
|
|
903
|
+
finally {
|
|
904
|
+
read();
|
|
905
|
+
}
|
|
734
906
|
}
|
|
735
907
|
/**
|
|
736
908
|
* Receives QUIC datagrams and routes each to its subscription's track producer (lite-05 §6.4).
|
|
@@ -783,7 +955,7 @@ export class Subscriber {
|
|
|
783
955
|
// Decode one datagram body and hand it to the matching subscription's producer. Drops the
|
|
784
956
|
// datagram (best-effort) if the subscription is unknown/closed or its timescale isn't resolved.
|
|
785
957
|
async #routeDatagram(payload) {
|
|
786
|
-
const dg = await DatagramMessage.decode(payload);
|
|
958
|
+
const dg = await DatagramMessage.decode(payload, this.version);
|
|
787
959
|
const entry = this.#subscribes.get(dg.subscribe);
|
|
788
960
|
if (!entry)
|
|
789
961
|
return; // Unknown or already-closed subscription.
|
|
@@ -793,7 +965,9 @@ export class Subscriber {
|
|
|
793
965
|
if (!scale)
|
|
794
966
|
return;
|
|
795
967
|
const timestamp = new Time.Timestamp(dg.timestamp, Time.Timescale(scale));
|
|
796
|
-
|
|
968
|
+
// A datagram's sequence is never owed a stream, so it never holds the tail open.
|
|
969
|
+
entry.tail.account(dg.sequence, dg.sequence + 1);
|
|
970
|
+
entry.track.insertDatagram(dg.sequence, timestamp, dg.payload);
|
|
797
971
|
}
|
|
798
972
|
/**
|
|
799
973
|
* Opens a PROBE bidi stream to receive bandwidth estimates from the publisher.
|
|
@@ -832,7 +1006,7 @@ export class Subscriber {
|
|
|
832
1006
|
// transport hiccup) MUST NOT tear down the connection. On error, drop the
|
|
833
1007
|
// estimates so consumers know they're stale.
|
|
834
1008
|
try {
|
|
835
|
-
const stream = await Stream.open(this.#quic);
|
|
1009
|
+
const stream = await Stream.open(this.#quic, { version: this.version });
|
|
836
1010
|
await stream.writer.u53(StreamId.Probe);
|
|
837
1011
|
for (;;) {
|
|
838
1012
|
const probe = await Probe.decodeMaybe(stream.reader, this.version);
|
|
@@ -861,16 +1035,48 @@ export class Subscriber {
|
|
|
861
1035
|
this.#probe.set({});
|
|
862
1036
|
}
|
|
863
1037
|
}
|
|
864
|
-
|
|
865
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* Ends every subscribed track: cleanly for a deliberate close, or with `err` when the
|
|
1040
|
+
* session died, since those tracks were cut off rather than ended.
|
|
1041
|
+
*/
|
|
1042
|
+
close(err) {
|
|
1043
|
+
// A fetch or setup exchange cut off by the session is incomplete even on a deliberate
|
|
1044
|
+
// close, so it always ends with an error.
|
|
1045
|
+
const cut = err ?? new StreamError(StreamCode.SessionClosed, { message: "session closed" });
|
|
1046
|
+
this.#closed.abort(cut);
|
|
866
1047
|
for (const { track } of this.#subscribes.values()) {
|
|
867
|
-
track.close();
|
|
1048
|
+
track.close(err);
|
|
868
1049
|
}
|
|
869
1050
|
this.#subscribes.clear();
|
|
1051
|
+
// This also releases callers still awaiting acceptance.
|
|
1052
|
+
for (const { group } of this.#fetches.values()) {
|
|
1053
|
+
group.close(cut);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
// Settles with `step`, or rejects with the group's error once it closes first. A publisher
|
|
1058
|
+
// may never answer a FETCH, so Subscriber.close() closing the group is what releases it.
|
|
1059
|
+
async function untilClosed(group, step) {
|
|
1060
|
+
const value = await race([step, group.closed]);
|
|
1061
|
+
const closed = group.closed.peek();
|
|
1062
|
+
if (closed !== undefined)
|
|
1063
|
+
throw closed ?? new Error("fetch closed before it was accepted");
|
|
1064
|
+
return value;
|
|
1065
|
+
}
|
|
1066
|
+
// Like untilClosed, but also cancels once every reader has left. Demand is level-triggered, so a
|
|
1067
|
+
// caller that coalesces onto the group before the check re-arms it.
|
|
1068
|
+
async function untilAbandoned(group, step) {
|
|
1069
|
+
const idle = Symbol("idle");
|
|
1070
|
+
for (;;) {
|
|
1071
|
+
const value = await untilClosed(group, race([step, group.unused().then(() => idle)]));
|
|
1072
|
+
if (value !== idle)
|
|
1073
|
+
return value;
|
|
1074
|
+
if (!group.used.peek())
|
|
1075
|
+
throw new StreamError(StreamCode.Cancel, { message: "cancel" });
|
|
870
1076
|
}
|
|
871
1077
|
}
|
|
872
1078
|
/**
|
|
873
|
-
* A broadcast consumed from a lite session. It resolves `track.Consumer.
|
|
1079
|
+
* A broadcast consumed from a lite session. It resolves `track.Consumer.query()` and
|
|
874
1080
|
* `.fetchGroup()` over the wire (lite-05+ TRACK / FETCH streams) by reaching into the
|
|
875
1081
|
* {@link Subscriber} it was opened from, the way the Rust `BroadcastConsumer` holds its
|
|
876
1082
|
* session. Live subscribes still flow through the inherited requested() queue.
|
|
@@ -880,6 +1086,10 @@ class ConsumeBroadcast extends broadcast.Consumer {
|
|
|
880
1086
|
#path;
|
|
881
1087
|
constructor(subscriber, path, state) {
|
|
882
1088
|
super(state);
|
|
1089
|
+
overrideBroadcastWire(this, {
|
|
1090
|
+
resolveTrackInfo: (name) => subscriber.resolveTrackInfo(path, name),
|
|
1091
|
+
fetchGroup: (name, sequence, options) => subscriber.fetchGroup(path, name, sequence, options),
|
|
1092
|
+
});
|
|
883
1093
|
this.#subscriber = subscriber;
|
|
884
1094
|
this.#path = path;
|
|
885
1095
|
}
|
|
@@ -888,11 +1098,5 @@ class ConsumeBroadcast extends broadcast.Consumer {
|
|
|
888
1098
|
clone() {
|
|
889
1099
|
return new ConsumeBroadcast(this.#subscriber, this.#path, this.shareState());
|
|
890
1100
|
}
|
|
891
|
-
resolveTrackInfo(name) {
|
|
892
|
-
return this.#subscriber.resolveTrackInfo(this.#path, name);
|
|
893
|
-
}
|
|
894
|
-
fetchGroup(name, sequence, options) {
|
|
895
|
-
return this.#subscriber.fetchGroup(this.#path, name, sequence, options);
|
|
896
|
-
}
|
|
897
1101
|
}
|
|
898
1102
|
//# sourceMappingURL=subscriber.js.map
|