@norskvideo/moq-net 0.1.8 → 0.2.0

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