@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.
Files changed (310) hide show
  1. package/README.md +1 -0
  2. package/announced.d.ts +111 -21
  3. package/announced.d.ts.map +1 -1
  4. package/announced.js +213 -40
  5. package/announced.js.map +1 -1
  6. package/broadcast.d.ts +80 -25
  7. package/broadcast.d.ts.map +1 -1
  8. package/broadcast.js +270 -49
  9. package/broadcast.js.map +1 -1
  10. package/connection/accept.d.ts +6 -0
  11. package/connection/accept.d.ts.map +1 -1
  12. package/connection/accept.js +42 -13
  13. package/connection/accept.js.map +1 -1
  14. package/connection/browser.d.ts +5 -0
  15. package/connection/browser.d.ts.map +1 -0
  16. package/connection/browser.js +31 -0
  17. package/connection/browser.js.map +1 -0
  18. package/connection/connect.d.ts +43 -1
  19. package/connection/connect.d.ts.map +1 -1
  20. package/connection/connect.js +118 -123
  21. package/connection/connect.js.map +1 -1
  22. package/connection/established.d.ts +43 -16
  23. package/connection/established.d.ts.map +1 -1
  24. package/connection/established.js.map +1 -1
  25. package/connection/handshake.d.ts +12 -1
  26. package/connection/handshake.d.ts.map +1 -1
  27. package/connection/handshake.js +30 -14
  28. package/connection/handshake.js.map +1 -1
  29. package/connection/index.d.ts +8 -0
  30. package/connection/index.d.ts.map +1 -1
  31. package/connection/index.js +6 -0
  32. package/connection/index.js.map +1 -1
  33. package/connection/reload.d.ts +66 -9
  34. package/connection/reload.d.ts.map +1 -1
  35. package/connection/reload.js +192 -66
  36. package/connection/reload.js.map +1 -1
  37. package/connection/stats.d.ts +92 -0
  38. package/connection/stats.d.ts.map +1 -0
  39. package/connection/stats.js +35 -0
  40. package/connection/stats.js.map +1 -0
  41. package/connection/transport.d.ts +10 -0
  42. package/connection/transport.d.ts.map +1 -0
  43. package/connection/transport.js +12 -0
  44. package/connection/transport.js.map +1 -0
  45. package/consume.d.ts +44 -0
  46. package/consume.d.ts.map +1 -0
  47. package/consume.js +60 -0
  48. package/consume.js.map +1 -0
  49. package/datagram.d.ts +23 -0
  50. package/datagram.d.ts.map +1 -0
  51. package/datagram.js +3 -0
  52. package/datagram.js.map +1 -0
  53. package/error.d.ts +76 -0
  54. package/error.d.ts.map +1 -0
  55. package/error.js +107 -0
  56. package/error.js.map +1 -0
  57. package/group.d.ts +143 -38
  58. package/group.d.ts.map +1 -1
  59. package/group.js +270 -68
  60. package/group.js.map +1 -1
  61. package/ietf/adapter.d.ts +2 -2
  62. package/ietf/adapter.d.ts.map +1 -1
  63. package/ietf/adapter.js +20 -10
  64. package/ietf/adapter.js.map +1 -1
  65. package/ietf/aliases.d.ts +73 -5
  66. package/ietf/aliases.d.ts.map +1 -1
  67. package/ietf/aliases.js +121 -14
  68. package/ietf/aliases.js.map +1 -1
  69. package/ietf/cluster.d.ts +141 -0
  70. package/ietf/cluster.d.ts.map +1 -0
  71. package/ietf/cluster.js +204 -0
  72. package/ietf/cluster.js.map +1 -0
  73. package/ietf/connection.d.ts +39 -6
  74. package/ietf/connection.d.ts.map +1 -1
  75. package/ietf/connection.js +73 -12
  76. package/ietf/connection.js.map +1 -1
  77. package/ietf/fetch.d.ts +17 -0
  78. package/ietf/fetch.d.ts.map +1 -1
  79. package/ietf/fetch.js +21 -0
  80. package/ietf/fetch.js.map +1 -1
  81. package/ietf/filter.d.ts +87 -0
  82. package/ietf/filter.d.ts.map +1 -0
  83. package/ietf/filter.js +292 -0
  84. package/ietf/filter.js.map +1 -0
  85. package/ietf/index.d.ts +2 -0
  86. package/ietf/index.d.ts.map +1 -1
  87. package/ietf/index.js +3 -0
  88. package/ietf/index.js.map +1 -1
  89. package/ietf/namespace.d.ts +6 -0
  90. package/ietf/namespace.d.ts.map +1 -1
  91. package/ietf/namespace.js +27 -2
  92. package/ietf/namespace.js.map +1 -1
  93. package/ietf/object.d.ts +54 -6
  94. package/ietf/object.d.ts.map +1 -1
  95. package/ietf/object.js +178 -25
  96. package/ietf/object.js.map +1 -1
  97. package/ietf/parameters.d.ts +35 -10
  98. package/ietf/parameters.d.ts.map +1 -1
  99. package/ietf/parameters.js +236 -45
  100. package/ietf/parameters.js.map +1 -1
  101. package/ietf/priority.d.ts +5 -0
  102. package/ietf/priority.d.ts.map +1 -0
  103. package/ietf/priority.js +10 -0
  104. package/ietf/priority.js.map +1 -0
  105. package/ietf/properties.d.ts +13 -2
  106. package/ietf/properties.d.ts.map +1 -1
  107. package/ietf/properties.js +65 -8
  108. package/ietf/properties.js.map +1 -1
  109. package/ietf/publish.d.ts.map +1 -1
  110. package/ietf/publish.js +13 -3
  111. package/ietf/publish.js.map +1 -1
  112. package/ietf/publish_namespace.d.ts +16 -2
  113. package/ietf/publish_namespace.d.ts.map +1 -1
  114. package/ietf/publish_namespace.js +25 -6
  115. package/ietf/publish_namespace.js.map +1 -1
  116. package/ietf/publisher.d.ts +40 -7
  117. package/ietf/publisher.d.ts.map +1 -1
  118. package/ietf/publisher.js +751 -134
  119. package/ietf/publisher.js.map +1 -1
  120. package/ietf/request.js +1 -1
  121. package/ietf/request.js.map +1 -1
  122. package/ietf/solicit.d.ts +41 -0
  123. package/ietf/solicit.d.ts.map +1 -0
  124. package/ietf/solicit.js +47 -0
  125. package/ietf/solicit.js.map +1 -0
  126. package/ietf/subscribe.d.ts +41 -2
  127. package/ietf/subscribe.d.ts.map +1 -1
  128. package/ietf/subscribe.js +103 -22
  129. package/ietf/subscribe.js.map +1 -1
  130. package/ietf/subscribe_namespace.d.ts +21 -7
  131. package/ietf/subscribe_namespace.d.ts.map +1 -1
  132. package/ietf/subscribe_namespace.js +31 -14
  133. package/ietf/subscribe_namespace.js.map +1 -1
  134. package/ietf/subscriber.d.ts +20 -7
  135. package/ietf/subscriber.d.ts.map +1 -1
  136. package/ietf/subscriber.js +512 -142
  137. package/ietf/subscriber.js.map +1 -1
  138. package/ietf/version.d.ts +7 -1
  139. package/ietf/version.d.ts.map +1 -1
  140. package/ietf/version.js +7 -0
  141. package/ietf/version.js.map +1 -1
  142. package/index.d.ts +10 -5
  143. package/index.d.ts.map +1 -1
  144. package/index.js +10 -5
  145. package/index.js.map +1 -1
  146. package/internal.d.ts +29 -0
  147. package/internal.d.ts.map +1 -0
  148. package/internal.js +8 -0
  149. package/internal.js.map +1 -0
  150. package/lite/announce.d.ts +62 -15
  151. package/lite/announce.d.ts.map +1 -1
  152. package/lite/announce.js +230 -94
  153. package/lite/announce.js.map +1 -1
  154. package/lite/connection.d.ts +69 -20
  155. package/lite/connection.d.ts.map +1 -1
  156. package/lite/connection.js +128 -81
  157. package/lite/connection.js.map +1 -1
  158. package/lite/datagram.d.ts +23 -0
  159. package/lite/datagram.d.ts.map +1 -0
  160. package/lite/datagram.js +57 -0
  161. package/lite/datagram.js.map +1 -0
  162. package/lite/datagram_stream.d.ts +8 -0
  163. package/lite/datagram_stream.d.ts.map +1 -0
  164. package/lite/datagram_stream.js +44 -0
  165. package/lite/datagram_stream.js.map +1 -0
  166. package/lite/fetch.d.ts.map +1 -1
  167. package/lite/fetch.js +2 -2
  168. package/lite/fetch.js.map +1 -1
  169. package/lite/index.d.ts +3 -0
  170. package/lite/index.d.ts.map +1 -1
  171. package/lite/index.js +3 -0
  172. package/lite/index.js.map +1 -1
  173. package/lite/priority.d.ts +67 -0
  174. package/lite/priority.d.ts.map +1 -0
  175. package/lite/priority.js +114 -0
  176. package/lite/priority.js.map +1 -0
  177. package/lite/probe.d.ts +18 -2
  178. package/lite/probe.d.ts.map +1 -1
  179. package/lite/probe.js +24 -22
  180. package/lite/probe.js.map +1 -1
  181. package/lite/publisher.d.ts +14 -7
  182. package/lite/publisher.d.ts.map +1 -1
  183. package/lite/publisher.js +406 -84
  184. package/lite/publisher.js.map +1 -1
  185. package/lite/setup.d.ts +83 -6
  186. package/lite/setup.d.ts.map +1 -1
  187. package/lite/setup.js +203 -31
  188. package/lite/setup.js.map +1 -1
  189. package/lite/stream.d.ts +12 -3
  190. package/lite/stream.d.ts.map +1 -1
  191. package/lite/stream.js +7 -4
  192. package/lite/stream.js.map +1 -1
  193. package/lite/subscribe.d.ts +37 -23
  194. package/lite/subscribe.d.ts.map +1 -1
  195. package/lite/subscribe.js +70 -55
  196. package/lite/subscribe.js.map +1 -1
  197. package/lite/subscriber.d.ts +29 -23
  198. package/lite/subscriber.d.ts.map +1 -1
  199. package/lite/subscriber.js +689 -112
  200. package/lite/subscriber.js.map +1 -1
  201. package/lite/track.d.ts +25 -13
  202. package/lite/track.d.ts.map +1 -1
  203. package/lite/track.js +60 -35
  204. package/lite/track.js.map +1 -1
  205. package/lite/version.d.ts +39 -4
  206. package/lite/version.d.ts.map +1 -1
  207. package/lite/version.js +139 -17
  208. package/lite/version.js.map +1 -1
  209. package/mock.d.ts +53 -3
  210. package/mock.d.ts.map +1 -1
  211. package/mock.js +110 -21
  212. package/mock.js.map +1 -1
  213. package/{lite/origin.d.ts → origin.d.ts} +25 -1
  214. package/origin.d.ts.map +1 -0
  215. package/{lite/origin.js → origin.js} +25 -1
  216. package/origin.js.map +1 -0
  217. package/package.json +6 -4
  218. package/path.d.ts +67 -0
  219. package/path.d.ts.map +1 -1
  220. package/path.js +128 -0
  221. package/path.js.map +1 -1
  222. package/stream.d.ts +94 -4
  223. package/stream.d.ts.map +1 -1
  224. package/stream.js +128 -17
  225. package/stream.js.map +1 -1
  226. package/time.d.ts +109 -86
  227. package/time.d.ts.map +1 -1
  228. package/time.js +80 -30
  229. package/time.js.map +1 -1
  230. package/track.d.ts +298 -47
  231. package/track.d.ts.map +1 -1
  232. package/track.js +721 -109
  233. package/track.js.map +1 -1
  234. package/util/timeout.d.ts +5 -0
  235. package/util/timeout.d.ts.map +1 -0
  236. package/util/timeout.js +18 -0
  237. package/util/timeout.js.map +1 -0
  238. package/util/utf8.d.ts +3 -0
  239. package/util/utf8.d.ts.map +1 -0
  240. package/util/utf8.js +7 -0
  241. package/util/utf8.js.map +1 -0
  242. package/varint.d.ts +14 -3
  243. package/varint.d.ts.map +1 -1
  244. package/varint.js +23 -12
  245. package/varint.js.map +1 -1
  246. package/zod.d.ts +5 -5
  247. package/zod.d.ts.map +1 -1
  248. package/zod.js.map +1 -1
  249. package/bandwidth.d.ts +0 -11
  250. package/bandwidth.d.ts.map +0 -1
  251. package/bandwidth.js +0 -7
  252. package/bandwidth.js.map +0 -1
  253. package/connection/cert.test.d.ts +0 -2
  254. package/connection/cert.test.d.ts.map +0 -1
  255. package/connection/cert.test.js +0 -26
  256. package/connection/cert.test.js.map +0 -1
  257. package/connection/reload.test.d.ts +0 -2
  258. package/connection/reload.test.d.ts.map +0 -1
  259. package/connection/reload.test.js +0 -39
  260. package/connection/reload.test.js.map +0 -1
  261. package/group.test.d.ts +0 -2
  262. package/group.test.d.ts.map +0 -1
  263. package/group.test.js +0 -66
  264. package/group.test.js.map +0 -1
  265. package/ietf/aliases.test.d.ts +0 -2
  266. package/ietf/aliases.test.d.ts.map +0 -1
  267. package/ietf/aliases.test.js +0 -30
  268. package/ietf/aliases.test.js.map +0 -1
  269. package/ietf/ietf.test.d.ts +0 -2
  270. package/ietf/ietf.test.d.ts.map +0 -1
  271. package/ietf/ietf.test.js +0 -873
  272. package/ietf/ietf.test.js.map +0 -1
  273. package/integration.test.d.ts +0 -2
  274. package/integration.test.d.ts.map +0 -1
  275. package/integration.test.js +0 -90
  276. package/integration.test.js.map +0 -1
  277. package/lite/origin.d.ts.map +0 -1
  278. package/lite/origin.js.map +0 -1
  279. package/lite/subscriber.test.d.ts +0 -2
  280. package/lite/subscriber.test.d.ts.map +0 -1
  281. package/lite/subscriber.test.js +0 -30
  282. package/lite/subscriber.test.js.map +0 -1
  283. package/lite/track.test.d.ts +0 -2
  284. package/lite/track.test.d.ts.map +0 -1
  285. package/lite/track.test.js +0 -53
  286. package/lite/track.test.js.map +0 -1
  287. package/path.test.d.ts +0 -2
  288. package/path.test.d.ts.map +0 -1
  289. package/path.test.js +0 -169
  290. package/path.test.js.map +0 -1
  291. package/stream.test.d.ts +0 -2
  292. package/stream.test.d.ts.map +0 -1
  293. package/stream.test.js +0 -210
  294. package/stream.test.js.map +0 -1
  295. package/track.test.d.ts +0 -2
  296. package/track.test.d.ts.map +0 -1
  297. package/track.test.js +0 -56
  298. package/track.test.js.map +0 -1
  299. package/util/error.d.ts +0 -3
  300. package/util/error.d.ts.map +0 -1
  301. package/util/error.js +0 -9
  302. package/util/error.js.map +0 -1
  303. package/util/index.d.ts +0 -2
  304. package/util/index.d.ts.map +0 -1
  305. package/util/index.js +0 -3
  306. package/util/index.js.map +0 -1
  307. package/varint.test.d.ts +0 -2
  308. package/varint.test.d.ts.map +0 -1
  309. package/varint.test.js +0 -106
  310. package/varint.test.js.map +0 -1
