@norskvideo/moq-net 0.1.2 → 0.1.4
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 +1 -0
- package/announced.d.ts +111 -21
- package/announced.d.ts.map +1 -1
- package/announced.js +213 -40
- package/announced.js.map +1 -1
- package/broadcast.d.ts +80 -25
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +270 -49
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +6 -0
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +42 -13
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts +5 -0
- package/connection/browser.d.ts.map +1 -0
- package/connection/browser.js +31 -0
- package/connection/browser.js.map +1 -0
- package/connection/connect.d.ts +43 -1
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +118 -123
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +43 -16
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/handshake.d.ts +12 -1
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +30 -14
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +8 -0
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +6 -0
- package/connection/index.js.map +1 -1
- package/connection/reload.d.ts +66 -9
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +192 -66
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +92 -0
- package/connection/stats.d.ts.map +1 -0
- package/connection/stats.js +35 -0
- package/connection/stats.js.map +1 -0
- package/connection/transport.d.ts +10 -0
- package/connection/transport.d.ts.map +1 -0
- package/connection/transport.js +12 -0
- package/connection/transport.js.map +1 -0
- package/consume.d.ts +44 -0
- package/consume.d.ts.map +1 -0
- package/consume.js +60 -0
- package/consume.js.map +1 -0
- package/datagram.d.ts +23 -0
- package/datagram.d.ts.map +1 -0
- package/datagram.js +3 -0
- package/datagram.js.map +1 -0
- package/error.d.ts +76 -0
- package/error.d.ts.map +1 -0
- package/error.js +107 -0
- package/error.js.map +1 -0
- package/group.d.ts +143 -38
- package/group.d.ts.map +1 -1
- package/group.js +270 -68
- package/group.js.map +1 -1
- package/ietf/adapter.d.ts +2 -2
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +20 -10
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +73 -5
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/aliases.js +121 -14
- package/ietf/aliases.js.map +1 -1
- package/ietf/cluster.d.ts +141 -0
- package/ietf/cluster.d.ts.map +1 -0
- package/ietf/cluster.js +204 -0
- package/ietf/cluster.js.map +1 -0
- package/ietf/connection.d.ts +39 -6
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +73 -12
- package/ietf/connection.js.map +1 -1
- package/ietf/fetch.d.ts +17 -0
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +21 -0
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +87 -0
- package/ietf/filter.d.ts.map +1 -0
- package/ietf/filter.js +292 -0
- package/ietf/filter.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +3 -0
- package/ietf/index.js.map +1 -1
- package/ietf/namespace.d.ts +6 -0
- package/ietf/namespace.d.ts.map +1 -1
- package/ietf/namespace.js +27 -2
- package/ietf/namespace.js.map +1 -1
- package/ietf/object.d.ts +54 -6
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +178 -25
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +35 -10
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +236 -45
- package/ietf/parameters.js.map +1 -1
- package/ietf/priority.d.ts +5 -0
- package/ietf/priority.d.ts.map +1 -0
- package/ietf/priority.js +10 -0
- package/ietf/priority.js.map +1 -0
- package/ietf/properties.d.ts +13 -2
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +65 -8
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +13 -3
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +16 -2
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +25 -6
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +40 -7
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +751 -134
- package/ietf/publisher.js.map +1 -1
- package/ietf/request.js +1 -1
- package/ietf/request.js.map +1 -1
- package/ietf/solicit.d.ts +41 -0
- package/ietf/solicit.d.ts.map +1 -0
- package/ietf/solicit.js +47 -0
- package/ietf/solicit.js.map +1 -0
- package/ietf/subscribe.d.ts +41 -2
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +103 -22
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +21 -7
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +31 -14
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +20 -7
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +512 -142
- package/ietf/subscriber.js.map +1 -1
- package/ietf/version.d.ts +7 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +7 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +10 -5
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +29 -0
- package/internal.d.ts.map +1 -0
- package/internal.js +8 -0
- package/internal.js.map +1 -0
- package/lite/announce.d.ts +62 -15
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +230 -94
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +69 -20
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +128 -81
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +23 -0
- package/lite/datagram.d.ts.map +1 -0
- package/lite/datagram.js +57 -0
- package/lite/datagram.js.map +1 -0
- package/lite/datagram_stream.d.ts +8 -0
- package/lite/datagram_stream.d.ts.map +1 -0
- package/lite/datagram_stream.js +44 -0
- package/lite/datagram_stream.js.map +1 -0
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +2 -2
- package/lite/fetch.js.map +1 -1
- package/lite/index.d.ts +3 -0
- package/lite/index.d.ts.map +1 -1
- package/lite/index.js +3 -0
- package/lite/index.js.map +1 -1
- package/lite/priority.d.ts +67 -0
- package/lite/priority.d.ts.map +1 -0
- package/lite/priority.js +114 -0
- package/lite/priority.js.map +1 -0
- package/lite/probe.d.ts +18 -2
- package/lite/probe.d.ts.map +1 -1
- package/lite/probe.js +24 -22
- package/lite/probe.js.map +1 -1
- package/lite/publisher.d.ts +14 -7
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +406 -84
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +83 -6
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +203 -31
- package/lite/setup.js.map +1 -1
- package/lite/stream.d.ts +12 -3
- package/lite/stream.d.ts.map +1 -1
- package/lite/stream.js +7 -4
- package/lite/stream.js.map +1 -1
- package/lite/subscribe.d.ts +37 -23
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +70 -55
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +29 -23
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +689 -112
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +25 -13
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +60 -35
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +39 -4
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +139 -17
- package/lite/version.js.map +1 -1
- package/mock.d.ts +53 -3
- package/mock.d.ts.map +1 -1
- package/mock.js +110 -21
- package/mock.js.map +1 -1
- package/{lite/origin.d.ts → origin.d.ts} +25 -1
- package/origin.d.ts.map +1 -0
- package/{lite/origin.js → origin.js} +25 -1
- package/origin.js.map +1 -0
- package/package.json +6 -4
- package/path.d.ts +67 -0
- package/path.d.ts.map +1 -1
- package/path.js +128 -0
- package/path.js.map +1 -1
- package/stream.d.ts +94 -4
- package/stream.d.ts.map +1 -1
- package/stream.js +128 -17
- package/stream.js.map +1 -1
- package/time.d.ts +109 -86
- package/time.d.ts.map +1 -1
- package/time.js +80 -30
- package/time.js.map +1 -1
- package/track.d.ts +298 -47
- package/track.d.ts.map +1 -1
- package/track.js +721 -109
- package/track.js.map +1 -1
- package/util/timeout.d.ts +5 -0
- package/util/timeout.d.ts.map +1 -0
- package/util/timeout.js +18 -0
- package/util/timeout.js.map +1 -0
- package/util/utf8.d.ts +3 -0
- package/util/utf8.d.ts.map +1 -0
- package/util/utf8.js +7 -0
- package/util/utf8.js.map +1 -0
- package/varint.d.ts +14 -3
- package/varint.d.ts.map +1 -1
- package/varint.js +23 -12
- package/varint.js.map +1 -1
- package/zod.d.ts +5 -5
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/bandwidth.d.ts +0 -11
- package/bandwidth.d.ts.map +0 -1
- package/bandwidth.js +0 -7
- package/bandwidth.js.map +0 -1
- package/connection/cert.test.d.ts +0 -2
- package/connection/cert.test.d.ts.map +0 -1
- package/connection/cert.test.js +0 -26
- package/connection/cert.test.js.map +0 -1
- package/connection/reload.test.d.ts +0 -2
- package/connection/reload.test.d.ts.map +0 -1
- package/connection/reload.test.js +0 -39
- package/connection/reload.test.js.map +0 -1
- package/group.test.d.ts +0 -2
- package/group.test.d.ts.map +0 -1
- package/group.test.js +0 -66
- package/group.test.js.map +0 -1
- package/ietf/aliases.test.d.ts +0 -2
- package/ietf/aliases.test.d.ts.map +0 -1
- package/ietf/aliases.test.js +0 -30
- package/ietf/aliases.test.js.map +0 -1
- package/ietf/ietf.test.d.ts +0 -2
- package/ietf/ietf.test.d.ts.map +0 -1
- package/ietf/ietf.test.js +0 -873
- package/ietf/ietf.test.js.map +0 -1
- package/integration.test.d.ts +0 -2
- package/integration.test.d.ts.map +0 -1
- package/integration.test.js +0 -90
- package/integration.test.js.map +0 -1
- package/lite/origin.d.ts.map +0 -1
- package/lite/origin.js.map +0 -1
- package/lite/subscriber.test.d.ts +0 -2
- package/lite/subscriber.test.d.ts.map +0 -1
- package/lite/subscriber.test.js +0 -30
- package/lite/subscriber.test.js.map +0 -1
- package/lite/track.test.d.ts +0 -2
- package/lite/track.test.d.ts.map +0 -1
- package/lite/track.test.js +0 -53
- package/lite/track.test.js.map +0 -1
- package/path.test.d.ts +0 -2
- package/path.test.d.ts.map +0 -1
- package/path.test.js +0 -169
- package/path.test.js.map +0 -1
- package/stream.test.d.ts +0 -2
- package/stream.test.d.ts.map +0 -1
- package/stream.test.js +0 -210
- package/stream.test.js.map +0 -1
- package/track.test.d.ts +0 -2
- package/track.test.d.ts.map +0 -1
- package/track.test.js +0 -56
- package/track.test.js.map +0 -1
- package/util/error.d.ts +0 -3
- package/util/error.d.ts.map +0 -1
- package/util/error.js +0 -9
- package/util/error.js.map +0 -1
- package/util/index.d.ts +0 -2
- package/util/index.d.ts.map +0 -1
- package/util/index.js +0 -3
- package/util/index.js.map +0 -1
- package/varint.test.d.ts +0 -2
- package/varint.test.d.ts.map +0 -1
- package/varint.test.js +0 -106
- package/varint.test.js.map +0 -1
package/ietf/subscriber.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/* @ts-self-types="./subscriber.d.ts" */
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
2
|
+
import * as announce from "../announced.js";
|
|
3
|
+
import * as broadcast from "../broadcast.js";
|
|
4
|
+
import { BroadcastCache } from "../consume.js";
|
|
5
|
+
import { error, ProtocolViolation, reason } from "../error.js";
|
|
6
|
+
import * as netGroup from "../group.js";
|
|
5
7
|
import * as Path from "../path.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { Timestamp } from "../time.js";
|
|
9
|
+
import { withTimeout } from "../util/timeout.js";
|
|
10
|
+
import { DuplicateTrackAlias, RetiredTrackAlias, TrackAliases } from "./aliases.js";
|
|
11
|
+
import * as Cluster from "./cluster.js";
|
|
8
12
|
import { Frame } from "./object.js";
|
|
13
|
+
import { toWire } from "./priority.js";
|
|
9
14
|
import { PublishError } from "./publish.js";
|
|
10
|
-
import { PublishNamespaceError, PublishNamespaceOk } from "./publish_namespace.js";
|
|
15
|
+
import { PublishNamespace, PublishNamespaceDone, PublishNamespaceError, PublishNamespaceOk, } from "./publish_namespace.js";
|
|
11
16
|
import { RequestError, RequestOk } from "./request.js";
|
|
12
|
-
import { Subscribe, SubscribeError, SubscribeOk, Unsubscribe } from "./subscribe.js";
|
|
17
|
+
import { joinFilter, Subscribe, SubscribeError, SubscribeOk, Unsubscribe } from "./subscribe.js";
|
|
13
18
|
import { PublishBlocked, SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone, SubscribeNamespaceLegacy, SubscribeNamespaceOk, UnsubscribeNamespace, } from "./subscribe_namespace.js";
|
|
14
19
|
import { Version } from "./version.js";
|
|
20
|
+
// Bound on how long stream-open plus SUBSCRIBE_OK may take. Browsers cap
|
|
21
|
+
// concurrent QUIC streams (Chrome ~100); past the cap openBi() silently
|
|
22
|
+
// blocks. The timeout turns that into a clear error.
|
|
23
|
+
const SUBSCRIBE_OK_TIMEOUT_MS = 10_000;
|
|
15
24
|
/**
|
|
16
25
|
* Handles subscribing to broadcasts using moq-transport protocol.
|
|
17
26
|
* Uses the stream-per-request pattern (real bidi streams for v17, virtual for v14-v16).
|
|
@@ -20,40 +29,130 @@ import { Version } from "./version.js";
|
|
|
20
29
|
*/
|
|
21
30
|
export class Subscriber {
|
|
22
31
|
#session;
|
|
32
|
+
// The Hop IDs this session declared; see {@link Cluster}. What the peer declared is what
|
|
33
|
+
// says whether an advertisement carries a hop path, and ours is what a path looping back
|
|
34
|
+
// to us contains.
|
|
35
|
+
#cluster;
|
|
23
36
|
// Publisher-chosen aliases used by incoming group streams.
|
|
24
37
|
#aliases = new TrackAliases();
|
|
25
|
-
//
|
|
26
|
-
|
|
38
|
+
// Units for each track's object Timestamps, from the TIMESCALE Track Property in
|
|
39
|
+
// SUBSCRIBE_OK. A track missing from this map declared no timeline, so the publisher
|
|
40
|
+
// opted out of timestamps and its frames are stamped on arrival instead.
|
|
41
|
+
#timescales = new Map();
|
|
42
|
+
// Dedup consumed broadcasts per path: repeat consume() calls share one subscription.
|
|
43
|
+
#consumes = new BroadcastCache();
|
|
44
|
+
// Paths with a legacy PUBLISH_NAMESPACE request in flight, reserved synchronously.
|
|
45
|
+
// The count below is only taken once the OK is written, and two requests that both
|
|
46
|
+
// got past the duplicate check before either attached would both take one.
|
|
47
|
+
#legacyRequests = new Set();
|
|
48
|
+
// Every announced path, counted by how many live advertisements reference it.
|
|
49
|
+
//
|
|
50
|
+
// A peer may advertise one namespace twice on a session: an unsolicited
|
|
51
|
+
// PUBLISH_NAMESPACE and an inline NAMESPACE answering our own SUBSCRIBE_NAMESPACE are
|
|
52
|
+
// two messages about one source, which the MoQ Solicit draft requires us to tolerate.
|
|
53
|
+
// Counting them is what keeps the second from duplicating the announce and the first
|
|
54
|
+
// to end from retracting what the other still holds.
|
|
55
|
+
#announced = new Map();
|
|
27
56
|
// Any consumers that want each new announcement.
|
|
28
57
|
#announcedConsumers = new Set();
|
|
29
58
|
/**
|
|
30
59
|
* Creates a new Subscriber instance.
|
|
31
|
-
* @param session - The session abstraction for bidi streams and request IDs
|
|
32
60
|
*
|
|
33
61
|
* @internal
|
|
34
62
|
*/
|
|
35
|
-
constructor(session) {
|
|
63
|
+
constructor({ session, cluster, }) {
|
|
36
64
|
this.#session = session;
|
|
65
|
+
this.#cluster = cluster;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether an advertisement is ours coming back: its hop path already ran through us, so
|
|
69
|
+
* subscribing via it would route us back to ourselves.
|
|
70
|
+
*
|
|
71
|
+
* A conforming peer withholds these (it knows our Hop ID), so this is the backstop that
|
|
72
|
+
* keeps a mesh working when one member does not. A session that negotiated nothing
|
|
73
|
+
* carries no path, and there is nothing to check.
|
|
74
|
+
*/
|
|
75
|
+
#reflected(advert) {
|
|
76
|
+
return advert !== undefined && this.#cluster !== undefined && Cluster.loops(advert, this.#cluster.self);
|
|
37
77
|
}
|
|
38
78
|
/**
|
|
39
79
|
* Gets an announced reader for the specified prefix.
|
|
80
|
+
*
|
|
81
|
+
* The peer is asked with SUBSCRIBE_NAMESPACE regardless of what it declared, and an
|
|
82
|
+
* unsolicited PUBLISH_NAMESPACE lands here too, so a peer that only tells and one
|
|
83
|
+
* that only answers are both discovered.
|
|
40
84
|
*/
|
|
41
85
|
announced(prefix = Path.empty()) {
|
|
42
|
-
const announced = new
|
|
43
|
-
for (const active of this.#announced) {
|
|
44
|
-
|
|
86
|
+
const announced = new announce.Producer(prefix);
|
|
87
|
+
for (const active of this.#announced.keys()) {
|
|
88
|
+
const suffix = Path.stripPrefix(prefix, active);
|
|
89
|
+
if (suffix === null)
|
|
45
90
|
continue;
|
|
46
|
-
announced.append({ path:
|
|
91
|
+
announced.append({ path: suffix, active: true });
|
|
47
92
|
}
|
|
48
93
|
this.#announcedConsumers.add(announced);
|
|
49
94
|
void this.#runAnnounced(announced, prefix).finally(() => {
|
|
50
95
|
this.#announcedConsumers.delete(announced);
|
|
51
96
|
announced.close();
|
|
52
97
|
});
|
|
53
|
-
return announced;
|
|
98
|
+
return announced.consume();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Record one more advertisement for a path, telling consumers only when it is the
|
|
102
|
+
* first. A second one is the same namespace said twice, not news.
|
|
103
|
+
*/
|
|
104
|
+
#attachAnnounce(path) {
|
|
105
|
+
const count = this.#announced.get(path) ?? 0;
|
|
106
|
+
this.#announced.set(path, count + 1);
|
|
107
|
+
if (count > 0)
|
|
108
|
+
return;
|
|
109
|
+
console.debug(`announced: broadcast=${path} active=true`);
|
|
110
|
+
for (const consumer of this.#announcedConsumers) {
|
|
111
|
+
const suffix = Path.stripPrefix(consumer.prefix, path);
|
|
112
|
+
if (suffix === null)
|
|
113
|
+
continue;
|
|
114
|
+
consumer.append({ path: suffix, active: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Drop one advertisement for a path, retracting it only once the last one goes.
|
|
119
|
+
*/
|
|
120
|
+
#detachAnnounce(path) {
|
|
121
|
+
const count = this.#announced.get(path);
|
|
122
|
+
if (count === undefined)
|
|
123
|
+
return;
|
|
124
|
+
if (count > 1) {
|
|
125
|
+
this.#announced.set(path, count - 1);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.#announced.delete(path);
|
|
129
|
+
// The path is gone, so stop sharing its broadcast: a holder outliving the publisher
|
|
130
|
+
// would otherwise hand the dead generation to whoever consumes the path next.
|
|
131
|
+
this.#consumes.evict(path);
|
|
132
|
+
console.debug(`announced: broadcast=${path} active=false`);
|
|
133
|
+
for (const consumer of this.#announcedConsumers) {
|
|
134
|
+
const suffix = Path.stripPrefix(consumer.prefix, path);
|
|
135
|
+
if (suffix === null)
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
consumer.append({ path: suffix, active: false });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Consumer already closed, will be cleaned up
|
|
142
|
+
}
|
|
143
|
+
}
|
|
54
144
|
}
|
|
55
145
|
async #runAnnounced(announced, prefix) {
|
|
56
146
|
const version = this.#session.version;
|
|
147
|
+
// Suffixes live on this stream, so a repeat is recognized as an update to the
|
|
148
|
+
// advertisement rather than a second one, which would leak the count.
|
|
149
|
+
const live = new Set();
|
|
150
|
+
// Set once the teardown below has given back everything this stream held. The read
|
|
151
|
+
// loop is not awaited when the local consumer closes first, and closing the stream
|
|
152
|
+
// cancels the transport without discarding what the reader already buffered, so a
|
|
153
|
+
// fully buffered entry can still decode afterwards. Attaching one then would take a
|
|
154
|
+
// reference nobody is left to release, pinning the path for the session.
|
|
155
|
+
let released = false;
|
|
57
156
|
// v14/v15: SubscribeNamespace on control stream (via adapter virtual stream)
|
|
58
157
|
// v16+: SubscribeNamespace on its own real bidi stream
|
|
59
158
|
const requestId = await this.#session.nextRequestId();
|
|
@@ -100,25 +199,32 @@ export class Subscriber {
|
|
|
100
199
|
break;
|
|
101
200
|
const msgType = await stream.reader.u53();
|
|
102
201
|
if (msgType === SubscribeNamespaceEntry.id) {
|
|
103
|
-
const entry = await SubscribeNamespaceEntry.decode(stream.reader, version);
|
|
202
|
+
const entry = await SubscribeNamespaceEntry.decode(stream.reader, version, Cluster.negotiated(this.#cluster));
|
|
203
|
+
if (released)
|
|
204
|
+
break;
|
|
104
205
|
const path = Path.join(prefix, entry.suffix);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
206
|
+
// A repeat updates the advertisement in place, so one that now
|
|
207
|
+
// loops back through us has taken a route we can't subscribe
|
|
208
|
+
// over: the path is gone even though the message says active.
|
|
209
|
+
if (this.#reflected(entry.cluster)) {
|
|
210
|
+
console.debug(`dropping reflected namespace: broadcast=${path}`);
|
|
211
|
+
if (live.delete(path))
|
|
212
|
+
this.#detachAnnounce(path);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// A repeat updates the advertisement; only the first is news.
|
|
216
|
+
if (!live.has(path)) {
|
|
217
|
+
live.add(path);
|
|
218
|
+
this.#attachAnnounce(path);
|
|
111
219
|
}
|
|
112
220
|
}
|
|
113
221
|
else if (msgType === SubscribeNamespaceEntryDone.id) {
|
|
114
222
|
const entry = await SubscribeNamespaceEntryDone.decode(stream.reader, version);
|
|
223
|
+
if (released)
|
|
224
|
+
break;
|
|
115
225
|
const path = Path.join(prefix, entry.suffix);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
for (const consumer of this.#announcedConsumers) {
|
|
119
|
-
if (!Path.hasPrefix(consumer.prefix, path))
|
|
120
|
-
continue;
|
|
121
|
-
consumer.append({ path, active: false });
|
|
226
|
+
if (live.delete(path)) {
|
|
227
|
+
this.#detachAnnounce(path);
|
|
122
228
|
}
|
|
123
229
|
}
|
|
124
230
|
else if (msgType === PublishBlocked.id && version === Version.DRAFT_17) {
|
|
@@ -130,6 +236,15 @@ export class Subscriber {
|
|
|
130
236
|
}
|
|
131
237
|
}
|
|
132
238
|
})();
|
|
239
|
+
// The race below can end through the consumer's close while a message that
|
|
240
|
+
// already arrived is still decoding, which leaves the loop with nothing
|
|
241
|
+
// awaiting it: a violation decoded after that would land nowhere, and its
|
|
242
|
+
// rejection would go unhandled. Idempotent with the catch below, which is
|
|
243
|
+
// what runs when the loop is the one that ends the race.
|
|
244
|
+
readLoop.catch((err) => {
|
|
245
|
+
if (err instanceof ProtocolViolation)
|
|
246
|
+
this.#session.close();
|
|
247
|
+
});
|
|
133
248
|
// Wait for either the read loop or the announced to close
|
|
134
249
|
await Promise.race([readLoop, announced.closed]);
|
|
135
250
|
// For v14/v15: send UnsubscribeNamespace before closing
|
|
@@ -152,109 +267,269 @@ export class Subscriber {
|
|
|
152
267
|
}
|
|
153
268
|
catch (err) {
|
|
154
269
|
const e = error(err);
|
|
155
|
-
console.warn(`subscribe_namespace error: ${e
|
|
270
|
+
console.warn(`subscribe_namespace error: ${reason(e)}`);
|
|
271
|
+
// An advertisement is decoded here rather than in the session dispatch, so this
|
|
272
|
+
// is the only place a malformed one surfaces. The cluster draft requires closing
|
|
273
|
+
// the session over those, and the stream alone is not enough: the peer would
|
|
274
|
+
// just repeat it on the next SUBSCRIBE_NAMESPACE.
|
|
275
|
+
if (e instanceof ProtocolViolation)
|
|
276
|
+
this.#session.close();
|
|
277
|
+
// Abort the stream rather than letting the caller's `finally` close it cleanly.
|
|
278
|
+
// A rejected namespace subscription is a failure, and a consumer that can't tell
|
|
279
|
+
// it from "nothing is published under this prefix" waits forever on a broadcast
|
|
280
|
+
// that will never be announced. Matches the lite subscriber.
|
|
281
|
+
announced.close(e);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
// The stream owns every advertisement it carried, so release them however it
|
|
285
|
+
// ends: a clean close, a decode error, or the peer resetting it. Without this
|
|
286
|
+
// each namespace keeps its count and the source never detaches, which would
|
|
287
|
+
// pin the path for the session even after the other source withdrew.
|
|
288
|
+
released = true;
|
|
289
|
+
for (const path of live) {
|
|
290
|
+
this.#detachAnnounce(path);
|
|
291
|
+
}
|
|
292
|
+
live.clear();
|
|
156
293
|
}
|
|
157
294
|
}
|
|
158
295
|
/**
|
|
159
296
|
* Consumes a broadcast from the connection.
|
|
297
|
+
*
|
|
298
|
+
* Deduplicated per path: repeat calls for the same still-live path share one reference-counted
|
|
299
|
+
* broadcast (and one upstream subscription). The shared broadcast closes once every caller has
|
|
300
|
+
* closed its handle, so callers close normally.
|
|
160
301
|
*/
|
|
161
302
|
consume(path) {
|
|
162
|
-
|
|
303
|
+
return this.#consumes.get(path) ?? this.#consumes.insert(path, this.#createConsume(path));
|
|
304
|
+
}
|
|
305
|
+
#createConsume(path) {
|
|
306
|
+
// moq-transport has no one-shot group fetch; ConsumeBroadcast rejects it. Track info
|
|
307
|
+
// is resolved by the subscribe path (the inherited resolveTrackInfo).
|
|
308
|
+
const consumer = new ConsumeBroadcast();
|
|
163
309
|
void (async () => {
|
|
164
310
|
for (;;) {
|
|
165
|
-
const request = await
|
|
311
|
+
const request = await consumer.requested();
|
|
166
312
|
if (!request)
|
|
167
313
|
break;
|
|
168
314
|
void this.#runSubscribe(path, request);
|
|
169
315
|
}
|
|
170
316
|
})();
|
|
171
|
-
return
|
|
317
|
+
return consumer;
|
|
172
318
|
}
|
|
173
319
|
async #runSubscribe(broadcast, request) {
|
|
174
|
-
const version = this.#session.version;
|
|
175
320
|
const requestId = await this.#session.nextRequestId();
|
|
176
321
|
if (requestId === undefined) {
|
|
177
|
-
request.
|
|
322
|
+
request.reject(new Error("session closed"));
|
|
178
323
|
return;
|
|
179
324
|
}
|
|
180
|
-
console.debug(`subscribe start: id=${requestId} broadcast=${broadcast} track=${request.
|
|
325
|
+
console.debug(`subscribe start: id=${requestId} broadcast=${broadcast} track=${request.name}`);
|
|
326
|
+
// IETF negotiates group order in SUBSCRIBE_OK; this implementation only
|
|
327
|
+
// supports descending (newest-first), so commit ordered: false. (There's no
|
|
328
|
+
// per-frame timescale, so the rest stay at their defaults.) This
|
|
329
|
+
// resolves the consumer's track.info() and gives us the write side that
|
|
330
|
+
// incoming object streams are routed into.
|
|
331
|
+
const producer = request.accept({ ordered: false });
|
|
332
|
+
// Open the stream and wait for SUBSCRIBE_OK under a timeout. State
|
|
333
|
+
// flows back via `state` so the timeout path can clean up the stream
|
|
334
|
+
// and any registration if setup eventually finishes.
|
|
335
|
+
const state = {};
|
|
336
|
+
const setup = this.#openSubscribe(state, broadcast, request, producer, requestId);
|
|
337
|
+
// The publisher can be serving before it answers, so waiting only on the response
|
|
338
|
+
// would miss the local side going away and leave it serving a track nobody reads.
|
|
339
|
+
// Demand returning before we commit is not abandonment, matching the serving loop.
|
|
340
|
+
const waitAbandoned = async () => {
|
|
341
|
+
for (;;) {
|
|
342
|
+
await producer.unused();
|
|
343
|
+
if (producer.closed.peek() !== undefined || !producer.used.peek())
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
let stream;
|
|
348
|
+
let trackAlias;
|
|
181
349
|
try {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.track.name} alias=${ok.trackAlias}`);
|
|
206
|
-
try {
|
|
207
|
-
// Wait for stream close (= PublishDone) or track close (= local unsubscribe)
|
|
208
|
-
await Promise.race([stream.reader.closed, request.track.closed]);
|
|
209
|
-
// For v14-v16: send Unsubscribe before closing (removed in v17+)
|
|
210
|
-
if (version === Version.DRAFT_14 ||
|
|
211
|
-
version === Version.DRAFT_15 ||
|
|
212
|
-
version === Version.DRAFT_16) {
|
|
213
|
-
try {
|
|
214
|
-
await stream.writer.u53(Unsubscribe.id);
|
|
215
|
-
const unsub = new Unsubscribe({ requestId });
|
|
216
|
-
await unsub.encode(stream.writer, version);
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
// Stream might already be closed
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
request.track.close();
|
|
223
|
-
stream.close();
|
|
224
|
-
console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.track.name}`);
|
|
225
|
-
}
|
|
226
|
-
finally {
|
|
227
|
-
this.#aliases.delete(ok.trackAlias, request.track);
|
|
228
|
-
}
|
|
350
|
+
const result = await Promise.race([
|
|
351
|
+
withTimeout(setup, SUBSCRIBE_OK_TIMEOUT_MS, `subscribe timed out after ${SUBSCRIBE_OK_TIMEOUT_MS}ms waiting for SUBSCRIBE_OK (browser stream limit reached?)`),
|
|
352
|
+
waitAbandoned(),
|
|
353
|
+
]);
|
|
354
|
+
if (result === null)
|
|
355
|
+
throw new Error("subscribe abandoned before it was accepted");
|
|
356
|
+
stream = result.stream;
|
|
357
|
+
trackAlias = result.alias;
|
|
358
|
+
console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`);
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
const e = error(err);
|
|
362
|
+
producer.close(e);
|
|
363
|
+
console.warn(`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
364
|
+
// Runs now for whatever is already open, and again if `setup` settles late.
|
|
365
|
+
// Retirement is repeated because a late SUBSCRIBE_OK can register an alias after
|
|
366
|
+
// the first pass; the stream is torn down once.
|
|
367
|
+
let torn = false;
|
|
368
|
+
const cleanup = async (afterSetup) => {
|
|
369
|
+
state.cancelled = true;
|
|
370
|
+
if (state.registeredAlias !== undefined && this.#aliases.retire(state.registeredAlias, producer)) {
|
|
371
|
+
this.#timescales.delete(state.registeredAlias);
|
|
229
372
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
373
|
+
if (!state.stream || torn)
|
|
374
|
+
return;
|
|
375
|
+
// A SUBSCRIBE still being written must not be torn down from here. Aborting a
|
|
376
|
+
// Web Stream waits for the in-flight write, so the request can still reach the
|
|
377
|
+
// peer while the abort destroys the stream we would have cancelled on. Setup
|
|
378
|
+
// unwinds on the cancelled flag once that write lands, and its pass below has
|
|
379
|
+
// the finished picture.
|
|
380
|
+
if (!state.sent && !afterSetup)
|
|
381
|
+
return;
|
|
382
|
+
torn = true;
|
|
383
|
+
// Once the SUBSCRIBE is out the publisher may be serving, whether or not it has
|
|
384
|
+
// answered yet: data streams are independent of the request stream. Aborting says
|
|
385
|
+
// nothing on v14-16, where the request rides a virtual stream whose reset is
|
|
386
|
+
// local, so the UNSUBSCRIBE has to go out explicitly.
|
|
387
|
+
if (state.sent && !state.rejected)
|
|
388
|
+
await this.#cancelSubscribe(state.stream, requestId);
|
|
389
|
+
state.stream.abort(e);
|
|
390
|
+
};
|
|
391
|
+
// Tear down what is already open rather than waiting on `setup`. A peer that
|
|
392
|
+
// opened the stream and then went quiet never settles it, and deferring would
|
|
393
|
+
// leave the request outstanding for the life of the session -- which is the
|
|
394
|
+
// timeout case, not a rare one.
|
|
395
|
+
void cleanup(false);
|
|
396
|
+
setup.then(() => cleanup(true), () => cleanup(true));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
// Which terminal fired decides whether we owe the publisher a cancellation, so
|
|
401
|
+
// tag them rather than racing bare promises.
|
|
402
|
+
const publisherEnded = Symbol("publisher");
|
|
403
|
+
const localEnded = Symbol("local");
|
|
404
|
+
const idle = Symbol("idle");
|
|
405
|
+
// Terminal conditions settle at most once (stream close = PublishDone, track close =
|
|
406
|
+
// local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass.
|
|
407
|
+
const done = Promise.race([
|
|
408
|
+
stream.reader.closed.then(() => publisherEnded),
|
|
409
|
+
producer.closed.then(() => localEnded),
|
|
410
|
+
]);
|
|
411
|
+
// Serve until a terminal condition fires or the last local subscriber leaves. The unused
|
|
412
|
+
// wake is level-triggered: re-check demand so a subscriber that returns before we tear
|
|
413
|
+
// down resumes on the same stream.
|
|
414
|
+
let terminal = localEnded;
|
|
415
|
+
for (;;) {
|
|
416
|
+
const reason = await Promise.race([done, producer.unused().then(() => idle)]);
|
|
417
|
+
if (reason === idle && producer.closed.peek() === undefined && producer.used.peek())
|
|
418
|
+
continue;
|
|
419
|
+
terminal = reason;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
// The publisher already ended the request, so there is nothing to cancel. Sending
|
|
423
|
+
// UNSUBSCRIBE here would name a request it has already torn down.
|
|
424
|
+
if (terminal !== publisherEnded)
|
|
425
|
+
await this.#cancelSubscribe(stream, requestId);
|
|
426
|
+
producer.close();
|
|
427
|
+
stream.close();
|
|
428
|
+
console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.name}`);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
const e = error(err);
|
|
432
|
+
producer.close(e);
|
|
433
|
+
stream.abort(e);
|
|
434
|
+
console.warn(`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`);
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
// Only the owner tears down the alias metadata: a later subscription may have
|
|
438
|
+
// reclaimed the alias and installed its own timescale.
|
|
439
|
+
if (this.#aliases.retire(trackAlias, producer))
|
|
440
|
+
this.#timescales.delete(trackAlias);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Tell the publisher to stop serving a subscription we are walking away from.
|
|
445
|
+
*
|
|
446
|
+
* v14-16 cancel with UNSUBSCRIBE (draft-16 section 9.12), which is what lets the
|
|
447
|
+
* publisher destroy the subscription (section 5.1.1); v17+ removed the message and
|
|
448
|
+
* rely on the stream reset instead. Every path that abandons an Established
|
|
449
|
+
* subscription goes through here, because those versions carry the request over a
|
|
450
|
+
* virtual stream whose reset never reaches the peer.
|
|
451
|
+
*/
|
|
452
|
+
async #cancelSubscribe(stream, requestId) {
|
|
453
|
+
const version = this.#session.version;
|
|
454
|
+
if (version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16)
|
|
455
|
+
return;
|
|
456
|
+
try {
|
|
457
|
+
await stream.writer.u53(Unsubscribe.id);
|
|
458
|
+
await new Unsubscribe({ requestId }).encode(stream.writer, version);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// The stream may already be gone; there is nothing further to tell the peer.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Opens the subscribe stream, sends SUBSCRIBE, and reads the response.
|
|
465
|
+
// `state` is populated as soon as the stream opens and again when the
|
|
466
|
+
// trackAlias is registered, so the caller can clean both up on timeout
|
|
467
|
+
// even before this promise settles.
|
|
468
|
+
async #openSubscribe(state, broadcast, request, producer, requestId) {
|
|
469
|
+
const version = this.#session.version;
|
|
470
|
+
state.stream = await this.#session.openBi();
|
|
471
|
+
// The timeout can fire while the open is still in flight, in which case cleanup ran
|
|
472
|
+
// with nothing to release. Bail now that the stream is recorded, so settling this
|
|
473
|
+
// promise hands it back to be torn down.
|
|
474
|
+
if (state.cancelled)
|
|
475
|
+
throw new Error("subscribe cancelled before it was sent");
|
|
476
|
+
await state.stream.writer.u53(Subscribe.id);
|
|
477
|
+
const msg = new Subscribe({
|
|
478
|
+
requestId,
|
|
479
|
+
trackNamespace: broadcast,
|
|
480
|
+
trackName: request.name,
|
|
481
|
+
subscriberPriority: toWire(request.priority),
|
|
482
|
+
// No fill is requested: the fill's fetch stream and the live subscription split
|
|
483
|
+
// the group across two streams, and this subscriber does not reassemble them into
|
|
484
|
+
// one group yet. A lenient publisher (like ours) replays the whole in-range group
|
|
485
|
+
// on the subscription instead; a strict one only delivers from the next published
|
|
486
|
+
// object, and that mid-group stream is dropped, degrading the join to the next
|
|
487
|
+
// group boundary.
|
|
488
|
+
filter: joinFilter(version),
|
|
489
|
+
});
|
|
490
|
+
await msg.encode(state.stream.writer, version);
|
|
491
|
+
state.sent = true;
|
|
492
|
+
// The caller may have given up while that write was in flight. It deliberately left
|
|
493
|
+
// the stream alone, so unwind here and let its cleanup send the cancellation now that
|
|
494
|
+
// the SUBSCRIBE has actually reached the peer.
|
|
495
|
+
if (state.cancelled)
|
|
496
|
+
throw new Error("subscribe cancelled while it was being sent");
|
|
497
|
+
console.debug(`subscribe written: id=${requestId} broadcast=${broadcast} track=${request.name}`);
|
|
498
|
+
const respTypeId = await state.stream.reader.u53();
|
|
499
|
+
if (respTypeId !== SubscribeOk.id) {
|
|
500
|
+
let reasonPhrase = "unknown error";
|
|
501
|
+
try {
|
|
502
|
+
if (respTypeId === RequestError.id) {
|
|
503
|
+
const err = version === Version.DRAFT_14
|
|
504
|
+
? await SubscribeError.decode(state.stream.reader, version)
|
|
505
|
+
: await RequestError.decode(state.stream.reader, version);
|
|
506
|
+
reasonPhrase = `code=${err.errorCode} reason=${err.reasonPhrase}`;
|
|
246
507
|
}
|
|
247
508
|
}
|
|
248
|
-
catch
|
|
249
|
-
|
|
250
|
-
|
|
509
|
+
catch {
|
|
510
|
+
// Decoding error response failed, use default message
|
|
511
|
+
}
|
|
512
|
+
state.rejected = true;
|
|
513
|
+
throw new Error(`SUBSCRIBE error: ${reasonPhrase}`);
|
|
514
|
+
}
|
|
515
|
+
const ok = await SubscribeOk.decode(state.stream.reader, version);
|
|
516
|
+
try {
|
|
517
|
+
this.#aliases.set(ok.trackAlias, producer, { broadcast, name: request.name });
|
|
518
|
+
const timescale = ok.properties.timescale;
|
|
519
|
+
if (timescale !== undefined) {
|
|
520
|
+
this.#timescales.set(ok.trackAlias, timescale);
|
|
251
521
|
}
|
|
252
522
|
}
|
|
253
523
|
catch (err) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
524
|
+
// Only one alias naming two different tracks is the session's problem. A publisher
|
|
525
|
+
// sharing an alias between subscriptions to one track is allowed to do that, and
|
|
526
|
+
// disconnecting over it would drop every other broadcast on the session.
|
|
527
|
+
if (err instanceof DuplicateTrackAlias)
|
|
528
|
+
this.#session.close();
|
|
529
|
+
throw err;
|
|
257
530
|
}
|
|
531
|
+
state.registeredAlias = ok.trackAlias;
|
|
532
|
+
return { stream: state.stream, alias: ok.trackAlias };
|
|
258
533
|
}
|
|
259
534
|
/**
|
|
260
535
|
* Handles an incoming PUBLISH_NAMESPACE on a bidi stream.
|
|
@@ -265,32 +540,57 @@ export class Subscriber {
|
|
|
265
540
|
async runPublishNamespace(msg, stream) {
|
|
266
541
|
const version = this.#session.version;
|
|
267
542
|
const path = msg.trackNamespace;
|
|
268
|
-
|
|
543
|
+
// A path that already ran through us looped back. Refuse it rather than holding an
|
|
544
|
+
// advertisement we could never subscribe through. The peer knows our Hop ID, so a
|
|
545
|
+
// conforming one never offers it; 0 as the retry interval says not to come back.
|
|
546
|
+
if (this.#reflected(msg.cluster)) {
|
|
547
|
+
console.debug(`dropping reflected publish_namespace: broadcast=${path}`);
|
|
548
|
+
await stream.writer.u53(RequestError.id);
|
|
549
|
+
await new RequestError({
|
|
550
|
+
requestId: msg.requestId,
|
|
551
|
+
errorCode: 400,
|
|
552
|
+
reasonPhrase: "route loops back",
|
|
553
|
+
}).encode(stream.writer, version);
|
|
554
|
+
stream.close();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Draft-14/15 key their namespace-scoped messages by name, not request ID, so the
|
|
558
|
+
// adapter can hold only one request per namespace: a second would overwrite the
|
|
559
|
+
// first, and the withdrawals would then close the wrong stream and fail to find
|
|
560
|
+
// the other. Nothing is lost by refusing it, because the case that makes a second
|
|
561
|
+
// reference legitimate (an inline NAMESPACE for a path a PUBLISH_NAMESPACE already
|
|
562
|
+
// carried) needs a message those drafts do not have.
|
|
563
|
+
const legacy = version === Version.DRAFT_14 || version === Version.DRAFT_15;
|
|
564
|
+
if (legacy && (this.#announced.has(path) || this.#legacyRequests.has(path))) {
|
|
269
565
|
console.warn("duplicate PublishNamespace");
|
|
270
566
|
if (version === Version.DRAFT_14) {
|
|
271
567
|
await stream.writer.u53(PublishNamespaceError.id);
|
|
272
|
-
|
|
568
|
+
await new PublishNamespaceError({
|
|
273
569
|
requestId: msg.requestId,
|
|
274
570
|
errorCode: 409,
|
|
275
571
|
reasonPhrase: "duplicate namespace",
|
|
276
|
-
});
|
|
277
|
-
await err.encode(stream.writer, version);
|
|
572
|
+
}).encode(stream.writer, version);
|
|
278
573
|
}
|
|
279
574
|
else {
|
|
280
575
|
await stream.writer.u53(RequestError.id);
|
|
281
|
-
|
|
282
|
-
requestId:
|
|
576
|
+
await new RequestError({
|
|
577
|
+
requestId: msg.requestId,
|
|
283
578
|
errorCode: 409,
|
|
284
579
|
reasonPhrase: "duplicate namespace",
|
|
285
|
-
});
|
|
286
|
-
await err.encode(stream.writer, version);
|
|
580
|
+
}).encode(stream.writer, version);
|
|
287
581
|
}
|
|
288
582
|
stream.close();
|
|
289
583
|
return;
|
|
290
584
|
}
|
|
291
|
-
this
|
|
585
|
+
// Everywhere else a path this session already knows is not refused: the same
|
|
586
|
+
// namespace can reach us twice, and the count is what tells the second apart from
|
|
587
|
+
// news. This request owns exactly one of those references and gives it back when
|
|
588
|
+
// the stream ends.
|
|
589
|
+
if (legacy)
|
|
590
|
+
this.#legacyRequests.add(path);
|
|
591
|
+
let attached = false;
|
|
292
592
|
try {
|
|
293
|
-
// Send OK first
|
|
593
|
+
// Send OK first. This must complete before notifying consumers,
|
|
294
594
|
// because consumers may trigger Subscribe writes that would
|
|
295
595
|
// interleave with our OK on the control stream.
|
|
296
596
|
if (version === Version.DRAFT_14) {
|
|
@@ -305,32 +605,57 @@ export class Subscriber {
|
|
|
305
605
|
});
|
|
306
606
|
await ok.encode(stream.writer, version);
|
|
307
607
|
}
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
608
|
+
// Only now is the advertisement ours to announce, for the reason above: a
|
|
609
|
+
// consumer reacting with a SUBSCRIBE must not interleave with that OK.
|
|
610
|
+
attached = true;
|
|
611
|
+
this.#attachAnnounce(path);
|
|
612
|
+
// An advertisement is updated in place, by repeating PUBLISH_NAMESPACE on the
|
|
613
|
+
// stream that already carries it, so read until the stream ends rather than
|
|
614
|
+
// waiting on the close. Nothing else would deliver a re-parented route.
|
|
615
|
+
const done = version === Version.DRAFT_16 || legacy;
|
|
616
|
+
for (;;) {
|
|
617
|
+
if (await stream.reader.done())
|
|
618
|
+
break;
|
|
619
|
+
const typeId = await stream.reader.u53();
|
|
620
|
+
if (done && typeId === PublishNamespaceDone.id) {
|
|
621
|
+
await PublishNamespaceDone.decode(stream.reader, version);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
if (typeId !== PublishNamespace.id) {
|
|
625
|
+
throw new ProtocolViolation(`unexpected message on publish_namespace stream: 0x${typeId.toString(16)}`);
|
|
626
|
+
}
|
|
627
|
+
const update = await PublishNamespace.decode(stream.reader, version, Cluster.negotiated(this.#cluster));
|
|
628
|
+
// The stream is the advertisement, so an update on it must name the same
|
|
629
|
+
// one. Applying a mismatched update would retarget this path with metadata
|
|
630
|
+
// meant for a different request.
|
|
631
|
+
if (update.requestId !== msg.requestId || update.trackNamespace !== path) {
|
|
632
|
+
throw new ProtocolViolation("publish_namespace update does not match its stream");
|
|
633
|
+
}
|
|
634
|
+
// A path that now runs through us is unusable, so give it back. Keep
|
|
635
|
+
// reading: this stream is the advertisement's only channel, so a later
|
|
636
|
+
// clean path arrives here or nowhere.
|
|
637
|
+
if (this.#reflected(update.cluster)) {
|
|
638
|
+
if (attached) {
|
|
639
|
+
attached = false;
|
|
640
|
+
console.debug(`publish_namespace now loops back, detaching: broadcast=${path}`);
|
|
641
|
+
this.#detachAnnounce(path);
|
|
642
|
+
}
|
|
313
643
|
continue;
|
|
314
|
-
|
|
644
|
+
}
|
|
645
|
+
// Re-attach: a clean path replaced the reflected one we detached from.
|
|
646
|
+
if (!attached) {
|
|
647
|
+
attached = true;
|
|
648
|
+
this.#attachAnnounce(path);
|
|
649
|
+
}
|
|
315
650
|
}
|
|
316
|
-
// Wait for stream close (= PublishNamespaceDone)
|
|
317
|
-
console.debug(`runPublishNamespace: awaiting stream.reader.closed for ${path}`);
|
|
318
|
-
await stream.reader.closed;
|
|
319
|
-
console.debug(`runPublishNamespace: stream.reader.closed resolved for ${path}`);
|
|
320
651
|
}
|
|
321
652
|
finally {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
try {
|
|
329
|
-
consumer.append({ path, active: false });
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
// Consumer already closed, will be cleaned up
|
|
333
|
-
}
|
|
653
|
+
if (legacy)
|
|
654
|
+
this.#legacyRequests.delete(path);
|
|
655
|
+
// Give back exactly what was taken: a request that never got its OK out never
|
|
656
|
+
// referenced the path, and retracting there would drop someone else's count.
|
|
657
|
+
if (attached) {
|
|
658
|
+
this.#detachAnnounce(path);
|
|
334
659
|
}
|
|
335
660
|
}
|
|
336
661
|
}
|
|
@@ -342,11 +667,17 @@ export class Subscriber {
|
|
|
342
667
|
*/
|
|
343
668
|
async runPublish(msg, stream) {
|
|
344
669
|
const version = this.#session.version;
|
|
670
|
+
// NOT_SUPPORTED, from the PUBLISH error codes in draft-19 section 10.10. We decline
|
|
671
|
+
// the method itself rather than this particular track, which is UNINTERESTED (0x4).
|
|
672
|
+
//
|
|
673
|
+
// The alias the message carries is deliberately not recorded. Nothing will ever bind
|
|
674
|
+
// it, and a rejected request has no lifetime of ours to hang the cleanup on.
|
|
675
|
+
const NOT_SUPPORTED = 0x3;
|
|
345
676
|
if (version === Version.DRAFT_14) {
|
|
346
677
|
await stream.writer.u53(PublishError.id);
|
|
347
678
|
const err = new PublishError({
|
|
348
679
|
requestId: msg.requestId,
|
|
349
|
-
errorCode:
|
|
680
|
+
errorCode: NOT_SUPPORTED,
|
|
350
681
|
reasonPhrase: "publish not supported",
|
|
351
682
|
});
|
|
352
683
|
await err.encode(stream.writer, version);
|
|
@@ -355,7 +686,7 @@ export class Subscriber {
|
|
|
355
686
|
await stream.writer.u53(RequestError.id);
|
|
356
687
|
const err = new RequestError({
|
|
357
688
|
requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined,
|
|
358
|
-
errorCode:
|
|
689
|
+
errorCode: NOT_SUPPORTED,
|
|
359
690
|
reasonPhrase: "publish not supported",
|
|
360
691
|
});
|
|
361
692
|
await err.encode(stream.writer, version);
|
|
@@ -368,10 +699,25 @@ export class Subscriber {
|
|
|
368
699
|
* @internal
|
|
369
700
|
*/
|
|
370
701
|
async handleGroup(group, stream) {
|
|
371
|
-
const producer = new Group(group.groupId);
|
|
372
702
|
if (group.subGroupId !== 0) {
|
|
373
703
|
throw new Error("subgroups are not supported");
|
|
374
704
|
}
|
|
705
|
+
// FIRST_OBJECT clear says this stream starts partway through the group, which the
|
|
706
|
+
// draft lets a publisher do to answer a filter. Nothing above here can use it: the
|
|
707
|
+
// objects that would arrive are not decodable without the missing head, and a group
|
|
708
|
+
// is the unit an application resyncs on. Drop it and pick up at the next group, the
|
|
709
|
+
// same degradation as a publisher that no longer holds the head.
|
|
710
|
+
//
|
|
711
|
+
// This only saves reading a stream we would throw away. The bit is the publisher's
|
|
712
|
+
// claim, so what is enforced is the object ids themselves: `Frame.decode` holds every
|
|
713
|
+
// object to starting at 0 and incrementing by 1, whatever the header said and on the
|
|
714
|
+
// drafts that have no such bit to read.
|
|
715
|
+
if (!group.flags.firstObject) {
|
|
716
|
+
console.debug(`dropping a group with no head: alias=${group.trackAlias} group=${group.groupId}`);
|
|
717
|
+
stream.stop(new Error("a group must start at object 0"));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const producer = new netGroup.Producer(group.groupId);
|
|
375
721
|
try {
|
|
376
722
|
// The control message establishing this alias can arrive after the data stream.
|
|
377
723
|
const track = await this.#aliases.get(group.trackAlias);
|
|
@@ -380,18 +726,42 @@ export class Subscriber {
|
|
|
380
726
|
const done = await Promise.race([stream.done(), producer.closed, track.closed]);
|
|
381
727
|
if (done !== false)
|
|
382
728
|
break;
|
|
383
|
-
const frame = await Frame.decode(stream, group.flags);
|
|
729
|
+
const frame = await Frame.decode(stream, group.flags, this.#timescales.get(group.trackAlias), this.#session.version);
|
|
384
730
|
if (frame.payload === undefined)
|
|
385
731
|
break;
|
|
386
|
-
producer.writeFrame(frame.payload, frame.
|
|
732
|
+
producer.writeFrame({ payload: frame.payload, timestamp: frame.timestamp ?? Timestamp.now() });
|
|
387
733
|
}
|
|
388
734
|
producer.close();
|
|
389
735
|
}
|
|
390
736
|
catch (err) {
|
|
391
737
|
const e = error(err);
|
|
738
|
+
// Ours: we cancelled the subscription and the publisher has not stopped yet.
|
|
739
|
+
// Anything else on this alias is the publisher sending data for a track it never
|
|
740
|
+
// acknowledged, which is worth seeing.
|
|
741
|
+
if (e instanceof RetiredTrackAlias) {
|
|
742
|
+
console.debug(`dropping group for a cancelled subscription: alias=${group.trackAlias}`);
|
|
743
|
+
}
|
|
392
744
|
producer.close(e);
|
|
393
745
|
stream.stop(e);
|
|
394
746
|
}
|
|
395
747
|
}
|
|
396
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* A broadcast consumed from a moq-transport session. Track info is resolved by the
|
|
751
|
+
* subscribe path (the inherited `resolveTrackInfo`), but the protocol has no one-shot
|
|
752
|
+
* group fetch, so `track.Consumer.fetchGroup()` is rejected.
|
|
753
|
+
*/
|
|
754
|
+
class ConsumeBroadcast extends broadcast.Consumer {
|
|
755
|
+
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
|
|
756
|
+
constructor(state) {
|
|
757
|
+
super(state);
|
|
758
|
+
}
|
|
759
|
+
// Preserve the subclass when the consume cache shares this broadcast across callers.
|
|
760
|
+
clone() {
|
|
761
|
+
return new ConsumeBroadcast(this.shareState());
|
|
762
|
+
}
|
|
763
|
+
fetchGroup() {
|
|
764
|
+
return Promise.reject(new Error("fetch group is not supported for moq-transport"));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
397
767
|
//# sourceMappingURL=subscriber.js.map
|