@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.
- package/README.md +2 -2
- package/announce.d.ts +7 -0
- package/announce.d.ts.map +1 -0
- package/announce.js +8 -0
- package/announce.js.map +1 -0
- package/announced.d.ts +49 -91
- package/announced.d.ts.map +1 -1
- package/announced.js +21 -156
- package/announced.js.map +1 -1
- package/bandwidth.d.ts +163 -0
- package/bandwidth.d.ts.map +1 -0
- package/bandwidth.js +304 -0
- package/bandwidth.js.map +1 -0
- package/bandwidth_api.d.ts +7 -0
- package/bandwidth_api.d.ts.map +1 -0
- package/bandwidth_api.js +8 -0
- package/bandwidth_api.js.map +1 -0
- package/broadcast.d.ts +44 -35
- package/broadcast.d.ts.map +1 -1
- package/broadcast.js +104 -60
- package/broadcast.js.map +1 -1
- package/connection/accept.d.ts +16 -1
- package/connection/accept.d.ts.map +1 -1
- package/connection/accept.js +52 -28
- package/connection/accept.js.map +1 -1
- package/connection/browser.d.ts.map +1 -1
- package/connection/browser.js +9 -7
- package/connection/browser.js.map +1 -1
- package/connection/connect.d.ts +30 -6
- package/connection/connect.d.ts.map +1 -1
- package/connection/connect.js +110 -54
- package/connection/connect.js.map +1 -1
- package/connection/established.d.ts +17 -21
- package/connection/established.d.ts.map +1 -1
- package/connection/established.js.map +1 -1
- package/connection/forward.d.ts +2 -0
- package/connection/forward.d.ts.map +1 -0
- package/connection/forward.js +173 -0
- package/connection/forward.js.map +1 -0
- package/connection/handshake.d.ts +1 -0
- package/connection/handshake.d.ts.map +1 -1
- package/connection/handshake.js +5 -2
- package/connection/handshake.js.map +1 -1
- package/connection/index.d.ts +5 -5
- package/connection/index.d.ts.map +1 -1
- package/connection/index.js +4 -5
- package/connection/index.js.map +1 -1
- package/connection/pool.d.ts +186 -0
- package/connection/pool.d.ts.map +1 -0
- package/connection/pool.js +361 -0
- package/connection/pool.js.map +1 -0
- package/connection/reload.d.ts +14 -93
- package/connection/reload.d.ts.map +1 -1
- package/connection/reload.js +217 -81
- package/connection/reload.js.map +1 -1
- package/connection/stats.d.ts +3 -26
- package/connection/stats.d.ts.map +1 -1
- package/connection/stats.js.map +1 -1
- package/connection/transport.d.ts +0 -7
- package/connection/transport.d.ts.map +1 -1
- package/consume.d.ts +1 -43
- package/consume.d.ts.map +1 -1
- package/consume.js +1 -1
- package/consume.js.map +1 -1
- package/error.d.ts +180 -29
- package/error.d.ts.map +1 -1
- package/error.js +331 -16
- package/error.js.map +1 -1
- package/errors.d.ts +7 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +8 -0
- package/errors.js.map +1 -0
- package/group.d.ts +9 -43
- package/group.d.ts.map +1 -1
- package/group.js +284 -69
- package/group.js.map +1 -1
- package/hop.d.ts +115 -0
- package/hop.d.ts.map +1 -0
- package/hop.js +119 -0
- package/hop.js.map +1 -0
- package/ietf/adapter.d.ts +5 -1
- package/ietf/adapter.d.ts.map +1 -1
- package/ietf/adapter.js +105 -60
- package/ietf/adapter.js.map +1 -1
- package/ietf/aliases.d.ts +1 -78
- package/ietf/aliases.d.ts.map +1 -1
- package/ietf/cluster.d.ts +9 -123
- package/ietf/cluster.d.ts.map +1 -1
- package/ietf/cluster.js +84 -44
- package/ietf/cluster.js.map +1 -1
- package/ietf/connection.d.ts +6 -72
- package/ietf/connection.d.ts.map +1 -1
- package/ietf/connection.js +62 -56
- package/ietf/connection.js.map +1 -1
- package/ietf/error.d.ts +11 -0
- package/ietf/error.d.ts.map +1 -0
- package/ietf/error.js +193 -0
- package/ietf/error.js.map +1 -0
- package/ietf/fetch.d.ts +7 -20
- package/ietf/fetch.d.ts.map +1 -1
- package/ietf/fetch.js +52 -22
- package/ietf/fetch.js.map +1 -1
- package/ietf/filter.d.ts +2 -0
- package/ietf/filter.d.ts.map +1 -1
- package/ietf/filter.js +10 -1
- package/ietf/filter.js.map +1 -1
- package/ietf/goaway.d.ts.map +1 -1
- package/ietf/goaway.js +22 -5
- package/ietf/goaway.js.map +1 -1
- package/ietf/hidden.d.ts +2 -0
- package/ietf/hidden.d.ts.map +1 -0
- package/ietf/hidden.js +30 -0
- package/ietf/hidden.js.map +1 -0
- package/ietf/index.d.ts +2 -0
- package/ietf/index.d.ts.map +1 -1
- package/ietf/index.js +2 -0
- package/ietf/index.js.map +1 -1
- package/ietf/object.d.ts +15 -8
- package/ietf/object.d.ts.map +1 -1
- package/ietf/object.js +51 -36
- package/ietf/object.js.map +1 -1
- package/ietf/parameters.d.ts +14 -2
- package/ietf/parameters.d.ts.map +1 -1
- package/ietf/parameters.js +97 -29
- package/ietf/parameters.js.map +1 -1
- package/ietf/properties.d.ts +1 -0
- package/ietf/properties.d.ts.map +1 -1
- package/ietf/properties.js +14 -0
- package/ietf/properties.js.map +1 -1
- package/ietf/publish.d.ts +19 -2
- package/ietf/publish.d.ts.map +1 -1
- package/ietf/publish.js +40 -6
- package/ietf/publish.js.map +1 -1
- package/ietf/publish_namespace.d.ts +25 -0
- package/ietf/publish_namespace.d.ts.map +1 -1
- package/ietf/publish_namespace.js +63 -0
- package/ietf/publish_namespace.js.map +1 -1
- package/ietf/publisher.d.ts +1 -82
- package/ietf/publisher.d.ts.map +1 -1
- package/ietf/publisher.js +483 -235
- package/ietf/publisher.js.map +1 -1
- package/ietf/solicit.d.ts +1 -40
- package/ietf/solicit.d.ts.map +1 -1
- package/ietf/subscribe.d.ts +8 -6
- package/ietf/subscribe.d.ts.map +1 -1
- package/ietf/subscribe.js +33 -27
- package/ietf/subscribe.js.map +1 -1
- package/ietf/subscribe_namespace.d.ts +8 -2
- package/ietf/subscribe_namespace.d.ts.map +1 -1
- package/ietf/subscribe_namespace.js +18 -8
- package/ietf/subscribe_namespace.js.map +1 -1
- package/ietf/subscriber.d.ts +1 -65
- package/ietf/subscriber.d.ts.map +1 -1
- package/ietf/subscriber.js +337 -123
- package/ietf/subscriber.js.map +1 -1
- package/ietf/token.d.ts +2 -0
- package/ietf/token.d.ts.map +1 -0
- package/ietf/token.js +99 -0
- package/ietf/token.js.map +1 -0
- package/ietf/track.d.ts +4 -0
- package/ietf/track.d.ts.map +1 -1
- package/ietf/track.js +6 -20
- package/ietf/track.js.map +1 -1
- package/ietf/version.d.ts +12 -1
- package/ietf/version.d.ts.map +1 -1
- package/ietf/version.js +13 -0
- package/ietf/version.js.map +1 -1
- package/index.d.ts +12 -7
- package/index.d.ts.map +1 -1
- package/index.js +10 -5
- package/index.js.map +1 -1
- package/internal.d.ts +115 -1
- package/internal.d.ts.map +1 -1
- package/internal.js +108 -0
- package/internal.js.map +1 -1
- package/lite/announce.d.ts +60 -10
- package/lite/announce.d.ts.map +1 -1
- package/lite/announce.js +176 -31
- package/lite/announce.js.map +1 -1
- package/lite/connection.d.ts +9 -59
- package/lite/connection.d.ts.map +1 -1
- package/lite/connection.js +34 -35
- package/lite/connection.js.map +1 -1
- package/lite/datagram.d.ts +3 -2
- package/lite/datagram.d.ts.map +1 -1
- package/lite/datagram.js +7 -8
- package/lite/datagram.js.map +1 -1
- package/lite/fetch.d.ts +15 -1
- package/lite/fetch.d.ts.map +1 -1
- package/lite/fetch.js +39 -7
- package/lite/fetch.js.map +1 -1
- package/lite/goaway.d.ts.map +1 -1
- package/lite/goaway.js +7 -1
- package/lite/goaway.js.map +1 -1
- package/lite/group.d.ts +30 -12
- package/lite/group.d.ts.map +1 -1
- package/lite/group.js +68 -26
- package/lite/group.js.map +1 -1
- package/lite/message.d.ts +2 -2
- package/lite/message.d.ts.map +1 -1
- package/lite/message.js +14 -5
- package/lite/message.js.map +1 -1
- package/lite/priority.d.ts +1 -61
- package/lite/priority.d.ts.map +1 -1
- package/lite/priority.js +4 -5
- package/lite/priority.js.map +1 -1
- package/lite/publisher.d.ts +1 -69
- package/lite/publisher.d.ts.map +1 -1
- package/lite/publisher.js +630 -262
- package/lite/publisher.js.map +1 -1
- package/lite/setup.d.ts +8 -8
- package/lite/setup.d.ts.map +1 -1
- package/lite/setup.js +33 -30
- package/lite/setup.js.map +1 -1
- package/lite/subscribe.d.ts +71 -17
- package/lite/subscribe.d.ts.map +1 -1
- package/lite/subscribe.js +205 -53
- package/lite/subscribe.js.map +1 -1
- package/lite/subscriber.d.ts +15 -56
- package/lite/subscriber.d.ts.map +1 -1
- package/lite/subscriber.js +451 -247
- package/lite/subscriber.js.map +1 -1
- package/lite/track.d.ts +4 -10
- package/lite/track.d.ts.map +1 -1
- package/lite/track.js +34 -29
- package/lite/track.js.map +1 -1
- package/lite/version.d.ts +42 -6
- package/lite/version.d.ts.map +1 -1
- package/lite/version.js +126 -10
- package/lite/version.js.map +1 -1
- package/origin.d.ts +256 -29
- package/origin.d.ts.map +1 -1
- package/origin.js +1427 -37
- package/origin.js.map +1 -1
- package/package.json +8 -3
- package/path.d.ts +25 -7
- package/path.d.ts.map +1 -1
- package/path.js +5 -3
- package/path.js.map +1 -1
- package/stream.d.ts +73 -14
- package/stream.d.ts.map +1 -1
- package/stream.js +372 -141
- package/stream.js.map +1 -1
- package/tail.d.ts +18 -0
- package/tail.d.ts.map +1 -0
- package/tail.js +167 -0
- package/tail.js.map +1 -0
- package/time.d.ts +15 -2
- package/time.d.ts.map +1 -1
- package/time.js +28 -9
- package/time.js.map +1 -1
- package/track.d.ts +211 -83
- package/track.d.ts.map +1 -1
- package/track.js +816 -205
- package/track.js.map +1 -1
- package/util/abort.d.ts +2 -0
- package/util/abort.d.ts.map +1 -0
- package/util/abort.js +20 -0
- package/util/abort.js.map +1 -0
- package/util/log.d.ts +5 -0
- package/util/log.d.ts.map +1 -0
- package/util/log.js +17 -0
- package/util/log.js.map +1 -0
- package/util/u64.d.ts +39 -0
- package/util/u64.d.ts.map +1 -0
- package/util/u64.js +83 -0
- package/util/u64.js.map +1 -0
- package/util/varint.d.ts +29 -0
- package/util/varint.d.ts.map +1 -0
- package/util/varint.js +198 -0
- package/util/varint.js.map +1 -0
- package/varint.d.ts +10 -6
- package/varint.d.ts.map +1 -1
- package/varint.js +40 -237
- package/varint.js.map +1 -1
- package/wire.d.ts +80 -0
- package/wire.d.ts.map +1 -0
- package/wire.js +32 -0
- package/wire.js.map +1 -0
- package/zod.d.ts +1 -1
- package/zod.d.ts.map +1 -1
- package/zod.js.map +1 -1
- package/mock.d.ts +0 -66
- package/mock.d.ts.map +0 -1
- package/mock.js +0 -243
- package/mock.js.map +0 -1
package/track.js
CHANGED
|
@@ -5,43 +5,70 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
import { Once, Signal } from "@norskvideo/moq-signals";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
import { GroupTooLarge, TooFarBehind } from "./error.js";
|
|
9
|
+
import { Producer as GroupProducer } from "./group.js";
|
|
10
|
+
import { groupBounds, hooks, } from "./internal.js";
|
|
11
|
+
import { Milli, Timescale } from "./time.js";
|
|
12
|
+
import { registerTrackConsumer } from "./wire.js";
|
|
13
|
+
// The largest delay setTimeout keeps: anything more truncates to a signed 32-bit int
|
|
14
|
+
// and fires right away.
|
|
15
|
+
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
16
|
+
// The cache scans at most this many times per retention window.
|
|
17
|
+
const PRUNE_SLICES = 8;
|
|
18
|
+
/** Default {@link Info.maxAge} window (milliseconds) when the publisher does not set one. */
|
|
19
|
+
export const DEFAULT_MAX_AGE_MS = Milli(5000);
|
|
20
|
+
// The higher-first midpoint. IETF flips priority (lower first), so this goes out as 128, the
|
|
21
|
+
// draft's usual publisher priority, while moq-lite carries 127 as written: one urgency on both.
|
|
22
|
+
const DEFAULT_PRIORITY = 127;
|
|
23
|
+
/** Maximum buffered datagrams per subscriber; mirrors Rust's bounded send buffer. */
|
|
24
|
+
const MAX_DATAGRAMS = 64;
|
|
21
25
|
/**
|
|
22
26
|
* Sanity cap on a datagram payload: the QUIC DATAGRAM frame ceiling. The real limit is
|
|
23
27
|
* per-hop (the negotiated transport datagram size minus a small header) and oversize
|
|
24
28
|
* datagrams are dropped there; a payload above this cap could never fit anywhere.
|
|
25
29
|
*/
|
|
26
30
|
const MAX_DATAGRAM_BYTES = 65535;
|
|
31
|
+
// Normalize a latency budget for the wire, which carries it as an unsigned varint.
|
|
32
|
+
//
|
|
33
|
+
// Callers derive it from measurements (a jitter estimate scaled off RTT), so a fractional
|
|
34
|
+
// millisecond is expected; ceil rather than round, because a budget shortened by rounding
|
|
35
|
+
// skips a group the subscriber still wants. Anything that is not a duration is refused
|
|
36
|
+
// here, where the field is named, rather than deep in the encoder.
|
|
37
|
+
function maxAgeMillis(value) {
|
|
38
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
39
|
+
throw new RangeError(`maxAge must be a non-negative number of milliseconds: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
const millis = Math.ceil(value);
|
|
42
|
+
if (!Number.isSafeInteger(millis)) {
|
|
43
|
+
throw new RangeError(`maxAge exceeds the safe integer millisecond range: ${value}`);
|
|
44
|
+
}
|
|
45
|
+
return Milli(millis);
|
|
46
|
+
}
|
|
47
|
+
function priorityByte(value) {
|
|
48
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
49
|
+
throw new RangeError(`priority must be an integer in 0..=255: ${value}`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
27
53
|
/** Fill in any unset {@link Info} fields with their defaults. */
|
|
28
54
|
export function infoDefaults(info = {}) {
|
|
29
55
|
return {
|
|
30
|
-
timescale: info.timescale ?? Timescale.MILLI,
|
|
31
|
-
|
|
32
|
-
priority: info.priority ??
|
|
33
|
-
ordered: info.ordered ?? false,
|
|
56
|
+
timescale: Timescale(info.timescale ?? Timescale.MILLI),
|
|
57
|
+
maxAge: maxAgeMillis(info.maxAge ?? DEFAULT_MAX_AGE_MS),
|
|
58
|
+
priority: priorityByte(info.priority ?? DEFAULT_PRIORITY),
|
|
34
59
|
};
|
|
35
60
|
}
|
|
36
61
|
// Materialize the defaults at the model boundary so every layer observes a complete
|
|
37
62
|
// subscription rather than interpreting an omitted field differently.
|
|
38
63
|
function subscriptionDefaults(subscription = {}) {
|
|
64
|
+
const bounds = groupBounds(subscription.groups ?? {});
|
|
39
65
|
return {
|
|
40
|
-
priority: subscription.priority ?? 0,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
66
|
+
priority: priorityByte(subscription.priority ?? 0),
|
|
67
|
+
maxAge: maxAgeMillis(subscription.maxAge ?? Milli.zero),
|
|
68
|
+
groups: {
|
|
69
|
+
start: subscription.groups?.start === undefined ? undefined : { included: bounds.start },
|
|
70
|
+
end: bounds.end === undefined ? undefined : { excluded: bounds.end },
|
|
71
|
+
},
|
|
45
72
|
};
|
|
46
73
|
}
|
|
47
74
|
// Aggregate the preferences of every live subscriber, matching Rust's Subscription::poll_combined.
|
|
@@ -56,25 +83,22 @@ function combineSubscriptions(states) {
|
|
|
56
83
|
continue;
|
|
57
84
|
}
|
|
58
85
|
combined.priority = Math.max(combined.priority ?? 0, subscription.priority ?? 0);
|
|
59
|
-
combined.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
combined.endGroup = Math.max(combined.endGroup, subscription.endGroup);
|
|
72
|
-
}
|
|
86
|
+
combined.maxAge = Milli(Math.max(combined.maxAge ?? Milli.zero, subscription.maxAge ?? Milli.zero));
|
|
87
|
+
// A floor only restricts, so a subscriber without one clears the aggregate:
|
|
88
|
+
// its budget may reach below any floor the others set.
|
|
89
|
+
const a = groupBounds(combined.groups ?? {});
|
|
90
|
+
const b = groupBounds(subscription.groups ?? {});
|
|
91
|
+
combined.groups = {
|
|
92
|
+
start: combined.groups?.start === undefined || subscription.groups?.start === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: { included: Math.min(a.start, b.start) },
|
|
95
|
+
end: a.end === undefined || b.end === undefined ? undefined : { excluded: Math.max(a.end, b.end) },
|
|
96
|
+
};
|
|
73
97
|
}
|
|
74
98
|
return combined;
|
|
75
99
|
}
|
|
76
100
|
/**
|
|
77
|
-
* A request for a track the peer wants,
|
|
101
|
+
* A request for a track the peer wants, delivered to the publishing wire layer.
|
|
78
102
|
*
|
|
79
103
|
* Created internally by the broadcast when a subscription (or info lookup) needs a track
|
|
80
104
|
* served; answer it with {@link accept} or {@link reject}.
|
|
@@ -86,13 +110,17 @@ export class Request {
|
|
|
86
110
|
name;
|
|
87
111
|
#producer;
|
|
88
112
|
#sequences;
|
|
113
|
+
#pending;
|
|
89
114
|
constructor(options) {
|
|
90
115
|
this.name = options.name;
|
|
91
116
|
this.#producer = options.producer;
|
|
92
117
|
this.#sequences = options.sequences;
|
|
118
|
+
this.#pending = options.pending;
|
|
119
|
+
this.#pending.add(this);
|
|
93
120
|
}
|
|
94
121
|
static {
|
|
95
122
|
hooks.makeRequest = (options) => new Request(options);
|
|
123
|
+
hooks.pendingTrackProducer = (request) => request.#producer;
|
|
96
124
|
}
|
|
97
125
|
/** The aggregate subscription requested for this track. */
|
|
98
126
|
get subscription() {
|
|
@@ -104,11 +132,13 @@ export class Request {
|
|
|
104
132
|
}
|
|
105
133
|
/** Accept the request, committing the track's immutable {@link Info}. */
|
|
106
134
|
accept(info = {}) {
|
|
135
|
+
this.#pending.delete(this);
|
|
107
136
|
bindProducer(this.name, this.#producer, this.#sequences);
|
|
108
137
|
return this.#producer.accept(info);
|
|
109
138
|
}
|
|
110
139
|
/** Reject the request, closing the track optionally with an error. */
|
|
111
140
|
reject(err) {
|
|
141
|
+
this.#pending.delete(this);
|
|
112
142
|
this.#producer.close(err);
|
|
113
143
|
}
|
|
114
144
|
}
|
|
@@ -125,7 +155,17 @@ export class Consumer {
|
|
|
125
155
|
this.name = name;
|
|
126
156
|
this.#broadcast = broadcast;
|
|
127
157
|
}
|
|
128
|
-
|
|
158
|
+
static {
|
|
159
|
+
registerTrackConsumer((name, broadcast) => new Consumer(name, broadcast));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Open a live subscription to the track.
|
|
163
|
+
*
|
|
164
|
+
* The cursor starts at the group the subscription named (its floor), or 0.
|
|
165
|
+
* {@link Subscription.maxAge} is what asks for data: delivery skips everything above
|
|
166
|
+
* the floor that the budget convicts, so the default budget of zero delivers only the
|
|
167
|
+
* latest group and a larger one reaches back over what it can still use.
|
|
168
|
+
*/
|
|
129
169
|
subscribe(options) {
|
|
130
170
|
return this.#broadcast.subscribe(this.name, options);
|
|
131
171
|
}
|
|
@@ -138,15 +178,57 @@ export class Consumer {
|
|
|
138
178
|
return this.#broadcast.fetchGroup(this.name, sequence, options);
|
|
139
179
|
}
|
|
140
180
|
}
|
|
181
|
+
// The index of the first timeline group at or after `sequence`.
|
|
182
|
+
function timelineIndex(timeline, sequence) {
|
|
183
|
+
let lo = 0;
|
|
184
|
+
let hi = timeline.length;
|
|
185
|
+
while (lo < hi) {
|
|
186
|
+
const mid = (lo + hi) >>> 1;
|
|
187
|
+
if (timeline[mid].sequence < sequence)
|
|
188
|
+
lo = mid + 1;
|
|
189
|
+
else
|
|
190
|
+
hi = mid;
|
|
191
|
+
}
|
|
192
|
+
return lo;
|
|
193
|
+
}
|
|
194
|
+
// Add a group to a sorted timeline, replacing any group with the same sequence. Groups
|
|
195
|
+
// usually arrive in order, so the append is checked first.
|
|
196
|
+
function timelineInsert(timeline, group) {
|
|
197
|
+
const last = timeline.at(-1);
|
|
198
|
+
if (!last || last.sequence < group.sequence) {
|
|
199
|
+
timeline.push(group);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const index = timelineIndex(timeline, group.sequence);
|
|
203
|
+
if (timeline[index]?.sequence === group.sequence)
|
|
204
|
+
timeline[index] = group;
|
|
205
|
+
else
|
|
206
|
+
timeline.splice(index, 0, group);
|
|
207
|
+
}
|
|
141
208
|
// The shared state behind a Producer / Subscriber pair. Package-internal
|
|
142
209
|
// wiring, unexported so it never appears in the published type declarations.
|
|
143
210
|
class TrackState {
|
|
144
211
|
/** The producer fanning into this sink, so a subscriber can mint a sibling of itself. */
|
|
145
212
|
producer;
|
|
146
213
|
groups = new Signal([]);
|
|
147
|
-
|
|
214
|
+
// Every group still in the producer's replay cache, including groups this
|
|
215
|
+
// subscriber already consumed, sorted by sequence. Drift anchors have the same
|
|
216
|
+
// lifetime as content. Sorted so the latency guard, evaluated per group and per
|
|
217
|
+
// arrival, searches it instead of scanning the whole retained window.
|
|
218
|
+
timeline = [];
|
|
219
|
+
// First timestamps mutate group state rather than track state, so held groups
|
|
220
|
+
// watch this revision as well as arrivals when enforcing latency after handoff.
|
|
221
|
+
timelineChanged = new Signal(0);
|
|
222
|
+
/** Best-effort datagram channel, parallel to {@link groups}; a bounded send buffer per subscriber. */
|
|
148
223
|
datagrams = new Signal([]);
|
|
149
224
|
latest;
|
|
225
|
+
/**
|
|
226
|
+
* The exclusive final boundary, declared by {@link Producer.finishAt} or stamped by a
|
|
227
|
+
* clean close as one past the highest sequence produced. Groups and datagrams share the
|
|
228
|
+
* namespace, so this can exceed `latest + 1` (which only tracks groups). Mirrors the
|
|
229
|
+
* Rust `final_sequence`.
|
|
230
|
+
*/
|
|
231
|
+
final = new Signal(undefined);
|
|
150
232
|
closed = new Once();
|
|
151
233
|
update;
|
|
152
234
|
/** Resolved once the producer commits the immutable properties. */
|
|
@@ -190,6 +272,11 @@ function bindProducer(name, producer, sequences) {
|
|
|
190
272
|
bindProducerSequence(producer, shared);
|
|
191
273
|
}
|
|
192
274
|
let bindProducerSequence;
|
|
275
|
+
// The sequence-order cursor lives inside `Subscriber` (it shares the group buffer and the
|
|
276
|
+
// drift anchor with the arrival cursor), so `Ordered` reaches it through this bridge,
|
|
277
|
+
// assigned in the class's static block.
|
|
278
|
+
let makeOrdered;
|
|
279
|
+
let ordered_;
|
|
193
280
|
// Constructs a Subscriber from within this module without exposing a public
|
|
194
281
|
// constructor that would leak the unexported TrackState. Assigned in the class's
|
|
195
282
|
// static block.
|
|
@@ -201,7 +288,7 @@ let makeSubscriber;
|
|
|
201
288
|
* subscription the publisher serves from it) gets an independent
|
|
202
289
|
* {@link Subscriber} that receives a full copy of the groups, each with its own
|
|
203
290
|
* read cursor. Groups are mirrored into every live subscriber and retained for the
|
|
204
|
-
* track's `
|
|
291
|
+
* track's `maxAge` window so a late subscriber replays the recent groups.
|
|
205
292
|
*
|
|
206
293
|
* Obtained from {@link Request.accept} (the wire asks the application for a track to
|
|
207
294
|
* serve) or constructed directly for an in-process track.
|
|
@@ -213,11 +300,24 @@ export class Producer {
|
|
|
213
300
|
// read mirrored sinks, never this state directly.
|
|
214
301
|
#state = new TrackState();
|
|
215
302
|
#sequence = { next: 0 };
|
|
303
|
+
// One past the highest group or datagram this producer received, like the Rust
|
|
304
|
+
// `max_sequence`. The shared counter above can run ahead of it: sibling producers of
|
|
305
|
+
// the same track advance it too.
|
|
306
|
+
#received = 0;
|
|
216
307
|
// Recently written source groups, retained for replay to late subscribers and
|
|
217
|
-
// pruned once
|
|
308
|
+
// pruned once idle for longer than the cache window. Each entry tracks the mirror
|
|
218
309
|
// it handed to every sink so eviction can drop them too: otherwise a slow consumer
|
|
219
310
|
// that never reads would pin old groups (and their frame bytes) forever.
|
|
220
311
|
#cache = [];
|
|
312
|
+
// The same entries by sequence, so a write finds a duplicate without a scan.
|
|
313
|
+
#cached = new Map();
|
|
314
|
+
// When the cache was last scanned. See #prune.
|
|
315
|
+
#pruned = Number.NEGATIVE_INFINITY;
|
|
316
|
+
// Wakeup for the next entry due to age out. Writes settle retention inline, but a
|
|
317
|
+
// publisher that stalls stops writing, so without this an abandoned group (and any
|
|
318
|
+
// reader parked in it) would wait for a write that never comes.
|
|
319
|
+
#pruneTimer;
|
|
320
|
+
#pruneTimerAt = 0;
|
|
221
321
|
// One independent downstream state per live subscriber.
|
|
222
322
|
#sinks = new Set();
|
|
223
323
|
// Whether any subscriber is currently attached. Exposed as {@link used}; the consumer wire
|
|
@@ -238,6 +338,15 @@ export class Producer {
|
|
|
238
338
|
info() {
|
|
239
339
|
return resolveInfo(this.#state);
|
|
240
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Publisher priority from the committed {@link Info}, or the default before {@link accept}.
|
|
343
|
+
*
|
|
344
|
+
* Higher is served first. Hang publishers set this from `Catalog.PRIORITY` so
|
|
345
|
+
* audio outranks video on the wire and in the bandwidth allocator.
|
|
346
|
+
*/
|
|
347
|
+
get priority() {
|
|
348
|
+
return this.#state.info.peek()?.priority ?? DEFAULT_PRIORITY;
|
|
349
|
+
}
|
|
241
350
|
/**
|
|
242
351
|
* Settles once the track closes: `null` on a clean close, or the abort {@link Error}.
|
|
243
352
|
* Peek it synchronously (`undefined` while open), observe it reactively, or `await` it.
|
|
@@ -254,14 +363,24 @@ export class Producer {
|
|
|
254
363
|
}
|
|
255
364
|
/** Commit the immutable publisher properties, resolving {@link info}. Returns `this`. */
|
|
256
365
|
accept(info = {}) {
|
|
366
|
+
if (this.#state.closed.peek() !== undefined)
|
|
367
|
+
return this;
|
|
257
368
|
const resolved = infoDefaults(info);
|
|
258
369
|
this.#state.info.set(resolved);
|
|
259
370
|
// Propagate to any sink handed out before accept (the on-demand path).
|
|
260
371
|
for (const sink of this.#sinks)
|
|
261
372
|
sink.info.set(resolved);
|
|
373
|
+
this.#updateSubscription();
|
|
262
374
|
return this;
|
|
263
375
|
}
|
|
264
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* An independent {@link Subscriber} reading this track's groups.
|
|
378
|
+
*
|
|
379
|
+
* Its cursor starts at the group the subscription named (its floor), or 0.
|
|
380
|
+
* {@link Subscription.maxAge} is what asks for data: delivery skips everything above
|
|
381
|
+
* the floor that the budget convicts, so the default budget of zero delivers only the
|
|
382
|
+
* latest group and a larger one reaches back over what it can still use.
|
|
383
|
+
*/
|
|
265
384
|
subscribe(options = {}) {
|
|
266
385
|
const sink = new TrackState(options);
|
|
267
386
|
this.#addSink(sink);
|
|
@@ -310,6 +429,15 @@ export class Producer {
|
|
|
310
429
|
forward();
|
|
311
430
|
this.#sinks.delete(sink);
|
|
312
431
|
this.#updateSubscription();
|
|
432
|
+
// Update demand: once the last subscriber leaves, the consumer wire (watching
|
|
433
|
+
// {@link unused}) tears the upstream down instead of downloading to nobody.
|
|
434
|
+
this.#used.set(this.#sinks.size > 0);
|
|
435
|
+
// The producer closing every sink keeps its mirrors tracked, so what the sink
|
|
436
|
+
// still buffers ages out with the cache instead of staying pinned.
|
|
437
|
+
if (this.#state.closed.peek() !== undefined) {
|
|
438
|
+
dispose();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
313
441
|
for (const entry of this.#cache) {
|
|
314
442
|
const mirror = entry.mirrors.get(sink);
|
|
315
443
|
if (mirror) {
|
|
@@ -320,21 +448,23 @@ export class Producer {
|
|
|
320
448
|
for (const group of sink.groups.peek())
|
|
321
449
|
group.close(abort);
|
|
322
450
|
dispose();
|
|
323
|
-
// Update demand: once the last subscriber leaves, the consumer wire (watching
|
|
324
|
-
// {@link unused}) tears the upstream down instead of downloading to nobody.
|
|
325
|
-
this.#used.set(this.#sinks.size > 0);
|
|
326
451
|
});
|
|
327
452
|
}
|
|
328
453
|
this.#prune();
|
|
329
454
|
for (const entry of this.#cache)
|
|
330
455
|
this.#mirror(entry, sink);
|
|
456
|
+
sink.final.set(this.#state.final.peek());
|
|
331
457
|
if (closed !== undefined)
|
|
332
458
|
closeTrackState(sink, closed instanceof Error ? closed : undefined);
|
|
333
459
|
}
|
|
334
460
|
// Recompute from every live sink because an update or close can narrow as well as widen
|
|
335
461
|
// the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE.
|
|
336
462
|
#updateSubscription() {
|
|
337
|
-
|
|
463
|
+
const combined = combineSubscriptions(this.#sinks);
|
|
464
|
+
const retained = this.#state.info.peek()?.maxAge;
|
|
465
|
+
if (combined && retained !== undefined)
|
|
466
|
+
combined.maxAge = Milli.min(combined.maxAge ?? Milli.zero, retained);
|
|
467
|
+
this.#state.update.set(combined);
|
|
338
468
|
}
|
|
339
469
|
// Mirror a cached source group into a sink. The mirror fills synchronously as the
|
|
340
470
|
// source is written and keeps its own read cursor; frame bytes are shared by
|
|
@@ -342,6 +472,8 @@ export class Producer {
|
|
|
342
472
|
#mirror(entry, sink) {
|
|
343
473
|
const dst = entry.group.mirror();
|
|
344
474
|
entry.mirrors.set(sink, dst);
|
|
475
|
+
timelineInsert(sink.timeline, dst);
|
|
476
|
+
void dst.readable().then(() => sink.timelineChanged.update((revision) => revision + 1));
|
|
345
477
|
sink.latest = Math.max(sink.latest ?? 0, dst.sequence);
|
|
346
478
|
sink.groups.mutate((groups) => {
|
|
347
479
|
groups.push(dst);
|
|
@@ -350,43 +482,135 @@ export class Producer {
|
|
|
350
482
|
}
|
|
351
483
|
// Drop a cached group's mirror from every sink so no consumer can pin it.
|
|
352
484
|
#evict(entry) {
|
|
485
|
+
// Reclaiming a group the publisher never closed ends it as the gap it is, before
|
|
486
|
+
// the mirrors below would otherwise report a clean finish to a reader that had
|
|
487
|
+
// drained it. The usual case, an already-closed group aging out, keeps its own
|
|
488
|
+
// terminal state.
|
|
489
|
+
if (!entry.group.isClosed)
|
|
490
|
+
entry.group.close(new TooFarBehind());
|
|
491
|
+
const mirrors = [...entry.mirrors.values()];
|
|
492
|
+
for (const mirror of mirrors)
|
|
493
|
+
hooks.evictGroup(mirror);
|
|
494
|
+
this.#unlink(entry);
|
|
495
|
+
for (const mirror of mirrors)
|
|
496
|
+
mirror.close();
|
|
497
|
+
}
|
|
498
|
+
// Take a cached group's mirrors out of every sink, so a subscriber can no longer
|
|
499
|
+
// receive them. A reader already holding one keeps it as is.
|
|
500
|
+
#unlink(entry) {
|
|
353
501
|
for (const [sink, mirror] of entry.mirrors) {
|
|
354
502
|
sink.groups.mutate((groups) => {
|
|
355
503
|
const i = groups.indexOf(mirror);
|
|
356
504
|
if (i >= 0)
|
|
357
505
|
groups.splice(i, 1);
|
|
358
506
|
});
|
|
359
|
-
mirror.
|
|
507
|
+
const index = timelineIndex(sink.timeline, mirror.sequence);
|
|
508
|
+
if (sink.timeline[index] === mirror)
|
|
509
|
+
sink.timeline.splice(index, 1);
|
|
360
510
|
}
|
|
361
511
|
entry.mirrors.clear();
|
|
362
512
|
}
|
|
363
|
-
//
|
|
513
|
+
// Take a cached group out of the cache lookups.
|
|
514
|
+
#uncache(entry) {
|
|
515
|
+
this.#cache.splice(this.#cache.indexOf(entry), 1);
|
|
516
|
+
this.#cached.delete(entry.group.sequence);
|
|
517
|
+
}
|
|
518
|
+
// The one group retention never takes: the newest, while it is still open. That is
|
|
519
|
+
// the live edge a publisher is appending to, and a track may legitimately keep it
|
|
520
|
+
// open across a long quiet stretch (a catalog snapshot, a JSON stream). Every other
|
|
521
|
+
// open group is an abandoned one, and ages out like a closed one.
|
|
522
|
+
#liveEdge() {
|
|
523
|
+
const latest = this.#cache.at(-1)?.group;
|
|
524
|
+
return latest?.closed.peek() === undefined ? latest : undefined;
|
|
525
|
+
}
|
|
526
|
+
// Evict cached groups idle for longer than the cache window. Idle means nothing
|
|
527
|
+
// written, so an abandoned open group ages out instead of pinning its buffer (and
|
|
528
|
+
// any reader parked in it) forever.
|
|
529
|
+
//
|
|
530
|
+
// Scans at most once per slice of the window, so a track publishing faster than that
|
|
531
|
+
// evicts a run of groups per scan instead of scanning everything to evict one per
|
|
532
|
+
// write. A group can outlive the window by up to one slice.
|
|
364
533
|
#prune() {
|
|
365
|
-
const
|
|
366
|
-
const
|
|
534
|
+
const maxAgeMs = this.#state.info.peek()?.maxAge ?? DEFAULT_MAX_AGE_MS;
|
|
535
|
+
const now = performance.now();
|
|
536
|
+
const slice = maxAgeMs / PRUNE_SLICES;
|
|
537
|
+
if (now < this.#pruned + slice) {
|
|
538
|
+
// Something may have come due since the last scan, so make sure another follows.
|
|
539
|
+
this.#wake(this.#pruned + slice);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.#pruned = now;
|
|
543
|
+
const cutoff = now - maxAgeMs;
|
|
544
|
+
const live = this.#liveEdge();
|
|
545
|
+
let oldest;
|
|
367
546
|
const retained = [];
|
|
368
547
|
for (const entry of this.#cache) {
|
|
369
|
-
if (entry.
|
|
548
|
+
if (entry.group === live) {
|
|
370
549
|
retained.push(entry);
|
|
371
|
-
continue;
|
|
372
550
|
}
|
|
373
|
-
|
|
551
|
+
else if (entry.group.activity >= cutoff) {
|
|
552
|
+
retained.push(entry);
|
|
553
|
+
if (oldest === undefined || entry.group.activity < oldest)
|
|
554
|
+
oldest = entry.group.activity;
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
this.#cached.delete(entry.group.sequence);
|
|
558
|
+
this.#evict(entry);
|
|
559
|
+
}
|
|
374
560
|
}
|
|
375
561
|
this.#cache = retained;
|
|
562
|
+
// Replace the wakeup with one for the next entry due to age out. Writes settle
|
|
563
|
+
// retention inline, so this only has to cover the case no write follows. Cheap to
|
|
564
|
+
// over-arm: an entry written since is retained and re-armed.
|
|
565
|
+
clearTimeout(this.#pruneTimer);
|
|
566
|
+
this.#pruneTimer = undefined;
|
|
567
|
+
if (oldest !== undefined)
|
|
568
|
+
this.#wake(Math.max(oldest + maxAgeMs, now + slice));
|
|
569
|
+
}
|
|
570
|
+
// Arm the prune wakeup for `at`, unless one is already armed sooner. Kept after a close,
|
|
571
|
+
// until the cache empties, so what the track left behind still ages out.
|
|
572
|
+
#wake(at) {
|
|
573
|
+
if (this.#pruneTimer !== undefined && this.#pruneTimerAt <= at)
|
|
574
|
+
return;
|
|
575
|
+
clearTimeout(this.#pruneTimer);
|
|
576
|
+
// setTimeout truncates its delay to a signed 32-bit int, so a longer window
|
|
577
|
+
// would fire immediately and spin. Wake at the cap instead and re-arm: #prune
|
|
578
|
+
// retains anything still fresh, so the extra wakeups are the only cost.
|
|
579
|
+
const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, at - performance.now()));
|
|
580
|
+
const timer = setTimeout(() => {
|
|
581
|
+
this.#pruneTimer = undefined;
|
|
582
|
+
this.#prune();
|
|
583
|
+
}, delay);
|
|
584
|
+
// A cache prune is never a reason to hold a Node/Bun process open.
|
|
585
|
+
timer.unref?.();
|
|
586
|
+
this.#pruneTimer = timer;
|
|
587
|
+
this.#pruneTimerAt = at;
|
|
376
588
|
}
|
|
377
589
|
// Retain a source group and fan it out to every live sink.
|
|
378
590
|
#publish(group) {
|
|
379
|
-
const entry = { group,
|
|
591
|
+
const entry = { group, mirrors: new Map() };
|
|
380
592
|
this.#cache.push(entry);
|
|
381
|
-
this.#
|
|
593
|
+
this.#cached.set(group.sequence, entry);
|
|
594
|
+
this.#received = Math.max(this.#received, group.sequence + 1);
|
|
382
595
|
for (const sink of this.#sinks)
|
|
383
596
|
this.#mirror(entry, sink);
|
|
597
|
+
// Give held mirrors the new live edge before pruning their timeline entry,
|
|
598
|
+
// so their latency guard can preserve a terminal expiry verdict.
|
|
599
|
+
this.#prune();
|
|
384
600
|
}
|
|
385
|
-
|
|
386
|
-
|
|
601
|
+
// Refuse a write once the track is closed, or at or past its declared end.
|
|
602
|
+
#writable(sequence) {
|
|
387
603
|
if (this.#state.closed.peek() !== undefined)
|
|
388
604
|
throw new Error("track is closed");
|
|
605
|
+
const final = this.#state.final.peek();
|
|
606
|
+
if (final !== undefined && sequence >= final) {
|
|
607
|
+
throw new Error(`sequence ${sequence} is at or past the track's end ${final}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Append a new group with the next sequence number. */
|
|
611
|
+
appendGroup() {
|
|
389
612
|
const sequence = this.#sequence;
|
|
613
|
+
this.#writable(sequence.next);
|
|
390
614
|
const group = new GroupProducer(sequence.next);
|
|
391
615
|
sequence.next = group.sequence + 1;
|
|
392
616
|
this.#publish(group);
|
|
@@ -401,16 +625,14 @@ export class Producer {
|
|
|
401
625
|
* entry is already gone, so a long-evicted sequence is accepted as new.
|
|
402
626
|
*/
|
|
403
627
|
writeGroup(group) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const entry = this.#cache[existing];
|
|
409
|
-
if (!(entry.group.closed.peek() instanceof Error)) {
|
|
628
|
+
this.#writable(group.sequence);
|
|
629
|
+
const existing = this.#cached.get(group.sequence);
|
|
630
|
+
if (existing) {
|
|
631
|
+
if (!(existing.group.closed.peek() instanceof Error)) {
|
|
410
632
|
throw new Error(`duplicate group: sequence=${group.sequence}`);
|
|
411
633
|
}
|
|
412
|
-
this.#evict(
|
|
413
|
-
this.#
|
|
634
|
+
this.#evict(existing);
|
|
635
|
+
this.#uncache(existing);
|
|
414
636
|
}
|
|
415
637
|
// Only advance the shared counter upward (for appendGroup auto-increment).
|
|
416
638
|
const sequence = this.#sequence;
|
|
@@ -422,13 +644,12 @@ export class Producer {
|
|
|
422
644
|
// Fan a datagram out to every live subscriber, dropping the oldest once the ring is full.
|
|
423
645
|
// Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache).
|
|
424
646
|
#publishDatagram(datagram) {
|
|
425
|
-
|
|
647
|
+
this.#received = Math.max(this.#received, datagram.sequence + 1);
|
|
426
648
|
for (const sink of this.#sinks) {
|
|
427
649
|
sink.datagrams.mutate((list) => {
|
|
428
|
-
list.
|
|
429
|
-
// Drop anything older than the send-buffer window.
|
|
430
|
-
while (list.length > 0 && now - list[0].time > MAX_DATAGRAM_AGE_MS)
|
|
650
|
+
if (list.length === MAX_DATAGRAMS)
|
|
431
651
|
list.shift();
|
|
652
|
+
list.push(datagram);
|
|
432
653
|
});
|
|
433
654
|
}
|
|
434
655
|
}
|
|
@@ -442,48 +663,106 @@ export class Producer {
|
|
|
442
663
|
* keep datagram payloads small (e.g. a single audio frame). Datagrams are never delivered
|
|
443
664
|
* over IETF moq-transport or stream-only transports (the WebSocket fallback). A payload over
|
|
444
665
|
* 65535 bytes (the QUIC datagram frame ceiling) throws. An origin publisher uses this; a
|
|
445
|
-
* relay preserving upstream numbering uses {@link
|
|
666
|
+
* relay preserving upstream numbering uses {@link insertDatagram}.
|
|
446
667
|
*/
|
|
447
668
|
appendDatagram(timestamp, payload) {
|
|
448
|
-
if (this.#state.closed.peek() !== undefined)
|
|
449
|
-
throw new Error("track is closed");
|
|
450
|
-
if (payload.byteLength > MAX_DATAGRAM_BYTES)
|
|
451
|
-
throw new Error("datagram payload too large");
|
|
452
669
|
const counter = this.#sequence;
|
|
453
670
|
const sequence = counter.next;
|
|
671
|
+
this.#writable(sequence);
|
|
672
|
+
if (payload.byteLength > MAX_DATAGRAM_BYTES)
|
|
673
|
+
throw new Error("datagram payload too large");
|
|
454
674
|
counter.next = sequence + 1;
|
|
455
675
|
this.#publishDatagram({ sequence, timestamp, payload });
|
|
456
676
|
return sequence;
|
|
457
677
|
}
|
|
458
678
|
/**
|
|
459
|
-
*
|
|
679
|
+
* Insert a datagram with an explicit sequence number.
|
|
460
680
|
*
|
|
461
681
|
* Preserves the supplied sequence (advancing the shared counter if needed) so a relay can
|
|
462
682
|
* forward a datagram without renumbering it. The size limits of {@link appendDatagram}
|
|
463
683
|
* apply. Most origin publishers want {@link appendDatagram} instead.
|
|
464
684
|
*/
|
|
465
|
-
|
|
685
|
+
insertDatagram(sequence, timestamp, payload) {
|
|
686
|
+
this.#writable(sequence);
|
|
687
|
+
if (payload.byteLength > MAX_DATAGRAM_BYTES)
|
|
688
|
+
throw new Error("datagram payload too large");
|
|
689
|
+
const counter = this.#sequence;
|
|
690
|
+
if (sequence >= counter.next) {
|
|
691
|
+
counter.next = sequence + 1;
|
|
692
|
+
}
|
|
693
|
+
this.#publishDatagram({ sequence, timestamp, payload });
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Declare the track's exclusive end, possibly ahead of the live edge, mirroring the Rust
|
|
697
|
+
* `finish_at`.
|
|
698
|
+
*
|
|
699
|
+
* `final` is the first sequence that will never be produced, so a track whose last group
|
|
700
|
+
* is 89 finishes at 90. Groups and datagrams below it are still accepted; anything at or
|
|
701
|
+
* above it is refused. Unlike {@link close} it is not terminal: call `close()` once the
|
|
702
|
+
* remaining groups are written. Throws if the track is closed, already has an end, or
|
|
703
|
+
* `final` is at or below a sequence already produced.
|
|
704
|
+
*/
|
|
705
|
+
finishAt(final) {
|
|
466
706
|
if (this.#state.closed.peek() !== undefined)
|
|
467
707
|
throw new Error("track is closed");
|
|
468
|
-
if (
|
|
469
|
-
throw new
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
472
|
-
|
|
708
|
+
if (!Number.isSafeInteger(final) || final < 0)
|
|
709
|
+
throw new RangeError(`invalid track end: ${final}`);
|
|
710
|
+
const declared = this.#state.final.peek();
|
|
711
|
+
if (declared !== undefined)
|
|
712
|
+
throw new Error(`track already ends at ${declared}`);
|
|
713
|
+
if (final < this.#received) {
|
|
714
|
+
throw new Error(`track end ${final} is below the next sequence ${this.#received}`);
|
|
473
715
|
}
|
|
474
|
-
this.#
|
|
716
|
+
this.#declareFinal(final);
|
|
717
|
+
}
|
|
718
|
+
#declareFinal(final) {
|
|
719
|
+
this.#state.final.set(final);
|
|
720
|
+
for (const sink of this.#sinks)
|
|
721
|
+
sink.final.set(final);
|
|
475
722
|
}
|
|
476
|
-
/**
|
|
723
|
+
/**
|
|
724
|
+
* Close the track and every subscriber, mirroring the abort to their groups. Idempotent.
|
|
725
|
+
*
|
|
726
|
+
* A clean close keeps the end {@link finishAt} declared, or declares one past the highest
|
|
727
|
+
* sequence produced; an abort ends without one. Subscribers still draining get the
|
|
728
|
+
* finished groups first, then the end or the abort. An abort after the declared end
|
|
729
|
+
* settled (reached, with every group below it finished) is a clean close. The groups
|
|
730
|
+
* left behind still age out after the track's `maxAge`, so a stale subscriber can't pin
|
|
731
|
+
* them.
|
|
732
|
+
*/
|
|
477
733
|
close(abort) {
|
|
734
|
+
if (this.#state.closed.peek() !== undefined)
|
|
735
|
+
return;
|
|
736
|
+
if (abort && this.#settled())
|
|
737
|
+
abort = undefined;
|
|
738
|
+
if (abort === undefined && this.#state.final.peek() === undefined) {
|
|
739
|
+
this.#declareFinal(this.#received);
|
|
740
|
+
}
|
|
741
|
+
// Nobody will finish these, so a subscriber that has not taken one yet never sees it.
|
|
742
|
+
// Not evicted: a reader already holding one keeps its frames and sees the abort.
|
|
743
|
+
const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : [];
|
|
478
744
|
closeTrackState(this.#state, abort);
|
|
479
745
|
for (const { group } of this.#cache)
|
|
480
746
|
group.close(abort);
|
|
747
|
+
for (const entry of open) {
|
|
748
|
+
this.#unlink(entry);
|
|
749
|
+
this.#uncache(entry);
|
|
750
|
+
}
|
|
481
751
|
for (const sink of this.#sinks) {
|
|
482
752
|
for (const group of sink.groups.peek())
|
|
483
753
|
group.close(abort);
|
|
484
754
|
closeTrackState(sink, abort);
|
|
485
755
|
}
|
|
486
756
|
this.#sinks.clear();
|
|
757
|
+
this.#prune();
|
|
758
|
+
}
|
|
759
|
+
// Whether the declared end was reached and every cached group below it finished, so
|
|
760
|
+
// the track already holds everything it promised. Mirrors the Rust `is_settled`.
|
|
761
|
+
#settled() {
|
|
762
|
+
const final = this.#state.final.peek();
|
|
763
|
+
if (final === undefined || this.#received < final)
|
|
764
|
+
return false;
|
|
765
|
+
return this.#cache.every(({ group }) => group.sequence >= final || group.closed.peek() === null);
|
|
487
766
|
}
|
|
488
767
|
/** Append a frame as its own single-frame group. */
|
|
489
768
|
writeFrame(frame) {
|
|
@@ -523,12 +802,136 @@ export class Subscriber {
|
|
|
523
802
|
#state;
|
|
524
803
|
#nextSequence = 0;
|
|
525
804
|
#cursor = new Signal({ start: 0 });
|
|
805
|
+
#enforceLatency = true;
|
|
806
|
+
// Which cursor owns this subscription. Both cursors draw from one buffer, so the
|
|
807
|
+
// first group read commits the track to arrival order and {@link ordered} commits
|
|
808
|
+
// it to sequence order; whichever wins is the only one allowed from here on.
|
|
809
|
+
// Datagrams are a separate cursor and never commit.
|
|
810
|
+
#mode;
|
|
811
|
+
// The group the frame-level helpers are currently draining, acquired through the
|
|
812
|
+
// sequence cursor so frame reads and {@link Ordered.nextGroup} share one floor.
|
|
813
|
+
#frameGroup;
|
|
814
|
+
#drift() {
|
|
815
|
+
const { end } = this.#cursor.peek();
|
|
816
|
+
const timeline = this.#state.timeline;
|
|
817
|
+
let presentation;
|
|
818
|
+
// The edge wants the newest content that exists, so it takes the newest
|
|
819
|
+
// stamped group's latest frame: walk back from the cap to the first one.
|
|
820
|
+
for (let i = (end === undefined ? timeline.length : timelineIndex(timeline, end)) - 1; i >= 0; i--) {
|
|
821
|
+
const group = timeline[i];
|
|
822
|
+
if (group.closed.peek() instanceof Error)
|
|
823
|
+
continue;
|
|
824
|
+
const timestamp = hooks.groupTimestamp(group);
|
|
825
|
+
if (timestamp === undefined)
|
|
826
|
+
continue;
|
|
827
|
+
presentation = { sequence: group.sequence, timestamp: hooks.groupLatest(group) ?? timestamp };
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
const requested = this.#state.update.peek()?.maxAge ?? 0;
|
|
831
|
+
const retained = this.#state.info.peek()?.maxAge;
|
|
832
|
+
return {
|
|
833
|
+
budget: this.#enforceLatency
|
|
834
|
+
? retained === undefined
|
|
835
|
+
? requested
|
|
836
|
+
: Math.min(requested, retained)
|
|
837
|
+
: Number.POSITIVE_INFINITY,
|
|
838
|
+
presentation,
|
|
839
|
+
end,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
// The furthest presentation time the group at `sequence` could still reach: where its
|
|
843
|
+
// immediate servable successor begins, or undefined when nothing proves where it
|
|
844
|
+
// stops. An upper bound, deliberately: a frame's duration is not on the wire, so a
|
|
845
|
+
// group's own last timestamp says where it starts presenting, not where it ends.
|
|
846
|
+
// Only the *immediate* successor counts: timestamps need not rise with sequence (a
|
|
847
|
+
// rewind reorders them), so a later stamped group proves nothing about where an
|
|
848
|
+
// unstamped successor will begin, and shrinking the bound is the unsafe direction.
|
|
849
|
+
// An unstamped successor leaves the reach unbounded until it presents a frame.
|
|
850
|
+
#reach(sequence, end) {
|
|
851
|
+
const timeline = this.#state.timeline;
|
|
852
|
+
for (let i = timelineIndex(timeline, sequence + 1); i < timeline.length; i++) {
|
|
853
|
+
const successor = timeline[i];
|
|
854
|
+
if (end !== undefined && successor.sequence >= end)
|
|
855
|
+
break;
|
|
856
|
+
if (successor.closed.peek() instanceof Error)
|
|
857
|
+
continue;
|
|
858
|
+
return hooks.groupTimestamp(successor)?.asMillis();
|
|
859
|
+
}
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
// Whether the drift budget says to give up on `group`.
|
|
863
|
+
//
|
|
864
|
+
// Presentation time measures a group by how far it could still reach, not by how far
|
|
865
|
+
// behind it started. Being behind is survivable: priority transmits newer groups first,
|
|
866
|
+
// so a backlog bursts at whatever rate is left over and closes the gap faster than the
|
|
867
|
+
// live edge advances. A group is abandoned only once everything it could still present
|
|
868
|
+
// falls outside the budget.
|
|
869
|
+
//
|
|
870
|
+
// A group's reach is bounded by its nearest successor: it cannot present past where the
|
|
871
|
+
// next group begins. Its own frames say nothing, since a frame's duration is not on the
|
|
872
|
+
// wire, and the candidate needs no timestamp of its own: an empty group is bounded by
|
|
873
|
+
// its stamped successor the same way. The bound is exclusive, so the comparison is `>=`:
|
|
874
|
+
// the freshest frame a group could still hold sits just below its reach, so an age equal
|
|
875
|
+
// to the budget already puts every frame in it strictly past the budget. A zero budget
|
|
876
|
+
// falls out for free. Only timestamps drive expiry; wall-clock reclamation of idle
|
|
877
|
+
// content is the cache's own policy, not the budget's.
|
|
878
|
+
#isStale(group, drift) {
|
|
879
|
+
if (this.#state.timeline[timelineIndex(this.#state.timeline, group.sequence)] !== group)
|
|
880
|
+
return false;
|
|
881
|
+
const reach = this.#reach(group.sequence, drift.end);
|
|
882
|
+
return (drift.presentation !== undefined &&
|
|
883
|
+
drift.presentation.sequence > group.sequence &&
|
|
884
|
+
reach !== undefined &&
|
|
885
|
+
drift.presentation.timestamp.asMillis() - reach >= drift.budget);
|
|
886
|
+
}
|
|
887
|
+
#guard(group) {
|
|
888
|
+
if (!this.#enforceLatency)
|
|
889
|
+
return group;
|
|
890
|
+
hooks.expireGroup(group, {
|
|
891
|
+
expired: () => this.#isStale(group, this.#drift()),
|
|
892
|
+
changed: [
|
|
893
|
+
this.#state.groups,
|
|
894
|
+
this.#state.timelineChanged,
|
|
895
|
+
this.#state.update,
|
|
896
|
+
this.#state.info,
|
|
897
|
+
this.#cursor,
|
|
898
|
+
this.#state.closed,
|
|
899
|
+
],
|
|
900
|
+
});
|
|
901
|
+
return group;
|
|
902
|
+
}
|
|
526
903
|
constructor(name, state) {
|
|
527
904
|
this.name = name;
|
|
528
905
|
this.#state = state;
|
|
906
|
+
// The cursor's floor is the group the subscription named, or 0. A floor is the
|
|
907
|
+
// only thing a start contributes; {@link Subscription.maxAge} is what asks for
|
|
908
|
+
// data, and delivery skips everything above the floor that the budget convicts.
|
|
909
|
+
this.#cursor.set({ start: groupBounds(state.update.peek()?.groups ?? {}).start });
|
|
529
910
|
}
|
|
530
911
|
static {
|
|
531
912
|
makeSubscriber = (name, state) => new Subscriber(name, state);
|
|
913
|
+
hooks.tryRecvGroup = (subscriber) => subscriber.#tryRecvGroup();
|
|
914
|
+
hooks.groupChanged = (subscriber, fn) => subscriber.#groupChanged(fn);
|
|
915
|
+
hooks.exemptFetch = (subscriber) => {
|
|
916
|
+
subscriber.#enforceLatency = false;
|
|
917
|
+
};
|
|
918
|
+
hooks.replaceGroups = (subscriber, groups) => subscriber.#replaceGroups(groups);
|
|
919
|
+
// The sequence cursor lives here (it shares the buffer and the drift anchor with
|
|
920
|
+
// the arrival cursor); `Ordered` is the handle that reaches it.
|
|
921
|
+
ordered_ = {
|
|
922
|
+
nextGroup: (subscriber) => subscriber.#nextGroup(),
|
|
923
|
+
readFrame: (subscriber) => subscriber.#readFrame(),
|
|
924
|
+
readString: (subscriber) => subscriber.#readString(),
|
|
925
|
+
readJson: (subscriber) => subscriber.#readJson(),
|
|
926
|
+
readBool: (subscriber) => subscriber.#readBool(),
|
|
927
|
+
// Unordered either way, so both handles reach the same cursor.
|
|
928
|
+
recvDatagram: (subscriber) => subscriber.#recvDatagram(),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
// Refuse a read on this handle once `ordered()` has taken the subscription.
|
|
932
|
+
#live() {
|
|
933
|
+
if (this.#mode === "ordered")
|
|
934
|
+
throw new Error("track is read in sequence order; use the Ordered handle");
|
|
532
935
|
}
|
|
533
936
|
/**
|
|
534
937
|
* Resolve this track's immutable publisher properties.
|
|
@@ -552,6 +955,37 @@ export class Subscriber {
|
|
|
552
955
|
latest() {
|
|
553
956
|
return this.#state.latest;
|
|
554
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* The track's exclusive final boundary: the end {@link Producer.finishAt} declared, which
|
|
960
|
+
* can be ahead of the live edge, or one past the highest sequence produced once the
|
|
961
|
+
* producer closes cleanly (0 for a track that produced none). Groups and datagrams share
|
|
962
|
+
* the sequence namespace, so this can exceed `latest() + 1`. Undefined until declared,
|
|
963
|
+
* and after an abort that declared none.
|
|
964
|
+
*/
|
|
965
|
+
final() {
|
|
966
|
+
return this.#state.final.peek();
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Resolve with the track's exclusive final boundary once it is known, mirroring the Rust
|
|
970
|
+
* `finished`.
|
|
971
|
+
*
|
|
972
|
+
* Resolves as soon as the end is declared, which may be ahead of the live edge, so it
|
|
973
|
+
* says nothing about every group having arrived: read until the cursor returns
|
|
974
|
+
* `undefined` for that. Rejects with the abort, or if the track closes without an end.
|
|
975
|
+
*/
|
|
976
|
+
async finished() {
|
|
977
|
+
for (;;) {
|
|
978
|
+
const final = this.#state.final.peek();
|
|
979
|
+
if (final !== undefined)
|
|
980
|
+
return final;
|
|
981
|
+
const closed = this.#state.closed.peek();
|
|
982
|
+
if (closed instanceof Error)
|
|
983
|
+
throw closed;
|
|
984
|
+
if (closed !== undefined)
|
|
985
|
+
throw new Error("track closed before its end was known");
|
|
986
|
+
await Signal.race(this.#state.final, this.#state.closed);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
555
989
|
/**
|
|
556
990
|
* The newest frame this track has produced, or `undefined` while it has none.
|
|
557
991
|
*
|
|
@@ -592,22 +1026,33 @@ export class Subscriber {
|
|
|
592
1026
|
throw new Error("track has no producer to fork from");
|
|
593
1027
|
return producer.subscribe(options);
|
|
594
1028
|
}
|
|
595
|
-
/**
|
|
596
|
-
|
|
597
|
-
this
|
|
1029
|
+
/** Limit subsequent reads to these groups and return this reader for chaining. */
|
|
1030
|
+
withGroups(groups) {
|
|
1031
|
+
this.setGroups(groups);
|
|
1032
|
+
return this;
|
|
598
1033
|
}
|
|
599
1034
|
/**
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
1035
|
+
* Limit subsequent reads without rewinding read progress or changing the wire request.
|
|
1036
|
+
* An omitted start preserves the current floor; an omitted end removes the cap.
|
|
1037
|
+
* Raising the end makes unread buffered groups available again.
|
|
603
1038
|
*/
|
|
604
|
-
|
|
605
|
-
|
|
1039
|
+
setGroups(groups) {
|
|
1040
|
+
const { start, end } = groupBounds(groups);
|
|
1041
|
+
this.#cursor.update((cursor) => ({ start: Math.max(cursor.start, start), end }));
|
|
1042
|
+
}
|
|
1043
|
+
// Serving counterpart of setGroups: a named start replaces the floor, matching
|
|
1044
|
+
// Rust `start_at`. Local readers stay monotonic; only the wire publisher lowers.
|
|
1045
|
+
#replaceGroups(groups) {
|
|
1046
|
+
const { start, end } = groupBounds(groups);
|
|
1047
|
+
this.#cursor.update((cursor) => ({
|
|
1048
|
+
start: groups.start === undefined ? cursor.start : start,
|
|
1049
|
+
end,
|
|
1050
|
+
}));
|
|
606
1051
|
}
|
|
607
1052
|
/** Close the track (optionally with an error), closing any pending groups. Idempotent. */
|
|
608
1053
|
close(abort) {
|
|
609
1054
|
// Settle if we're first (the producer may already have); either way drop anything
|
|
610
|
-
// still buffered. Groups parked at the
|
|
1055
|
+
// still buffered. Groups parked at the setGroups cap deliberately outlive a clean
|
|
611
1056
|
// producer close, so the subscriber leaving is what must release them: closing
|
|
612
1057
|
// and clearing wakes a pending read to observe the end instead of hanging.
|
|
613
1058
|
closeTrackState(this.#state, abort);
|
|
@@ -616,55 +1061,108 @@ export class Subscriber {
|
|
|
616
1061
|
group.close(abort);
|
|
617
1062
|
groups.length = 0;
|
|
618
1063
|
});
|
|
1064
|
+
this.#frameGroup?.close(abort);
|
|
1065
|
+
this.#frameGroup = undefined;
|
|
1066
|
+
this.#state.timeline.length = 0;
|
|
619
1067
|
}
|
|
620
1068
|
/**
|
|
621
1069
|
* Receive every group on this track exactly once, as it becomes available.
|
|
622
1070
|
*
|
|
623
1071
|
* Groups may arrive out of order or with gaps due to network conditions; unlike
|
|
624
|
-
* {@link nextGroup}, one that arrives after a newer group was already returned
|
|
625
|
-
* still delivered. When several groups are buffered, the lowest sequence is
|
|
1072
|
+
* {@link Ordered.nextGroup}, one that arrives after a newer group was already returned
|
|
1073
|
+
* is still delivered. When several groups are buffered, the lowest sequence is
|
|
626
1074
|
* returned first.
|
|
627
1075
|
*
|
|
628
|
-
* Honors the
|
|
1076
|
+
* Honors the range set by {@link setGroups}: a group
|
|
629
1077
|
* beyond the cap stays buffered (not dropped) and is offered once the cap rises, even
|
|
630
1078
|
* after a clean close, without blocking in-range groups that arrive behind it.
|
|
1079
|
+
* A group whose presentation time is further behind the live edge than this
|
|
1080
|
+
* subscriber's `maxAge` is skipped. The default of zero takes the live edge.
|
|
1081
|
+
* The budget remains attached after return, so a pending frame read rejects if a stalled
|
|
1082
|
+
* group becomes stale while newer data advances.
|
|
1083
|
+
*
|
|
1084
|
+
* The first call commits this track to arrival order: {@link ordered} throws afterwards.
|
|
631
1085
|
*/
|
|
632
1086
|
async recvGroup() {
|
|
1087
|
+
this.#live();
|
|
1088
|
+
this.#mode = "arrival";
|
|
633
1089
|
for (;;) {
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1090
|
+
const recv = this.#tryRecvGroup();
|
|
1091
|
+
switch (recv.kind) {
|
|
1092
|
+
case "group":
|
|
1093
|
+
return recv.group;
|
|
1094
|
+
case "done":
|
|
1095
|
+
return undefined;
|
|
1096
|
+
case "error":
|
|
1097
|
+
throw recv.error;
|
|
1098
|
+
}
|
|
1099
|
+
// Idle, or parked at the boundary waiting for the cap to rise.
|
|
644
1100
|
await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
|
|
645
1101
|
}
|
|
646
1102
|
}
|
|
1103
|
+
// Package-internal synchronous half of recvGroup. The lite publisher uses this so applying
|
|
1104
|
+
// control state, popping the group, and positioning its frames are one JavaScript turn.
|
|
1105
|
+
#tryRecvGroup() {
|
|
1106
|
+
const groups = this.#state.groups.peek();
|
|
1107
|
+
const { start, end } = this.#cursor.peek();
|
|
1108
|
+
while (groups.length > 0 && groups[0].sequence < start)
|
|
1109
|
+
groups.shift()?.close();
|
|
1110
|
+
const drift = this.#drift();
|
|
1111
|
+
for (;;) {
|
|
1112
|
+
// The buffer is sequence-sorted, so an in-range group that arrives behind a
|
|
1113
|
+
// beyond-cap one sorts in front of it and is never blocked by it.
|
|
1114
|
+
const group = groups[0];
|
|
1115
|
+
if (!group || (end !== undefined && group.sequence >= end))
|
|
1116
|
+
break;
|
|
1117
|
+
groups.shift();
|
|
1118
|
+
if (this.#isStale(group, drift)) {
|
|
1119
|
+
group.close();
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
return { kind: "group", group: this.#guard(group) };
|
|
1123
|
+
}
|
|
1124
|
+
const group = groups[0];
|
|
1125
|
+
const closed = this.#state.closed.peek();
|
|
1126
|
+
if (closed instanceof Error)
|
|
1127
|
+
return { kind: "error", error: closed };
|
|
1128
|
+
if (closed === undefined)
|
|
1129
|
+
return { kind: "idle" };
|
|
1130
|
+
// A group beyond the cap outlives a clean close: it becomes deliverable if
|
|
1131
|
+
// the cap rises, so the track isn't over while any are held.
|
|
1132
|
+
return group ? { kind: "boundary" } : { kind: "done" };
|
|
1133
|
+
}
|
|
1134
|
+
// Package-internal readiness half of recvGroup. Each registration fires at most once, and
|
|
1135
|
+
// the caller disposes the losers after whichever source wakes it. A declared end wakes it
|
|
1136
|
+
// too, so a publisher can forward the end before the live edge reaches it.
|
|
1137
|
+
#groupChanged(fn) {
|
|
1138
|
+
const dispose = [
|
|
1139
|
+
this.#state.groups.changed(fn),
|
|
1140
|
+
this.#cursor.changed(fn),
|
|
1141
|
+
this.#state.closed.changed(fn),
|
|
1142
|
+
this.#state.final.changed(fn),
|
|
1143
|
+
];
|
|
1144
|
+
return () => {
|
|
1145
|
+
for (const close of dispose)
|
|
1146
|
+
close();
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
647
1149
|
/**
|
|
648
1150
|
* Take the next buffered group without blocking, honoring the same cursor bounds as
|
|
649
1151
|
* {@link recvGroup}.
|
|
650
1152
|
*
|
|
651
1153
|
* Returns `undefined` when nothing is deliverable right now, which is not by itself the
|
|
652
1154
|
* end of the track: a group may still arrive, or one may be parked beyond the
|
|
653
|
-
* {@link
|
|
1155
|
+
* {@link setGroups} cap. Use it to drain what the retained window already holds, where
|
|
654
1156
|
* waiting for a sequence nothing will republish would park forever.
|
|
655
1157
|
*/
|
|
656
1158
|
tryRecvGroup() {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (group && (end === undefined || group.sequence <= end)) {
|
|
665
|
-
groups.shift();
|
|
666
|
-
return group;
|
|
667
|
-
}
|
|
1159
|
+
this.#live();
|
|
1160
|
+
this.#mode = "arrival";
|
|
1161
|
+
const recv = this.#tryRecvGroup();
|
|
1162
|
+
if (recv.kind === "group")
|
|
1163
|
+
return recv.group;
|
|
1164
|
+
if (recv.kind === "error")
|
|
1165
|
+
throw recv.error;
|
|
668
1166
|
return undefined;
|
|
669
1167
|
}
|
|
670
1168
|
/**
|
|
@@ -673,23 +1171,20 @@ export class Subscriber {
|
|
|
673
1171
|
* Datagrams are a separate best-effort channel from groups (see
|
|
674
1172
|
* {@link Producer.appendDatagram}); they share only the sequence namespace. A consumer
|
|
675
1173
|
* that falls too far behind silently loses the oldest datagrams. Read this alongside
|
|
676
|
-
* {@link recvGroup} (e.g. in a separate loop) to receive both channels concurrently.
|
|
677
|
-
* a datagram
|
|
1174
|
+
* {@link recvGroup} (e.g. in a separate loop) to receive both channels concurrently.
|
|
1175
|
+
* The two cursors are independent: a datagram never moves the group cursor.
|
|
678
1176
|
*/
|
|
679
1177
|
async recvDatagram() {
|
|
1178
|
+
this.#live();
|
|
1179
|
+
return this.#recvDatagram();
|
|
1180
|
+
}
|
|
1181
|
+
// The datagram cursor, reachable from either handle: unordered by construction, so the
|
|
1182
|
+
// choice of group order says nothing about it.
|
|
1183
|
+
async #recvDatagram() {
|
|
680
1184
|
for (;;) {
|
|
681
1185
|
const datagrams = this.#state.datagrams.peek();
|
|
682
|
-
// Evict datagrams older than the send-buffer window (also enforced on write), so a
|
|
683
|
-
// reader that stalled skips stale datagrams instead of replaying them.
|
|
684
|
-
const cutoff = performance.now() - MAX_DATAGRAM_AGE_MS;
|
|
685
|
-
while (datagrams.length > 0 && datagrams[0].time < cutoff)
|
|
686
|
-
datagrams.shift();
|
|
687
1186
|
if (datagrams.length > 0) {
|
|
688
|
-
|
|
689
|
-
if (datagram) {
|
|
690
|
-
this.#nextSequence = Math.max(this.#nextSequence, datagram.sequence + 1);
|
|
691
|
-
}
|
|
692
|
-
return datagram;
|
|
1187
|
+
return datagrams.shift();
|
|
693
1188
|
}
|
|
694
1189
|
const closed = this.#state.closed.peek();
|
|
695
1190
|
if (closed instanceof Error)
|
|
@@ -699,128 +1194,111 @@ export class Subscriber {
|
|
|
699
1194
|
await Signal.race(this.#state.datagrams, this.#state.closed);
|
|
700
1195
|
}
|
|
701
1196
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
*
|
|
705
|
-
* Late arrivals (sequence at or below the last returned) are silently skipped.
|
|
706
|
-
* Use {@link recvGroup} to see every group in arrival order instead.
|
|
707
|
-
*/
|
|
708
|
-
async nextGroup() {
|
|
1197
|
+
// The sequence cursor behind {@link Ordered}, which owns the only public door to it.
|
|
1198
|
+
async #nextGroup() {
|
|
709
1199
|
for (;;) {
|
|
710
1200
|
const groups = this.#state.groups.peek();
|
|
711
1201
|
const cursor = this.#cursor.peek();
|
|
712
1202
|
const start = Math.max(cursor.start, this.#nextSequence);
|
|
713
1203
|
while (groups.length > 0 && groups[0].sequence < start)
|
|
714
1204
|
groups.shift()?.close();
|
|
715
|
-
|
|
716
|
-
|
|
1205
|
+
// One anchor for the whole pass, so walking a backlog off stays linear.
|
|
1206
|
+
const drift = this.#drift();
|
|
1207
|
+
for (;;) {
|
|
1208
|
+
const group = groups[0];
|
|
1209
|
+
if (!group || (cursor.end !== undefined && group.sequence >= cursor.end))
|
|
1210
|
+
break;
|
|
717
1211
|
groups.shift();
|
|
718
1212
|
this.#nextSequence = group.sequence + 1;
|
|
719
|
-
|
|
1213
|
+
// Every frame this group could still hold is past the budget, so keep
|
|
1214
|
+
// scanning: one pass walks a whole backlog off rather than replaying it.
|
|
1215
|
+
if (this.#isStale(group, drift)) {
|
|
1216
|
+
group.close();
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
// One cursor: the frame helpers must not keep draining a group this
|
|
1220
|
+
// read just moved past, or interleaved reads would run backwards.
|
|
1221
|
+
if (this.#frameGroup && this.#frameGroup.sequence < group.sequence) {
|
|
1222
|
+
this.#frameGroup.close();
|
|
1223
|
+
this.#frameGroup = undefined;
|
|
1224
|
+
}
|
|
1225
|
+
return this.#guard(group);
|
|
720
1226
|
}
|
|
721
1227
|
const closed = this.#state.closed.peek();
|
|
722
1228
|
if (closed instanceof Error)
|
|
723
1229
|
throw closed;
|
|
724
|
-
|
|
1230
|
+
// A group parked above the cap stays deliverable even after a clean close
|
|
1231
|
+
// (its frames remain buffered), so keep waiting for a cap raise. Only a
|
|
1232
|
+
// drained track reports finished.
|
|
1233
|
+
if (closed !== undefined && !groups[0])
|
|
725
1234
|
return undefined;
|
|
726
1235
|
await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
|
|
727
1236
|
}
|
|
728
1237
|
}
|
|
729
1238
|
/**
|
|
730
|
-
* Reads the next frame across
|
|
731
|
-
* Treat the returned frame bytes as read-only; they are shared with other consumers.
|
|
732
|
-
*/
|
|
733
|
-
async readFrame() {
|
|
734
|
-
const next = await this.readFrameSequence();
|
|
735
|
-
return next ? { payload: next.payload, timestamp: next.timestamp } : undefined;
|
|
736
|
-
}
|
|
737
|
-
/**
|
|
738
|
-
* Reads the next frame along with its group and frame sequence numbers.
|
|
1239
|
+
* Reads the next frame across groups, in sequence order, with its group and frame numbers.
|
|
739
1240
|
* Treat the returned frame bytes as read-only; they are shared with other consumers.
|
|
1241
|
+
*
|
|
1242
|
+
* Groups are acquired through the same sequence cursor as {@link Ordered.nextGroup},
|
|
1243
|
+
* so frames never run backwards: a late lower-sequence group is skipped, and so is
|
|
1244
|
+
* one every frame of which `maxAge` proves is too old. A group the budget abandons
|
|
1245
|
+
* mid-stall ends cleanly and the cursor resyncs from the next group; a gap inside a
|
|
1246
|
+
* group still surfaces as {@link TooFarBehind} or {@link GroupTooLarge}.
|
|
740
1247
|
*/
|
|
741
|
-
async
|
|
1248
|
+
async #readFrame() {
|
|
742
1249
|
for (;;) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
groups.shift()?.close();
|
|
753
|
-
throw new Lagged();
|
|
754
|
-
}
|
|
755
|
-
const next = groups[0].tryReadFrameSequence();
|
|
756
|
-
if (next) {
|
|
757
|
-
return {
|
|
758
|
-
group: groups[0].sequence,
|
|
759
|
-
frame: next.sequence,
|
|
760
|
-
payload: next.payload,
|
|
761
|
-
timestamp: next.timestamp,
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
groups.shift()?.close();
|
|
1250
|
+
if (!this.#frameGroup) {
|
|
1251
|
+
this.#frameGroup = await this.#nextGroup();
|
|
1252
|
+
if (!this.#frameGroup)
|
|
1253
|
+
return undefined;
|
|
1254
|
+
}
|
|
1255
|
+
const group = this.#frameGroup;
|
|
1256
|
+
let next;
|
|
1257
|
+
try {
|
|
1258
|
+
next = await group.readFrameSequence();
|
|
765
1259
|
}
|
|
766
|
-
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
// The group failed underneath us: resync from the next one, surfacing
|
|
1262
|
+
// only what the caller can act on (a gap, or the track's own abort).
|
|
1263
|
+
this.#frameGroup = undefined;
|
|
1264
|
+
group.close();
|
|
1265
|
+
if (err instanceof TooFarBehind || err instanceof GroupTooLarge)
|
|
1266
|
+
throw err;
|
|
767
1267
|
const closed = this.#state.closed.peek();
|
|
768
1268
|
if (closed instanceof Error)
|
|
769
1269
|
throw closed;
|
|
770
|
-
if (closed !== undefined)
|
|
771
|
-
return undefined;
|
|
772
|
-
await Signal.race(this.#state.groups, this.#cursor, this.#state.closed);
|
|
773
1270
|
continue;
|
|
774
1271
|
}
|
|
775
|
-
|
|
776
|
-
if (group.skipped) {
|
|
777
|
-
// Fell behind this group's eviction window. Drop it and signal the gap;
|
|
778
|
-
// the next read resyncs from the following group.
|
|
779
|
-
groups.shift()?.close();
|
|
780
|
-
throw new Lagged();
|
|
781
|
-
}
|
|
782
|
-
const next = group.tryReadFrameSequence();
|
|
783
|
-
if (next)
|
|
1272
|
+
if (next) {
|
|
784
1273
|
return {
|
|
785
1274
|
group: group.sequence,
|
|
786
1275
|
frame: next.sequence,
|
|
787
1276
|
payload: next.payload,
|
|
788
1277
|
timestamp: next.timestamp,
|
|
789
1278
|
};
|
|
790
|
-
const closed = this.#state.closed.peek();
|
|
791
|
-
if (closed instanceof Error)
|
|
792
|
-
throw closed;
|
|
793
|
-
if (closed !== undefined)
|
|
794
|
-
return undefined;
|
|
795
|
-
// A finished (drained + closed) group has nothing left: drop it and loop, rather than
|
|
796
|
-
// busy-waiting on its already-resolved readable() (which would livelock and starve the
|
|
797
|
-
// macrotask that delivers the next group).
|
|
798
|
-
if (group.done) {
|
|
799
|
-
groups.shift()?.close();
|
|
800
|
-
continue;
|
|
801
1279
|
}
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
1280
|
+
// The group is exhausted (or the budget abandoned its stall); move on.
|
|
1281
|
+
this.#frameGroup = undefined;
|
|
1282
|
+
group.close();
|
|
805
1283
|
}
|
|
806
1284
|
}
|
|
807
1285
|
/** Reads the next frame and decodes it as a UTF-8 string. */
|
|
808
|
-
async readString() {
|
|
809
|
-
const next = await this
|
|
1286
|
+
async #readString() {
|
|
1287
|
+
const next = await this.#readFrame();
|
|
810
1288
|
if (!next)
|
|
811
1289
|
return undefined;
|
|
812
1290
|
return new TextDecoder().decode(next.payload);
|
|
813
1291
|
}
|
|
814
1292
|
/** Reads the next frame and parses it as JSON. */
|
|
815
|
-
async readJson() {
|
|
816
|
-
const next = await this
|
|
1293
|
+
async #readJson() {
|
|
1294
|
+
const next = await this.#readString();
|
|
817
1295
|
if (!next)
|
|
818
1296
|
return undefined;
|
|
819
1297
|
return JSON.parse(next);
|
|
820
1298
|
}
|
|
821
1299
|
/** Reads the next frame and decodes it as a one-byte boolean, throwing on a malformed frame. */
|
|
822
|
-
async readBool() {
|
|
823
|
-
const next = await this
|
|
1300
|
+
async #readBool() {
|
|
1301
|
+
const next = await this.#readFrame();
|
|
824
1302
|
if (!next)
|
|
825
1303
|
return undefined;
|
|
826
1304
|
const payload = next.payload;
|
|
@@ -828,6 +1306,25 @@ export class Subscriber {
|
|
|
828
1306
|
throw new Error("invalid bool frame");
|
|
829
1307
|
return payload[0] === 1;
|
|
830
1308
|
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Read this track's groups in sequence order instead of arrival order.
|
|
1311
|
+
*
|
|
1312
|
+
* Both cursors draw from the same buffer, so a track is read one way or the other: this
|
|
1313
|
+
* hands the subscription to the returned {@link Ordered} and leaves this handle inert.
|
|
1314
|
+
* {@link recvGroup} and {@link recvDatagram} throw afterwards. Throws once a
|
|
1315
|
+
* {@link recvGroup} call has already committed the track to arrival order.
|
|
1316
|
+
*
|
|
1317
|
+
* Datagrams come along: they are a separate cursor either way, so the choice of group
|
|
1318
|
+
* order says nothing about them, and reading them commits nothing.
|
|
1319
|
+
*/
|
|
1320
|
+
ordered() {
|
|
1321
|
+
if (this.#mode === "ordered")
|
|
1322
|
+
throw new Error("track is already read in sequence order");
|
|
1323
|
+
if (this.#mode === "arrival")
|
|
1324
|
+
throw new Error("track is already read in arrival order");
|
|
1325
|
+
this.#mode = "ordered";
|
|
1326
|
+
return makeOrdered(this);
|
|
1327
|
+
}
|
|
831
1328
|
/**
|
|
832
1329
|
* Update this subscription's options (e.g. priority), triggering a SUBSCRIBE_UPDATE to the
|
|
833
1330
|
* publisher. Mirrors the Rust `Subscriber::update`.
|
|
@@ -836,4 +1333,118 @@ export class Subscriber {
|
|
|
836
1333
|
this.#state.update.set(subscriptionDefaults(options));
|
|
837
1334
|
}
|
|
838
1335
|
}
|
|
1336
|
+
/**
|
|
1337
|
+
* A {@link Subscriber} that reads groups in sequence order.
|
|
1338
|
+
*
|
|
1339
|
+
* Created by {@link Subscriber.ordered}, which takes the subscription over: the two
|
|
1340
|
+
* cursors draw from one buffer, so a track is read one way or the other and never both.
|
|
1341
|
+
* Every group this returns has a higher sequence than the last, so a late arrival is
|
|
1342
|
+
* skipped rather than delivered out of turn.
|
|
1343
|
+
*
|
|
1344
|
+
* `maxAge` applies as this cursor reads, exactly as it does on the arrival cursor: a
|
|
1345
|
+
* group is skipped once its reach, where its immediate successor begins, is that far
|
|
1346
|
+
* behind the newest frame on the track. Nothing weaker convicts it, since the reach is
|
|
1347
|
+
* the only proof that every frame it could still hold is past the budget, so a backlog
|
|
1348
|
+
* inside the budget is still delivered whole, as a burst in order. The budget follows a
|
|
1349
|
+
* group already handed out too: a stalled group's pending frame read rejects once newer
|
|
1350
|
+
* content has pulled that far ahead.
|
|
1351
|
+
*/
|
|
1352
|
+
export class Ordered {
|
|
1353
|
+
/** The track name. */
|
|
1354
|
+
name;
|
|
1355
|
+
#subscriber;
|
|
1356
|
+
constructor(subscriber) {
|
|
1357
|
+
this.name = subscriber.name;
|
|
1358
|
+
this.#subscriber = subscriber;
|
|
1359
|
+
}
|
|
1360
|
+
static {
|
|
1361
|
+
makeOrdered = (subscriber) => new Ordered(subscriber);
|
|
1362
|
+
}
|
|
1363
|
+
/** Resolve this track's immutable publisher properties; see {@link Subscriber.info}. */
|
|
1364
|
+
info() {
|
|
1365
|
+
return this.#subscriber.info();
|
|
1366
|
+
}
|
|
1367
|
+
/** Settles once the track closes; see {@link Producer.closed}. */
|
|
1368
|
+
get closed() {
|
|
1369
|
+
return this.#subscriber.closed;
|
|
1370
|
+
}
|
|
1371
|
+
/** This subscriber's current options, including defaults and the last {@link update}. */
|
|
1372
|
+
get subscription() {
|
|
1373
|
+
return this.#subscriber.subscription;
|
|
1374
|
+
}
|
|
1375
|
+
/** The latest group sequence observed on this track, if any. */
|
|
1376
|
+
latest() {
|
|
1377
|
+
return this.#subscriber.latest();
|
|
1378
|
+
}
|
|
1379
|
+
/** The track's exclusive final boundary; see {@link Subscriber.final}. */
|
|
1380
|
+
final() {
|
|
1381
|
+
return this.#subscriber.final();
|
|
1382
|
+
}
|
|
1383
|
+
/** Resolve with the track's exclusive final boundary once known; see {@link Subscriber.finished}. */
|
|
1384
|
+
finished() {
|
|
1385
|
+
return this.#subscriber.finished();
|
|
1386
|
+
}
|
|
1387
|
+
/** Limit subsequent reads to these groups and return this reader for chaining. */
|
|
1388
|
+
withGroups(groups) {
|
|
1389
|
+
this.setGroups(groups);
|
|
1390
|
+
return this;
|
|
1391
|
+
}
|
|
1392
|
+
/** Limit subsequent reads to these groups; see {@link Subscriber.setGroups}. */
|
|
1393
|
+
setGroups(groups) {
|
|
1394
|
+
this.#subscriber.setGroups(groups);
|
|
1395
|
+
}
|
|
1396
|
+
/** Update this subscription's options; see {@link Subscriber.update}. */
|
|
1397
|
+
update(options) {
|
|
1398
|
+
this.#subscriber.update(options);
|
|
1399
|
+
}
|
|
1400
|
+
/** Close the track (optionally with an error), closing any pending groups. Idempotent. */
|
|
1401
|
+
close(abort) {
|
|
1402
|
+
this.#subscriber.close(abort);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Return the next group with a strictly-greater sequence number than the last returned.
|
|
1406
|
+
*
|
|
1407
|
+
* Late arrivals (sequence at or below the last returned) are silently skipped, as is a
|
|
1408
|
+
* group whose every frame is further behind the live edge than `maxAge` (the default of
|
|
1409
|
+
* zero keeps only what nothing newer has superseded). Honors the bounds set by
|
|
1410
|
+
* {@link setGroups}.
|
|
1411
|
+
*/
|
|
1412
|
+
nextGroup() {
|
|
1413
|
+
return ordered_.nextGroup(this.#subscriber);
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Read the next frame across groups, in sequence order, with its group and frame numbers.
|
|
1417
|
+
*
|
|
1418
|
+
* Rides the same cursor as {@link nextGroup} and shares this handle's contract: a
|
|
1419
|
+
* buffered backlog is drained in full up to the point `maxAge` proves it useless.
|
|
1420
|
+
* Treat the returned frame bytes as read-only; they are shared with other consumers.
|
|
1421
|
+
*/
|
|
1422
|
+
readFrame() {
|
|
1423
|
+
return ordered_.readFrame(this.#subscriber);
|
|
1424
|
+
}
|
|
1425
|
+
/** Read the next frame and decode it as a UTF-8 string. */
|
|
1426
|
+
readString() {
|
|
1427
|
+
return ordered_.readString(this.#subscriber);
|
|
1428
|
+
}
|
|
1429
|
+
/** Read the next frame and parse it as JSON. */
|
|
1430
|
+
readJson() {
|
|
1431
|
+
return ordered_.readJson(this.#subscriber);
|
|
1432
|
+
}
|
|
1433
|
+
/** Read the next frame and decode it as a one-byte boolean, throwing on a malformed frame. */
|
|
1434
|
+
readBool() {
|
|
1435
|
+
return ordered_.readBool(this.#subscriber);
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Receive the next datagram in arrival order.
|
|
1439
|
+
*
|
|
1440
|
+
* Datagrams are a separate best-effort channel from groups (see
|
|
1441
|
+
* {@link Producer.appendDatagram}); they share only the sequence namespace, and
|
|
1442
|
+
* neither cursor moves the other. Unordered by construction, so this behaves
|
|
1443
|
+
* identically on either handle; it is here so a track carrying both channels needs
|
|
1444
|
+
* one subscription rather than two.
|
|
1445
|
+
*/
|
|
1446
|
+
recvDatagram() {
|
|
1447
|
+
return ordered_.recvDatagram(this.#subscriber);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
839
1450
|
//# sourceMappingURL=track.js.map
|