@@ -1,17 +1,26 @@
1
1
  /* @ts-self-types="./subscriber.d.ts" */
2
- import { Announced } from "../announced.js";
3
- import { Broadcast } from "../broadcast.js";
4
- import { Group } from "../group.js";
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 { error } from "../util/error.js";
7
- import { TrackAliases } from "./aliases.js";
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
- // Any currently active announcements.
26
- #announced = new Set();
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 Announced(prefix);
43
- for (const active of this.#announced) {
44
- if (!Path.hasPrefix(prefix, active))
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: active, active: true });
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
- console.debug(`announced: broadcast=${path} active=true`);
106
- this.#announced.add(path);
107
- for (const consumer of this.#announcedConsumers) {
108
- if (!Path.hasPrefix(consumer.prefix, path))
109
- continue;
110
- consumer.append({ path, active: true });
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
- console.debug(`announced: broadcast=${path} active=false`);
117
- this.#announced.delete(path);
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.message}`);
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
- const broadcast = new Broadcast();
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 broadcast.requested();
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 broadcast;
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.track.close(new Error("session closed"));
322
+ request.reject(new Error("session closed"));
178
323
  return;
179
324
  }
180
- console.debug(`subscribe start: id=${requestId} broadcast=${broadcast} track=${request.track.name}`);
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 stream = await this.#session.openBi();
183
- try {
184
- // Write Subscribe
185
- await stream.writer.u53(Subscribe.id);
186
- const msg = new Subscribe({
187
- requestId,
188
- trackNamespace: broadcast,
189
- trackName: request.track.name,
190
- subscriberPriority: request.priority,
191
- });
192
- await msg.encode(stream.writer, version);
193
- console.debug(`subscribe written: id=${requestId} broadcast=${broadcast} track=${request.track.name}`);
194
- // Read response (SubscribeOk or error)
195
- const respTypeId = await stream.reader.u53();
196
- if (respTypeId === SubscribeOk.id) {
197
- const ok = await SubscribeOk.decode(stream.reader, version);
198
- try {
199
- this.#aliases.set(ok.trackAlias, request.track);
200
- }
201
- catch (err) {
202
- this.#session.close();
203
- throw err;
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
- else {
231
- // Error response
232
- let reasonPhrase = "unknown error";
233
- try {
234
- if (respTypeId === RequestError.id) {
235
- // SubscribeError (v14) or RequestError (v15+)
236
- const err = version === Version.DRAFT_14
237
- ? await SubscribeError.decode(stream.reader, version)
238
- : await RequestError.decode(stream.reader, version);
239
- reasonPhrase = `code=${err.errorCode} reason=${err.reasonPhrase}`;
240
- }
241
- }
242
- catch {
243
- // Decoding error response failed, use default message
244
- }
245
- throw new Error(`SUBSCRIBE error: ${reasonPhrase}`);
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 (err) {
249
- stream.abort(error(err));
250
- throw err;
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
- const e = error(err);
255
- request.track.close(e);
256
- console.warn(`subscribe error: id=${requestId} broadcast=${broadcast} track=${request.track.name} error=${e.message}`);
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
- if (this.#announced.has(path)) {
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
- const err = new PublishNamespaceError({
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
- const err = new RequestError({
282
- requestId: version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined,
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.#announced.add(path);
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 must complete before notifying consumers,
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
- console.debug(`announced: broadcast=${path} active=true`);
309
- // Notify consumers after OK is written
310
- for (const consumer of this.#announcedConsumers) {
311
- const suffix = Path.stripPrefix(consumer.prefix, path);
312
- if (suffix === null)
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
- consumer.append({ path, active: true });
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
- this.#announced.delete(path);
323
- console.debug(`announced: broadcast=${path} active=false`);
324
- for (const consumer of this.#announcedConsumers) {
325
- const suffix = Path.stripPrefix(consumer.prefix, path);
326
- if (suffix === null)
327
- continue;
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: 500,
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: 500,
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.extensions);
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