@blockcast/fec-worker 0.1.0-main.0c300647d0a5
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 +213 -0
- package/dist/.build-stamp +0 -0
- package/dist/fec-worker-client.d.ts +80 -0
- package/dist/fec-worker-client.d.ts.map +1 -0
- package/dist/fec-worker-client.js +270 -0
- package/dist/fec-worker-client.js.map +1 -0
- package/dist/fec-worker-types.d.ts +252 -0
- package/dist/fec-worker-types.d.ts.map +1 -0
- package/dist/fec-worker-types.js +11 -0
- package/dist/fec-worker-types.js.map +1 -0
- package/dist/fec-worker.d.ts +14 -0
- package/dist/fec-worker.d.ts.map +1 -0
- package/dist/fec-worker.js +565 -0
- package/dist/fec-worker.js.map +7 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/shred-fec-worker.d.ts +20 -0
- package/dist/shred-fec-worker.d.ts.map +1 -0
- package/dist/shred-fec-worker.js +349 -0
- package/dist/shred-fec-worker.js.map +7 -0
- package/dist/shred-worker-client.d.ts +104 -0
- package/dist/shred-worker-client.d.ts.map +1 -0
- package/dist/shred-worker-client.js +181 -0
- package/dist/shred-worker-client.js.map +1 -0
- package/dist/shred-worker-types.d.ts +179 -0
- package/dist/shred-worker-types.d.ts.map +1 -0
- package/dist/shred-worker-types.js +47 -0
- package/dist/shred-worker-types.js.map +1 -0
- package/dist/transfer.d.ts +9 -0
- package/dist/transfer.d.ts.map +1 -0
- package/dist/transfer.js +13 -0
- package/dist/transfer.js.map +1 -0
- package/package.json +59 -0
- package/src/fec-worker-client.ts +309 -0
- package/src/fec-worker-types.ts +268 -0
- package/src/fec-worker.ts +1048 -0
- package/src/index.ts +32 -0
- package/src/shred-fec-worker.ts +558 -0
- package/src/shred-worker-client.ts +222 -0
- package/src/shred-worker-types.ts +194 -0
- package/src/transfer.ts +12 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/fec-worker — Worker-per-track FEC decode with zero-copy transfers
|
|
3
|
+
*
|
|
4
|
+
* Re-exports shared types and main-thread client.
|
|
5
|
+
*/
|
|
6
|
+
export type { FecWorkerCommand, FecWorkerEvent, FecWorkerErrorCode, FecTrackConfig, FecTrackStats, FecBlockSnapshot, FrameMeta, } from "./fec-worker-types.js";
|
|
7
|
+
export { FecWorkerClient } from "./fec-worker-client.js";
|
|
8
|
+
export type { ShredWorkerCommand, ShredWorkerEvent, ShredWorkerConfig, ShredWorkerStats, ShredWorkerErrorCode, RecoveredMeta, SlotSample, } from "./shred-worker-types.js";
|
|
9
|
+
export { ShredWorkerClient } from "./shred-worker-client.js";
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,YAAY,EACX,gBAAgB,EAChB,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,SAAS,GACT,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAKzD,YAAY,EACX,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,UAAU,GACV,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/fec-worker — Worker-per-track FEC decode with zero-copy transfers
|
|
3
|
+
*
|
|
4
|
+
* Re-exports shared types and main-thread client.
|
|
5
|
+
*/
|
|
6
|
+
export { FecWorkerClient } from "./fec-worker-client.js";
|
|
7
|
+
export { ShredWorkerClient } from "./shred-worker-client.js";
|
|
8
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAYH,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAezD,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/fec-worker — Shred (Reed-Solomon) Worker Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Runs inside a DedicatedWorkerGlobalScope. Owns the batch-drain: reads raw
|
|
5
|
+
* shred frames (either from a transferred `ReadableStream` or via `feedShred`),
|
|
6
|
+
* parses the wire header, drives the `ShredFecBuffer` WASM decoder, and posts
|
|
7
|
+
* only recovered FEC sets (plus a coalesced stats heartbeat) back to the main
|
|
8
|
+
* thread. This moves the datagram read loop + wire parse + WASM FEC entirely
|
|
9
|
+
* off the main thread, so the page does no `read()`-per-packet work.
|
|
10
|
+
*
|
|
11
|
+
* WASM bindings resolve via:
|
|
12
|
+
* 1. (self as any).__SHRED_WASM_BINDINGS__ — injected by a loader/importmap
|
|
13
|
+
* or by tests (mirrors the RaptorQ worker's __MMT_WASM_BINDINGS__ contract)
|
|
14
|
+
* 2. dynamic import of config.wasmUrl (wasm-bindgen ESM: `await default()`
|
|
15
|
+
* then `new ShredFecBuffer(...)`)
|
|
16
|
+
*
|
|
17
|
+
* This file must NOT import DOM globals incompatible with Worker scope.
|
|
18
|
+
*/
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=shred-fec-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shred-fec-worker.d.ts","sourceRoot":"","sources":["../src/shred-fec-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG"}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Self-contained worker bundle (no bare imports). See build.mjs.
|
|
2
|
+
|
|
3
|
+
// src/shred-worker-types.ts
|
|
4
|
+
var SHRED_WIRE_VERSION = 3;
|
|
5
|
+
var SHRED_HEADER_LEN = 28;
|
|
6
|
+
var SHRED_FLAG_DATA_COMPLETE = 1;
|
|
7
|
+
var SHRED_FLAG_IS_CODING = 2;
|
|
8
|
+
|
|
9
|
+
// src/transfer.ts
|
|
10
|
+
function toOwnedArrayBuffer(view) {
|
|
11
|
+
const copy = new Uint8Array(view.byteLength);
|
|
12
|
+
copy.set(view);
|
|
13
|
+
return copy.buffer;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/shred-fec-worker.ts
|
|
17
|
+
function callAddShred(dec, slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet) {
|
|
18
|
+
if (dec.add_shred.length >= 7) {
|
|
19
|
+
return dec.add_shred(slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet);
|
|
20
|
+
}
|
|
21
|
+
if (!isCoding) return dec.add_shred(slot, fecSetIndex, localIndex, payload, lastInSet);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
var decoder = null;
|
|
25
|
+
var configureGen = 0;
|
|
26
|
+
var statsTimer = null;
|
|
27
|
+
var recoverTimer = null;
|
|
28
|
+
var activeStreamReader = null;
|
|
29
|
+
var disposed = false;
|
|
30
|
+
var maxSlot = 0n;
|
|
31
|
+
var lastSlotSampled = null;
|
|
32
|
+
var slotSamples = [];
|
|
33
|
+
var SLOT_SAMPLE_CAP = 256;
|
|
34
|
+
var setSendTs = /* @__PURE__ */ new Map();
|
|
35
|
+
var SET_TS_CAP = 4096;
|
|
36
|
+
function setKey(slot, fecSetIndex) {
|
|
37
|
+
return slot << 32n | BigInt(fecSetIndex >>> 0);
|
|
38
|
+
}
|
|
39
|
+
function recordSetSendTs(slot, fecSetIndex, sendTsUs) {
|
|
40
|
+
if (sendTsUs === 0n) return;
|
|
41
|
+
const key = setKey(slot, fecSetIndex);
|
|
42
|
+
if (setSendTs.has(key)) return;
|
|
43
|
+
if (setSendTs.size >= SET_TS_CAP) return;
|
|
44
|
+
setSendTs.set(key, sendTsUs);
|
|
45
|
+
}
|
|
46
|
+
var stats = {
|
|
47
|
+
shredsRx: 0,
|
|
48
|
+
sourceShreds: 0,
|
|
49
|
+
repairShreds: 0,
|
|
50
|
+
fecSetsCompleted: 0,
|
|
51
|
+
bytesReassembled: 0,
|
|
52
|
+
fecSetsRecovered: 0,
|
|
53
|
+
pendingFecSets: -1,
|
|
54
|
+
malformed: 0,
|
|
55
|
+
versionMismatch: 0,
|
|
56
|
+
currentSlot: "0",
|
|
57
|
+
slotSamples: []
|
|
58
|
+
};
|
|
59
|
+
function post(event, transfer = []) {
|
|
60
|
+
self.postMessage(event, transfer);
|
|
61
|
+
}
|
|
62
|
+
function postError(message, code) {
|
|
63
|
+
post({ type: "error", message, code });
|
|
64
|
+
}
|
|
65
|
+
function emitRecovered(view, meta) {
|
|
66
|
+
const buf = toOwnedArrayBuffer(view);
|
|
67
|
+
stats.fecSetsCompleted++;
|
|
68
|
+
stats.bytesReassembled += buf.byteLength;
|
|
69
|
+
if (meta.recovered) stats.fecSetsRecovered++;
|
|
70
|
+
post({ type: "recovered", data: buf, meta }, [buf]);
|
|
71
|
+
}
|
|
72
|
+
function postStats() {
|
|
73
|
+
if (decoder && typeof decoder.pending_fec_sets === "function") {
|
|
74
|
+
try {
|
|
75
|
+
stats.pendingFecSets = decoder.pending_fec_sets();
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
stats.currentSlot = maxSlot.toString();
|
|
80
|
+
const drainedSamples = slotSamples;
|
|
81
|
+
slotSamples = [];
|
|
82
|
+
post({ type: "stats", stats: { ...stats, slotSamples: drainedSamples } });
|
|
83
|
+
}
|
|
84
|
+
function ingestFrame(frame) {
|
|
85
|
+
if (frame.byteLength < SHRED_HEADER_LEN) {
|
|
86
|
+
stats.malformed++;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
90
|
+
if (dv.getUint8(0) !== SHRED_WIRE_VERSION) {
|
|
91
|
+
stats.versionMismatch++;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const slot = dv.getBigUint64(1, true);
|
|
95
|
+
const fecSetIndex = dv.getUint32(9, true);
|
|
96
|
+
const localIndex = dv.getUint32(13, true);
|
|
97
|
+
const flags = dv.getUint8(17);
|
|
98
|
+
const dataComplete = (flags & SHRED_FLAG_DATA_COMPLETE) !== 0;
|
|
99
|
+
const isCoding = (flags & SHRED_FLAG_IS_CODING) !== 0;
|
|
100
|
+
const numData = dv.getUint8(18);
|
|
101
|
+
const numCoding = dv.getUint8(19);
|
|
102
|
+
const sendTsUs = dv.getBigUint64(20, true);
|
|
103
|
+
const payload = frame.subarray(SHRED_HEADER_LEN);
|
|
104
|
+
stats.shredsRx++;
|
|
105
|
+
if (isCoding) stats.repairShreds++;
|
|
106
|
+
else stats.sourceShreds++;
|
|
107
|
+
if (slot > maxSlot) maxSlot = slot;
|
|
108
|
+
if (lastSlotSampled === null || slot !== lastSlotSampled) {
|
|
109
|
+
lastSlotSampled = slot;
|
|
110
|
+
if (slotSamples.length < SLOT_SAMPLE_CAP) {
|
|
111
|
+
slotSamples.push({ slot, sendTsUs, bytes: frame.byteLength });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
recordSetSendTs(slot, fecSetIndex, sendTsUs);
|
|
115
|
+
if (!decoder) return;
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = callAddShred(
|
|
119
|
+
decoder,
|
|
120
|
+
slot,
|
|
121
|
+
fecSetIndex,
|
|
122
|
+
localIndex,
|
|
123
|
+
numData,
|
|
124
|
+
numCoding,
|
|
125
|
+
isCoding,
|
|
126
|
+
payload,
|
|
127
|
+
dataComplete
|
|
128
|
+
);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
postError(`add_shred threw: ${errString(err)}`, "worker-runtime");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (result && result.byteLength > 0) {
|
|
134
|
+
setSendTs.delete(setKey(slot, fecSetIndex));
|
|
135
|
+
emitRecovered(result, { slot, fecSetIndex, sendTsUs, recovered: false });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function recoverAll(nowMs) {
|
|
139
|
+
if (!decoder) return;
|
|
140
|
+
if (typeof decoder.try_recover_all_with_identity === "function") {
|
|
141
|
+
let set;
|
|
142
|
+
try {
|
|
143
|
+
set = decoder.try_recover_all_with_identity(nowMs);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
postError(`try_recover_all_with_identity threw: ${errString(err)}`, "worker-runtime");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!set) return;
|
|
149
|
+
const slot = set.slot;
|
|
150
|
+
const fecSetIndex = set.fec_set_index;
|
|
151
|
+
const payload = set.payload;
|
|
152
|
+
try {
|
|
153
|
+
set.free?.();
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
const key = setKey(slot, fecSetIndex);
|
|
157
|
+
const sendTsUs = setSendTs.get(key) ?? 0n;
|
|
158
|
+
setSendTs.delete(key);
|
|
159
|
+
if (!payload || payload.byteLength === 0) return;
|
|
160
|
+
emitRecovered(payload, { slot, fecSetIndex, sendTsUs, recovered: true });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (typeof decoder.try_recover_all !== "function") return;
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = decoder.try_recover_all(nowMs);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
postError(`try_recover_all threw: ${errString(err)}`, "worker-runtime");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (result && result.byteLength > 0) {
|
|
172
|
+
emitRecovered(result, {
|
|
173
|
+
slot: 0n,
|
|
174
|
+
fecSetIndex: 0,
|
|
175
|
+
sendTsUs: 0n,
|
|
176
|
+
recovered: true
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function drainStream(readable) {
|
|
181
|
+
await cancelActiveStream();
|
|
182
|
+
const reader = readable.getReader();
|
|
183
|
+
activeStreamReader = reader;
|
|
184
|
+
try {
|
|
185
|
+
for (; ; ) {
|
|
186
|
+
const { value, done } = await reader.read();
|
|
187
|
+
if (done || disposed) break;
|
|
188
|
+
if (value) ingestFrame(value);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (!disposed) postError(`stream read failed: ${errString(err)}`, "stream-read-failed");
|
|
192
|
+
} finally {
|
|
193
|
+
if (activeStreamReader === reader) activeStreamReader = null;
|
|
194
|
+
try {
|
|
195
|
+
reader.releaseLock();
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function cancelActiveStream() {
|
|
201
|
+
const reader = activeStreamReader;
|
|
202
|
+
if (!reader) return;
|
|
203
|
+
activeStreamReader = null;
|
|
204
|
+
try {
|
|
205
|
+
await reader.cancel();
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function pickDecoderCtor(b) {
|
|
214
|
+
if (typeof b.ShredFecBuffer === "function") return b.ShredFecBuffer;
|
|
215
|
+
if (typeof b.ShredReassembler === "function") return b.ShredReassembler;
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
async function loadBindings(cfg) {
|
|
219
|
+
const injected = self.__SHRED_WASM_BINDINGS__;
|
|
220
|
+
if (injected && pickDecoderCtor(injected)) {
|
|
221
|
+
return injected;
|
|
222
|
+
}
|
|
223
|
+
if (!cfg.wasmUrl) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
"no shred WASM: set self.__SHRED_WASM_BINDINGS__ = { ShredFecBuffer } or pass config.wasmUrl"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const mod = await import(
|
|
229
|
+
/* @vite-ignore */
|
|
230
|
+
cfg.wasmUrl
|
|
231
|
+
);
|
|
232
|
+
if (typeof mod.default === "function") await mod.default();
|
|
233
|
+
if (!pickDecoderCtor(mod)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`shred WASM at ${cfg.wasmUrl} exports neither ShredFecBuffer nor ShredReassembler`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return mod;
|
|
239
|
+
}
|
|
240
|
+
async function configure(cfg) {
|
|
241
|
+
const gen = ++configureGen;
|
|
242
|
+
disposed = false;
|
|
243
|
+
teardownTimers();
|
|
244
|
+
await cancelActiveStream();
|
|
245
|
+
if (decoder?.free) {
|
|
246
|
+
try {
|
|
247
|
+
decoder.free();
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
decoder = null;
|
|
252
|
+
setSendTs.clear();
|
|
253
|
+
let bindings;
|
|
254
|
+
try {
|
|
255
|
+
bindings = await loadBindings(cfg);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (gen === configureGen) postError(errString(err), "wasm-load-failed");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (gen !== configureGen) return;
|
|
261
|
+
const Ctor = pickDecoderCtor(bindings);
|
|
262
|
+
if (!Ctor) {
|
|
263
|
+
postError("no shred decoder constructor after load", "wasm-bindings-missing");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
decoder = new Ctor(BigInt(cfg.maxSlots), cfg.symbolSize);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
postError(`shred decoder ctor threw: ${errString(err)}`, "wasm-load-failed");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
teardownTimers();
|
|
273
|
+
const statsMs = cfg.statsIntervalMs ?? 250;
|
|
274
|
+
if (statsMs > 0) statsTimer = setInterval(postStats, statsMs);
|
|
275
|
+
const recoverMs = cfg.recoverIntervalMs ?? 50;
|
|
276
|
+
if (recoverMs > 0) recoverTimer = setInterval(() => recoverAll(Date.now()), recoverMs);
|
|
277
|
+
}
|
|
278
|
+
function teardownTimers() {
|
|
279
|
+
if (statsTimer !== null) {
|
|
280
|
+
clearInterval(statsTimer);
|
|
281
|
+
statsTimer = null;
|
|
282
|
+
}
|
|
283
|
+
if (recoverTimer !== null) {
|
|
284
|
+
clearInterval(recoverTimer);
|
|
285
|
+
recoverTimer = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function dispose() {
|
|
289
|
+
disposed = true;
|
|
290
|
+
teardownTimers();
|
|
291
|
+
await cancelActiveStream();
|
|
292
|
+
if (decoder?.free) {
|
|
293
|
+
try {
|
|
294
|
+
decoder.free();
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
decoder = null;
|
|
299
|
+
setSendTs.clear();
|
|
300
|
+
}
|
|
301
|
+
self.onmessage = (e) => {
|
|
302
|
+
const cmd = e.data;
|
|
303
|
+
switch (cmd.type) {
|
|
304
|
+
case "configure":
|
|
305
|
+
void configure(cmd.config).catch(
|
|
306
|
+
(err) => postError(errString(err), "wasm-load-failed")
|
|
307
|
+
);
|
|
308
|
+
break;
|
|
309
|
+
case "attachStream":
|
|
310
|
+
if (!decoder) {
|
|
311
|
+
postError("attachStream before configure", "not-configured");
|
|
312
|
+
void cancelStreamHandle(cmd.readable);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
void drainStream(cmd.readable);
|
|
316
|
+
break;
|
|
317
|
+
case "feedShred":
|
|
318
|
+
if (!decoder) {
|
|
319
|
+
postError("feedShred before configure", "not-configured");
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
ingestFrame(new Uint8Array(cmd.frame));
|
|
323
|
+
break;
|
|
324
|
+
case "recoverAll":
|
|
325
|
+
recoverAll(cmd.nowMs);
|
|
326
|
+
break;
|
|
327
|
+
case "dispose":
|
|
328
|
+
void dispose();
|
|
329
|
+
break;
|
|
330
|
+
default: {
|
|
331
|
+
const _exhaustive = cmd;
|
|
332
|
+
postError(`unknown command: ${JSON.stringify(_exhaustive)}`, "unknown-command");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
self.onmessageerror = () => {
|
|
337
|
+
postError("failed to deserialize worker message", "worker-message");
|
|
338
|
+
};
|
|
339
|
+
async function cancelStreamHandle(readable) {
|
|
340
|
+
try {
|
|
341
|
+
await readable.cancel();
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function errString(err) {
|
|
346
|
+
if (err instanceof Error) return err.message;
|
|
347
|
+
return String(err);
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=shred-fec-worker.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/shred-worker-types.ts", "../src/transfer.ts", "../src/shred-fec-worker.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * @blockcast/fec-worker \u2014 Shred (Reed-Solomon) Worker Protocol\n *\n * A sibling codec to the RaptorQ/MMTP worker (`fec-worker.ts`). Solana-style\n * shred streams carry a different FEC payload identity than MMTP source/repair\n * symbols \u2014 a shred is `(slot, fecSetIndex, localIndex, numData, numCoding,\n * isCoding, lastInSet)`, not a flat `ssId` \u2014 so they need their own command\n * shape. The transport harness (Worker spawn, onmessage plumbing, zero-copy\n * `toOwnedArrayBuffer` transfers) is shared; the decoder WASM is not.\n *\n * The worker owns the batch-drain: the consumer transfers a WebTransport\n * `datagrams.readable` (or any `ReadableStream<Uint8Array>` of raw shred\n * frames) into the Worker via {@link ShredWorkerCommand} `attachStream`, and\n * the read loop + wire parse + `ShredFecBuffer.add_shred` all run off the main\n * thread. Only recovered FEC sets (and coalesced stats) cross back. A\n * `feedShred` command covers sources that have already demuxed the shred bytes\n * on the main thread (e.g. a moqtail OBJECT_DATAGRAM callback, or the IWA\n * multicast bridge).\n *\n * Types here are importable from both main thread and Worker contexts.\n */\n\n// --- Wire format ------------------------------------------------------------\n\n/**\n * Wire header layout the worker parses. Mirrors the deployed shred player's\n * `onShredBytes` exactly (libmmt shred-forwarder `emit_frame`) so the off-thread\n * path is bit-identical to the proven on-thread path.\n *\n * v3 (28-byte header):\n * [0] version u8 (== 3; non-3 frames are dropped, not misframed)\n * [1-8] slot u64 LE\n * [9-12] fecSetIndex u32 LE\n * [13-16] localIndex u32 LE (data: 0..numData; coding: numData+position)\n * [17] flags u8 (bit0 = DATA_COMPLETE, bit1 = IS_CODING_SHRED)\n * [18] numData u8 (coding shreds only; 0 on data \u2014 WASM infers)\n * [19] numCoding u8 (coding shreds only; 0 on data)\n * [20-27] sendTsUs u64 LE (\u00B5s since epoch, 0 when unstamped)\n * [28+] erasureShard (equal length across the FEC set)\n *\n * DATA_COMPLETE (last data shred in the set) is passed to `add_shred` as its\n * `lastInSet` argument \u2014 the WASM uses it to infer numData for a set that\n * received only data shreds (no coding).\n */\nexport const SHRED_WIRE_VERSION = 3;\nexport const SHRED_HEADER_LEN = 28;\nexport const SHRED_FLAG_DATA_COMPLETE = 0x01;\nexport const SHRED_FLAG_IS_CODING = 0x02;\n\n// --- Config -----------------------------------------------------------------\n\n/** Per-decoder configuration for the shred FEC worker. */\nexport interface ShredWorkerConfig {\n\t/** Ring capacity in slots (matches `new ShredFecBuffer(maxSlots, symbolSize)`). */\n\tmaxSlots: number;\n\t/** Symbol size in bytes (Solana shred payloads are fixed-size within a set). */\n\tsymbolSize: number;\n\t/**\n\t * Module URL for the shred WASM (wasm-bindgen ESM exporting `default` init +\n\t * `ShredFecBuffer`). The worker `import()`s it and calls `await default()`.\n\t * Ignored when `self.__SHRED_WASM_BINDINGS__` is pre-injected (tests / custom\n\t * loaders), mirroring the RaptorQ worker's `__MMT_WASM_BINDINGS__` contract.\n\t */\n\twasmUrl?: string;\n\t/**\n\t * How often (ms) the worker coalesces and posts a `stats` event. The read\n\t * loop never posts per-shred; only recovered frames and this heartbeat cross\n\t * the boundary. Default 250ms.\n\t */\n\tstatsIntervalMs?: number;\n\t/**\n\t * How often (ms) the worker runs `try_recover_all(now)` to drain timed-out\n\t * FEC sets that `add_shred` didn't complete (loss scenarios). Default 50ms,\n\t * matching the player's on-thread recovery timer. Set to 0 to disable (the\n\t * consumer may instead drive it explicitly with `recoverAll`).\n\t */\n\trecoverIntervalMs?: number;\n}\n\n// --- Stats ------------------------------------------------------------------\n\n/**\n * First-shred-per-slot sample, collected by the worker and drained into each\n * coalesced `stats` heartbeat. Lets the main thread run app-specific,\n * slot-derived stats (e.g. the turbine-leg latency estimator, per-slot latency\n * percentiles) WITHOUT re-parsing the wire header \u2014 the worker stays the single\n * source of truth for parsing, and these cross the boundary aggregated (a\n * handful per heartbeat), never per-packet.\n */\nexport interface SlotSample {\n\t/** Slot number (first shred of this slot the worker saw). */\n\tslot: bigint;\n\t/** Publisher send timestamp (\u00B5s since epoch) of that first shred, 0 when unstamped. */\n\tsendTsUs: bigint;\n\t/** Frame byte length of that first shred. */\n\tbytes: number;\n}\n\n/** Aggregate shred-decode counters, emitted on the coalesced `stats` heartbeat. */\nexport interface ShredWorkerStats {\n\t/** Total shred frames parsed (data + coding). */\n\tshredsRx: number;\n\t/** Data (source) shreds observed. */\n\tsourceShreds: number;\n\t/** Coding (repair) shreds observed. */\n\trepairShreds: number;\n\t/** FEC sets completed (direct completion + FEC recovery). */\n\tfecSetsCompleted: number;\n\t/** Bytes handed back as recovered/completed sets. */\n\tbytesReassembled: number;\n\t/** Sets recovered specifically via `try_recover_all` (loss recovery). */\n\tfecSetsRecovered: number;\n\t/** Current `ShredFecBuffer.pending_fec_sets()`, or -1 when unavailable. */\n\tpendingFecSets: number;\n\t/** Frames dropped for being shorter than the v3 header. */\n\tmalformed: number;\n\t/** Frames dropped for carrying a non-v3 version byte (forwarder/worker skew). */\n\tversionMismatch: number;\n\t/** Highest slot number observed (as a string \u2014 bigint isn't JSON-safe for UI). */\n\tcurrentSlot: string;\n\t/** First-shred-per-slot samples parsed since the previous heartbeat (drained each emit). */\n\tslotSamples: SlotSample[];\n}\n\n/** Stable identities for worker failures whose message text carries live values. */\nexport type ShredWorkerErrorCode =\n\t| \"wasm-load-failed\"\n\t| \"wasm-bindings-missing\"\n\t| \"not-configured\"\n\t| \"stream-read-failed\"\n\t| \"unknown-command\"\n\t| \"worker-runtime\"\n\t| \"worker-message\";\n\n// --- Recovered frame metadata ----------------------------------------------\n\n/** Metadata attached to each recovered set the worker emits. */\nexport interface RecoveredMeta {\n\t/**\n\t * Slot the recovered set belongs to. Attributed on both the direct-completion\n\t * path and the identity-carrying recovery drain\n\t * (`try_recover_all_with_identity`); only 0n on the legacy `ShredReassembler`\n\t * fallback, which has no identity variant. Do NOT branch on `slot === 0n` as\n\t * \"unattributed\" \u2014 that would drop the majority of recovered sets.\n\t */\n\tslot: bigint;\n\t/** FEC set index within the slot (0 only on the legacy identity-less fallback). */\n\tfecSetIndex: number;\n\t/**\n\t * Publisher send timestamp (\u00B5s since epoch), 0 when unstamped/unknown. On\n\t * direct completion this is the completing shred's stamp; on the recovery\n\t * drain it is the set's first-seen shred stamp (shreds of a set are sent \u00B5s\n\t * apart, so the two are equivalent latency baselines).\n\t */\n\tsendTsUs: bigint;\n\t/** True when produced by loss recovery (`try_recover_all`) rather than direct completion. */\n\trecovered: boolean;\n}\n\n// --- Commands (main -> worker) ----------------------------------------------\n\n/** Discriminated union of all commands sent from the main thread to the shred worker. */\nexport type ShredWorkerCommand =\n\t| { type: \"configure\"; config: ShredWorkerConfig }\n\t| {\n\t\t\t/**\n\t\t\t * Hand the worker a stream of raw shred frames to batch-drain. Each\n\t\t\t * chunk is one wire frame (one WebTransport datagram). The stream is\n\t\t\t * transferred (add to the postMessage transfer list) \u2014 the main thread\n\t\t\t * must not touch it afterward.\n\t\t\t */\n\t\t\ttype: \"attachStream\";\n\t\t\treadable: ReadableStream<Uint8Array>;\n\t }\n\t| {\n\t\t\t/**\n\t\t\t * Feed one already-demuxed raw shred frame. `frame` is transferred\n\t\t\t * zero-copy \u2014 the caller must not access it after posting. Use this for\n\t\t\t * sources whose demux stays on the main thread (moqtail OBJECT_DATAGRAM\n\t\t\t * callback, IWA multicast bridge).\n\t\t\t */\n\t\t\ttype: \"feedShred\";\n\t\t\tframe: ArrayBuffer;\n\t }\n\t| { type: \"recoverAll\"; nowMs: number }\n\t| { type: \"dispose\" };\n\n// --- Events (worker -> main) ------------------------------------------------\n\n/** Discriminated union of all events emitted from the shred worker to the main thread. */\nexport type ShredWorkerEvent =\n\t| { type: \"recovered\"; data: ArrayBuffer; meta: RecoveredMeta }\n\t| { type: \"stats\"; stats: ShredWorkerStats }\n\t| { type: \"error\"; message: string; code?: ShredWorkerErrorCode };\n", "/**\n * Return a fresh ArrayBuffer containing exactly the bytes in `view`.\n *\n * Worker outputs must not transfer buffers that might be owned by a WASM\n * binding or by a larger shared packet. A fresh JS-owned buffer gives\n * postMessage() exclusive ownership without detaching unrelated memory.\n */\nexport function toOwnedArrayBuffer(view: Uint8Array): ArrayBuffer {\n\tconst copy = new Uint8Array(view.byteLength);\n\tcopy.set(view);\n\treturn copy.buffer;\n}\n", "/**\n * @blockcast/fec-worker \u2014 Shred (Reed-Solomon) Worker Entry Point\n *\n * Runs inside a DedicatedWorkerGlobalScope. Owns the batch-drain: reads raw\n * shred frames (either from a transferred `ReadableStream` or via `feedShred`),\n * parses the wire header, drives the `ShredFecBuffer` WASM decoder, and posts\n * only recovered FEC sets (plus a coalesced stats heartbeat) back to the main\n * thread. This moves the datagram read loop + wire parse + WASM FEC entirely\n * off the main thread, so the page does no `read()`-per-packet work.\n *\n * WASM bindings resolve via:\n * 1. (self as any).__SHRED_WASM_BINDINGS__ \u2014 injected by a loader/importmap\n * or by tests (mirrors the RaptorQ worker's __MMT_WASM_BINDINGS__ contract)\n * 2. dynamic import of config.wasmUrl (wasm-bindgen ESM: `await default()`\n * then `new ShredFecBuffer(...)`)\n *\n * This file must NOT import DOM globals incompatible with Worker scope.\n */\n\nimport type {\n\tShredWorkerCommand,\n\tShredWorkerConfig,\n\tShredWorkerEvent,\n\tShredWorkerStats,\n\tShredWorkerErrorCode,\n\tRecoveredMeta,\n\tSlotSample,\n} from \"./shred-worker-types.js\";\nimport {\n\tSHRED_WIRE_VERSION,\n\tSHRED_HEADER_LEN,\n\tSHRED_FLAG_IS_CODING,\n\tSHRED_FLAG_DATA_COMPLETE,\n} from \"./shred-worker-types.js\";\nimport { toOwnedArrayBuffer } from \"./transfer.js\";\n\n// --- WASM binding types (from the shred `mmt_wasm` build) -------------------\n// Two decoder classes exist, selected the same way the player does\n// (`ShredFecBuffer ?? ShredReassembler`) and dispatched by add_shred arity:\n// - ShredFecBuffer : full Reed-Solomon path, 8-arg add_shred\n// (slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet)\n// - ShredReassembler : legacy data-only, 5-arg add_shred\n// (slot, fecSetIndex, localIndex, payload, lastInSet); coding shreds ignored\n/**\n * A recovered FEC set that carries its `(slot, fecSetIndex)` identity, as\n * returned by `ShredFecBuffer.try_recover_all_with_identity`. wasm-bindgen\n * getters surface `slot` as a BigInt and `fec_set_index` as a number; `payload`\n * is a fresh owned copy (safe to read after `free()`). The object is WASM-owned\n * \u2014 call `free()` once consumed.\n */\ninterface RecoveredSetWasm {\n\treadonly slot: bigint;\n\treadonly fec_set_index: number;\n\treadonly payload: Uint8Array;\n\tfree?(): void;\n}\n\ninterface ShredDecoderWasm {\n\t// Loose signature so both arities type-check; the caller branches on\n\t// `add_shred.length` (wasm-bindgen preserves declared arity).\n\tadd_shred(...args: unknown[]): Uint8Array | null | undefined;\n\t/** Drain timed-out FEC sets the direct path didn't complete (RS path only). */\n\ttry_recover_all?(nowMs: number): Uint8Array | null | undefined;\n\t/**\n\t * Identity-carrying drain (ShredFecBuffer only): same backlog as\n\t * `try_recover_all` but the result keeps its `(slot, fecSetIndex)`, so the\n\t * worker can attribute the real send_ts recorded at ingest to a set that\n\t * completes on the recover timer rather than inline. Absent on the legacy\n\t * reassembler \u2014 the worker falls back to `try_recover_all` there.\n\t */\n\ttry_recover_all_with_identity?(nowMs: number): RecoveredSetWasm | null | undefined;\n\tpending_fec_sets?(): number;\n\tfree?(): void;\n}\n\ntype ShredDecoderCtor = new (maxSlots: bigint, symbolSize: number) => ShredDecoderWasm;\n\ninterface ShredWasmBindings {\n\t/** wasm-bindgen ESM default init (absent when bindings are pre-inited). */\n\tdefault?: () => Promise<unknown>;\n\tShredFecBuffer?: ShredDecoderCtor;\n\tShredReassembler?: ShredDecoderCtor;\n}\n\n/**\n * Dispatch one shred to the decoder, mirroring the player's `callAddShred`:\n * ShredFecBuffer (>=7-arg) gets the full RS call; the legacy 5-arg reassembler\n * gets data shreds only (coding shreds are dropped, it can't use them).\n */\nfunction callAddShred(\n\tdec: ShredDecoderWasm,\n\tslot: bigint,\n\tfecSetIndex: number,\n\tlocalIndex: number,\n\tnumData: number,\n\tnumCoding: number,\n\tisCoding: boolean,\n\tpayload: Uint8Array,\n\tlastInSet: boolean,\n): Uint8Array | null | undefined {\n\tif (dec.add_shred.length >= 7) {\n\t\treturn dec.add_shred(slot, fecSetIndex, localIndex, numData, numCoding, isCoding, payload, lastInSet);\n\t}\n\tif (!isCoding) return dec.add_shred(slot, fecSetIndex, localIndex, payload, lastInSet);\n\treturn null;\n}\n\n// --- Worker state -----------------------------------------------------------\n\nlet decoder: ShredDecoderWasm | null = null;\n// configure() generation \u2014 guards against re-entrant configure() races: a later\n// call supersedes an earlier one still awaiting the WASM load, so the loser bails\n// instead of arming a second set of timers (which would orphan the winner's \u2014 a\n// leak that outlives dispose()).\nlet configureGen = 0;\nlet statsTimer: ReturnType<typeof setInterval> | null = null;\nlet recoverTimer: ReturnType<typeof setInterval> | null = null;\nlet activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;\nlet disposed = false;\n\n// Slot tracking for main-thread stats (turbine estimator, latency percentiles).\n// The worker is the single parser; it hands the main thread first-shred-per-slot\n// samples aggregated into the stats heartbeat, never per-packet.\nlet maxSlot = 0n;\nlet lastSlotSampled: bigint | null = null;\nlet slotSamples: SlotSample[] = [];\n/** Cap the per-heartbeat sample batch; ~400ms slots means this is rarely hit. */\nconst SLOT_SAMPLE_CAP = 256;\n\n// Per-FEC-set send timestamp, recorded at ingest (`(slot, fecSetIndex)` \u2192\n// first-seen sendTsUs). The loss-recovery drain returns a set that completed on\n// the recover timer, not inline, so its completing shred's send_ts isn't in\n// hand at emit time; without this, every RS-recovered set would carry sendTsUs\n// 0n and the \"Listener\u2192browser\" latency widgets would stay empty whenever loss\n// (the common case over a QUIC datagram path) makes recovery \u2014 not inline\n// completion \u2014 the dominant path.\n//\n// Key is a bigint pack `(slot << 32) | fecSetIndex` (fecSetIndex is u32) rather\n// than a template string \u2014 `recordSetSendTs` runs on every ingested shred (a\n// documented per-packet hot path), so this avoids a per-frame string alloc.\nconst setSendTs = new Map<bigint, bigint>();\n/**\n * Bound the map for sets that never complete (shreds dropped before enough of a\n * set arrived to recover). Entries are deleted the moment a set completes\n * (either path), so this cap is only reached under sustained un-recoverable\n * loss; ~a few sets/slot means 4096 covers minutes of backlog.\n */\nconst SET_TS_CAP = 4096;\n\nfunction setKey(slot: bigint, fecSetIndex: number): bigint {\n\treturn (slot << 32n) | BigInt(fecSetIndex >>> 0);\n}\n\n/** Record the first send_ts seen for a set so the recover path can attribute it. */\nfunction recordSetSendTs(slot: bigint, fecSetIndex: number, sendTsUs: bigint): void {\n\tif (sendTsUs === 0n) return; // unstamped feed \u2014 nothing to attribute\n\tconst key = setKey(slot, fecSetIndex);\n\tif (setSendTs.has(key)) return; // keep the first-seen stamp (matches slotSamples)\n\t// At cap, reject the NEW entry rather than evicting the oldest \u2014 matching the\n\t// flood-resistance policy of this package's `enqueuePendingVerify`\n\t// (fec-worker.ts). The oldest pending sets are the ones closest to completing\n\t// via the recover timer, so evict-oldest would drop attribution exactly when\n\t// it matters and is attacker-triggerable via a flood of never-completing sets.\n\t// A rejected set simply falls back to sendTsUs 0n if it later recovers.\n\tif (setSendTs.size >= SET_TS_CAP) return;\n\tsetSendTs.set(key, sendTsUs);\n}\n\nconst stats: ShredWorkerStats = {\n\tshredsRx: 0,\n\tsourceShreds: 0,\n\trepairShreds: 0,\n\tfecSetsCompleted: 0,\n\tbytesReassembled: 0,\n\tfecSetsRecovered: 0,\n\tpendingFecSets: -1,\n\tmalformed: 0,\n\tversionMismatch: 0,\n\tcurrentSlot: \"0\",\n\tslotSamples: [],\n};\n\n// --- Emit helpers -----------------------------------------------------------\n\nfunction post(event: ShredWorkerEvent, transfer: Transferable[] = []): void {\n\t(self as unknown as DedicatedWorkerGlobalScope).postMessage(event, transfer);\n}\n\nfunction postError(message: string, code?: ShredWorkerErrorCode): void {\n\tpost({ type: \"error\", message, code });\n}\n\nfunction emitRecovered(view: Uint8Array, meta: RecoveredMeta): void {\n\t// Copy out of WASM-owned memory into a fresh JS buffer before transfer.\n\tconst buf = toOwnedArrayBuffer(view);\n\tstats.fecSetsCompleted++;\n\tstats.bytesReassembled += buf.byteLength;\n\tif (meta.recovered) stats.fecSetsRecovered++;\n\tpost({ type: \"recovered\", data: buf, meta }, [buf]);\n}\n\nfunction postStats(): void {\n\tif (decoder && typeof decoder.pending_fec_sets === \"function\") {\n\t\ttry {\n\t\t\tstats.pendingFecSets = decoder.pending_fec_sets();\n\t\t} catch {\n\t\t\t/* pending count is best-effort */\n\t\t}\n\t}\n\tstats.currentSlot = maxSlot.toString();\n\t// Drain the slot-sample batch into this heartbeat; live counters keep mutating.\n\tconst drainedSamples = slotSamples;\n\tslotSamples = [];\n\tpost({ type: \"stats\", stats: { ...stats, slotSamples: drainedSamples } });\n}\n\n// --- Wire parse + decode ----------------------------------------------------\n\n/**\n * Parse one raw shred frame and feed the decoder. Mirrors the deployed player's\n * `onShredBytes` (v3 wire, version-guarded) so the off-thread path is\n * bit-identical to the proven on-thread path. Emits a `recovered` event when a\n * set completes directly.\n */\nfunction ingestFrame(frame: Uint8Array): void {\n\tif (frame.byteLength < SHRED_HEADER_LEN) {\n\t\tstats.malformed++;\n\t\treturn;\n\t}\n\tconst dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);\n\tif (dv.getUint8(0) !== SHRED_WIRE_VERSION) {\n\t\t// Forwarder/worker wire-version skew \u2014 drop rather than misframe every shred.\n\t\tstats.versionMismatch++;\n\t\treturn;\n\t}\n\tconst slot = dv.getBigUint64(1, true);\n\tconst fecSetIndex = dv.getUint32(9, true);\n\tconst localIndex = dv.getUint32(13, true);\n\tconst flags = dv.getUint8(17);\n\tconst dataComplete = (flags & SHRED_FLAG_DATA_COMPLETE) !== 0; // last data shred in set\n\tconst isCoding = (flags & SHRED_FLAG_IS_CODING) !== 0;\n\tconst numData = dv.getUint8(18);\n\tconst numCoding = dv.getUint8(19);\n\tconst sendTsUs = dv.getBigUint64(20, true);\n\tconst payload = frame.subarray(SHRED_HEADER_LEN);\n\n\tstats.shredsRx++;\n\tif (isCoding) stats.repairShreds++;\n\telse stats.sourceShreds++;\n\n\t// Slot tracking for main-thread stats. Sample the first shred of each new\n\t// slot (send timestamp + size) so the page can run the turbine-leg estimator\n\t// and latency percentiles without re-parsing the wire header itself.\n\tif (slot > maxSlot) maxSlot = slot;\n\tif (lastSlotSampled === null || slot !== lastSlotSampled) {\n\t\tlastSlotSampled = slot;\n\t\tif (slotSamples.length < SLOT_SAMPLE_CAP) {\n\t\t\tslotSamples.push({ slot, sendTsUs, bytes: frame.byteLength });\n\t\t}\n\t}\n\n\t// Record this set's send_ts so the recover-timer path (which completes a set\n\t// without its completing shred in hand) can attribute a real timestamp.\n\trecordSetSendTs(slot, fecSetIndex, sendTsUs);\n\n\tif (!decoder) return;\n\n\t// DATA_COMPLETE is passed as `lastInSet`: the WASM uses it to infer numData\n\t// for a set that received only data shreds (no coding).\n\tlet result: Uint8Array | null | undefined;\n\ttry {\n\t\tresult = callAddShred(\n\t\t\tdecoder,\n\t\t\tslot,\n\t\t\tfecSetIndex,\n\t\t\tlocalIndex,\n\t\t\tnumData,\n\t\t\tnumCoding,\n\t\t\tisCoding,\n\t\t\tpayload,\n\t\t\tdataComplete,\n\t\t);\n\t} catch (err) {\n\t\tpostError(`add_shred threw: ${errString(err)}`, \"worker-runtime\");\n\t\treturn;\n\t}\n\tif (result && result.byteLength > 0) {\n\t\t// Set completed inline \u2014 its completing shred's send_ts is exact; drop the\n\t\t// ingest record so it can't leak or be re-attributed by the recover path.\n\t\tsetSendTs.delete(setKey(slot, fecSetIndex));\n\t\temitRecovered(result, { slot, fecSetIndex, sendTsUs, recovered: false });\n\t}\n}\n\n/** Run one loss-recovery drain and emit any late-recovered set. */\nfunction recoverAll(nowMs: number): void {\n\tif (!decoder) return;\n\n\t// Preferred path: the identity-carrying drain (ShredFecBuffer) keeps the\n\t// recovered set's `(slot, fecSetIndex)`, so we can attribute the real send_ts\n\t// recorded at ingest \u2014 otherwise a loss-recovered set (the common case over a\n\t// lossy datagram path) would carry sendTsUs 0n and the latency widgets would\n\t// never populate despite valid stamps on the wire.\n\tif (typeof decoder.try_recover_all_with_identity === \"function\") {\n\t\tlet set: RecoveredSetWasm | null | undefined;\n\t\ttry {\n\t\t\tset = decoder.try_recover_all_with_identity(nowMs);\n\t\t} catch (err) {\n\t\t\tpostError(`try_recover_all_with_identity threw: ${errString(err)}`, \"worker-runtime\");\n\t\t\treturn;\n\t\t}\n\t\tif (!set) return;\n\t\t// Read the WASM-owned fields before freeing; `payload` is a fresh copy.\n\t\tconst slot = set.slot;\n\t\tconst fecSetIndex = set.fec_set_index;\n\t\tconst payload = set.payload;\n\t\t// Best-effort free, matching every other fallible WASM call in this file:\n\t\t// recoverAll runs from an unguarded setInterval / message-dispatch branch,\n\t\t// so a throwing free() must not propagate and silently drop the set.\n\t\ttry {\n\t\t\tset.free?.();\n\t\t} catch {\n\t\t\t/* best-effort */\n\t\t}\n\t\t// Clear the map entry on ANY exit for this completed key (empty payload\n\t\t// included), so a set can't linger until FIFO eviction.\n\t\tconst key = setKey(slot, fecSetIndex);\n\t\t// sendTsUs here is the set's FIRST-seen shred stamp (recordSetSendTs), vs the\n\t\t// inline path's completing-shred stamp; a set's shreds are sent \u00B5s apart, so\n\t\t// both are equivalent baselines for the \"Listener\u2192browser\" percentiles.\n\t\tconst sendTsUs = setSendTs.get(key) ?? 0n;\n\t\tsetSendTs.delete(key);\n\t\tif (!payload || payload.byteLength === 0) return;\n\t\temitRecovered(payload, { slot, fecSetIndex, sendTsUs, recovered: true });\n\t\treturn;\n\t}\n\n\t// Legacy reassembler: payload-only drain, no set identity available.\n\tif (typeof decoder.try_recover_all !== \"function\") return;\n\tlet result: Uint8Array | null | undefined;\n\ttry {\n\t\tresult = decoder.try_recover_all(nowMs);\n\t} catch (err) {\n\t\tpostError(`try_recover_all threw: ${errString(err)}`, \"worker-runtime\");\n\t\treturn;\n\t}\n\tif (result && result.byteLength > 0) {\n\t\t// slot / fecSetIndex / sendTs are not attributable from a bulk drain.\n\t\temitRecovered(result, {\n\t\t\tslot: 0n,\n\t\t\tfecSetIndex: 0,\n\t\t\tsendTsUs: 0n,\n\t\t\trecovered: true,\n\t\t});\n\t}\n}\n\n// --- Stream batch-drain -----------------------------------------------------\n\n/**\n * Batch-drain a transferred stream of raw shred frames. The read loop lives\n * here (not on the main thread), so the page never pays a `read()` per packet;\n * only recovered sets and the coalesced stats heartbeat cross back.\n */\nasync function drainStream(readable: ReadableStream<Uint8Array>): Promise<void> {\n\t// Replace any prior stream \u2014 one active source at a time.\n\tawait cancelActiveStream();\n\tconst reader = readable.getReader();\n\tactiveStreamReader = reader;\n\ttry {\n\t\tfor (;;) {\n\t\t\tconst { value, done } = await reader.read();\n\t\t\tif (done || disposed) break;\n\t\t\tif (value) ingestFrame(value);\n\t\t}\n\t} catch (err) {\n\t\tif (!disposed) postError(`stream read failed: ${errString(err)}`, \"stream-read-failed\");\n\t} finally {\n\t\tif (activeStreamReader === reader) activeStreamReader = null;\n\t\ttry {\n\t\t\treader.releaseLock();\n\t\t} catch {\n\t\t\t/* already released */\n\t\t}\n\t}\n}\n\nasync function cancelActiveStream(): Promise<void> {\n\tconst reader = activeStreamReader;\n\tif (!reader) return;\n\tactiveStreamReader = null;\n\ttry {\n\t\tawait reader.cancel();\n\t} catch {\n\t\t/* stream may already be closed */\n\t}\n\ttry {\n\t\treader.releaseLock();\n\t} catch {\n\t\t/* already released */\n\t}\n}\n\n// --- WASM load --------------------------------------------------------------\n\n/** Select the decoder class the same way the player does: RS first, legacy fallback. */\nfunction pickDecoderCtor(b: ShredWasmBindings): ShredDecoderCtor | null {\n\tif (typeof b.ShredFecBuffer === \"function\") return b.ShredFecBuffer;\n\tif (typeof b.ShredReassembler === \"function\") return b.ShredReassembler;\n\treturn null;\n}\n\nasync function loadBindings(cfg: ShredWorkerConfig): Promise<ShredWasmBindings> {\n\tconst injected = (self as unknown as { __SHRED_WASM_BINDINGS__?: ShredWasmBindings })\n\t\t.__SHRED_WASM_BINDINGS__;\n\tif (injected && pickDecoderCtor(injected)) {\n\t\treturn injected;\n\t}\n\tif (!cfg.wasmUrl) {\n\t\tthrow new Error(\n\t\t\t\"no shred WASM: set self.__SHRED_WASM_BINDINGS__ = { ShredFecBuffer } or pass config.wasmUrl\",\n\t\t);\n\t}\n\tconst mod = (await import(/* @vite-ignore */ cfg.wasmUrl)) as ShredWasmBindings;\n\tif (typeof mod.default === \"function\") await mod.default();\n\tif (!pickDecoderCtor(mod)) {\n\t\tthrow new Error(\n\t\t\t`shred WASM at ${cfg.wasmUrl} exports neither ShredFecBuffer nor ShredReassembler`,\n\t\t);\n\t}\n\treturn mod;\n}\n\nasync function configure(cfg: ShredWorkerConfig): Promise<void> {\n\tconst gen = ++configureGen;\n\tdisposed = false;\n\tteardownTimers();\n\tawait cancelActiveStream();\n\tif (decoder?.free) {\n\t\ttry {\n\t\t\tdecoder.free();\n\t\t} catch {\n\t\t\t/* best-effort */\n\t\t}\n\t}\n\tdecoder = null;\n\tsetSendTs.clear(); // stale per-set stamps must not survive a reconfigure\n\n\tlet bindings: ShredWasmBindings;\n\ttry {\n\t\tbindings = await loadBindings(cfg);\n\t} catch (err) {\n\t\tif (gen === configureGen) postError(errString(err), \"wasm-load-failed\");\n\t\treturn;\n\t}\n\tif (gen !== configureGen) return; // superseded by a newer configure() while loading\n\tconst Ctor = pickDecoderCtor(bindings);\n\tif (!Ctor) {\n\t\tpostError(\"no shred decoder constructor after load\", \"wasm-bindings-missing\");\n\t\treturn;\n\t}\n\ttry {\n\t\tdecoder = new Ctor(BigInt(cfg.maxSlots), cfg.symbolSize);\n\t} catch (err) {\n\t\tpostError(`shred decoder ctor threw: ${errString(err)}`, \"wasm-load-failed\");\n\t\treturn;\n\t}\n\n\t// Clear any timer a racing continuation may have armed between the await and\n\t// here, so exactly one stats/recover interval is live.\n\tteardownTimers();\n\tconst statsMs = cfg.statsIntervalMs ?? 250;\n\tif (statsMs > 0) statsTimer = setInterval(postStats, statsMs);\n\tconst recoverMs = cfg.recoverIntervalMs ?? 50;\n\tif (recoverMs > 0) recoverTimer = setInterval(() => recoverAll(Date.now()), recoverMs);\n}\n\nfunction teardownTimers(): void {\n\tif (statsTimer !== null) {\n\t\tclearInterval(statsTimer);\n\t\tstatsTimer = null;\n\t}\n\tif (recoverTimer !== null) {\n\t\tclearInterval(recoverTimer);\n\t\trecoverTimer = null;\n\t}\n}\n\nasync function dispose(): Promise<void> {\n\tdisposed = true;\n\tteardownTimers();\n\tawait cancelActiveStream();\n\tif (decoder?.free) {\n\t\ttry {\n\t\t\tdecoder.free();\n\t\t} catch {\n\t\t\t/* best-effort */\n\t\t}\n\t}\n\tdecoder = null;\n\tsetSendTs.clear();\n}\n\n// --- Message dispatch -------------------------------------------------------\n\nself.onmessage = (e: MessageEvent<ShredWorkerCommand>): void => {\n\tconst cmd = e.data;\n\tswitch (cmd.type) {\n\t\tcase \"configure\":\n\t\t\tvoid configure(cmd.config).catch((err) =>\n\t\t\t\tpostError(errString(err), \"wasm-load-failed\"),\n\t\t\t);\n\t\t\tbreak;\n\t\tcase \"attachStream\":\n\t\t\tif (!decoder) {\n\t\t\t\tpostError(\"attachStream before configure\", \"not-configured\");\n\t\t\t\t// Drop the transferred stream so it doesn't leak.\n\t\t\t\tvoid cancelStreamHandle(cmd.readable);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tvoid drainStream(cmd.readable);\n\t\t\tbreak;\n\t\tcase \"feedShred\":\n\t\t\tif (!decoder) {\n\t\t\t\tpostError(\"feedShred before configure\", \"not-configured\");\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tingestFrame(new Uint8Array(cmd.frame));\n\t\t\tbreak;\n\t\tcase \"recoverAll\":\n\t\t\trecoverAll(cmd.nowMs);\n\t\t\tbreak;\n\t\tcase \"dispose\":\n\t\t\tvoid dispose();\n\t\t\tbreak;\n\t\tdefault: {\n\t\t\tconst _exhaustive: never = cmd;\n\t\t\tpostError(`unknown command: ${JSON.stringify(_exhaustive)}`, \"unknown-command\");\n\t\t}\n\t}\n};\n\nself.onmessageerror = (): void => {\n\tpostError(\"failed to deserialize worker message\", \"worker-message\");\n};\n\nasync function cancelStreamHandle(readable: ReadableStream<Uint8Array>): Promise<void> {\n\ttry {\n\t\tawait readable.cancel();\n\t} catch {\n\t\t/* best-effort */\n\t}\n}\n\nfunction errString(err: unknown): string {\n\tif (err instanceof Error) return err.message;\n\treturn String(err);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blockcast/fec-worker — Shred Worker Main-Thread Client
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over the shred (Reed-Solomon) worker, mirroring
|
|
5
|
+
* {@link FecWorkerClient}'s shape. Spawns one Worker, speaks the
|
|
6
|
+
* {@link ShredWorkerCommand}/{@link ShredWorkerEvent} protocol, and keeps the
|
|
7
|
+
* datagram read loop + wire parse + WASM FEC off the main thread.
|
|
8
|
+
*
|
|
9
|
+
* Typical wiring for a WebTransport datagram source (fully off-thread):
|
|
10
|
+
*
|
|
11
|
+
* const client = new ShredWorkerClient(new URL("./shred-fec-worker.js", import.meta.url));
|
|
12
|
+
* client.onRecovered((data, meta) => renderRecoveredSet(data, meta));
|
|
13
|
+
* client.configure({ maxSlots: 5000, symbolSize: 512, wasmUrl: "/vendor/fec/mmt_wasm.js" });
|
|
14
|
+
* client.attachStream(wt.datagrams.readable); // transferred; main does no read() per packet
|
|
15
|
+
*
|
|
16
|
+
* For sources that demux on the main thread (moqtail OBJECT_DATAGRAM callback,
|
|
17
|
+
* IWA multicast bridge), push each raw frame with {@link feedShred} instead.
|
|
18
|
+
*
|
|
19
|
+
* {@link feedShred} is a fire-and-forget `postMessage` with no native
|
|
20
|
+
* backpressure, so a consumer that decodes slower than the ingest rate (a
|
|
21
|
+
* backgrounded/throttled tab, WASM RS falling behind a ~4k shred/s feed) would
|
|
22
|
+
* grow the worker's inbound message queue without limit — the queue lives in the
|
|
23
|
+
* renderer process and has been observed to reach multiple GB, GC-thrashing the
|
|
24
|
+
* page into a wedge. The client bounds it with an in-flight window keyed off the
|
|
25
|
+
* worker's own periodic stats heartbeat (no per-frame ack round-trip); see
|
|
26
|
+
* {@link ShredWorkerClientOptions.maxInflight}.
|
|
27
|
+
*/
|
|
28
|
+
import type { ShredWorkerConfig, ShredWorkerStats, ShredWorkerErrorCode, RecoveredMeta } from "./shred-worker-types.js";
|
|
29
|
+
/** Construction-time options for {@link ShredWorkerClient}. */
|
|
30
|
+
export interface ShredWorkerClientOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Cap on frames posted via {@link ShredWorkerClient.feedShred} but not yet
|
|
33
|
+
* drained by the worker, before further frames are dropped ("skip-to-live" at
|
|
34
|
+
* the worker boundary). Bounds the worker's inbound message queue — the sole
|
|
35
|
+
* unbounded structure on the main-thread-demux (`feedShred`) path.
|
|
36
|
+
*
|
|
37
|
+
* The drain signal is the worker's periodic `stats` heartbeat: the monotonic
|
|
38
|
+
* total of frames it has taken off the queue (`shredsRx + malformed +
|
|
39
|
+
* versionMismatch`). No per-frame acknowledgement crosses the boundary, so the
|
|
40
|
+
* bound is heartbeat-granular — size it above one heartbeat's worth of ingest
|
|
41
|
+
* (default 250ms heartbeat × ~4k frames/s ≈ ~1k frames).
|
|
42
|
+
*
|
|
43
|
+
* Default {@link DEFAULT_MAX_INFLIGHT} (8192 frames ≈ ~10 MB at ~1.3 KB/frame).
|
|
44
|
+
* Set to `0` (or any value `<= 0`) to disable the bound entirely — only do so
|
|
45
|
+
* for a source with its own backpressure (e.g. a bounded {@link
|
|
46
|
+
* ShredWorkerClient.attachStream} producer). Note the bound assumes `feedShred`
|
|
47
|
+
* is the sole ingest path; mixing it with `attachStream` (whose frames also
|
|
48
|
+
* advance the heartbeat total) makes the window under-count and benignly
|
|
49
|
+
* disables the drop.
|
|
50
|
+
*/
|
|
51
|
+
maxInflight?: number;
|
|
52
|
+
}
|
|
53
|
+
/** Default {@link ShredWorkerClientOptions.maxInflight} (~10 MB of queued frames). */
|
|
54
|
+
export declare const DEFAULT_MAX_INFLIGHT = 8192;
|
|
55
|
+
export declare class ShredWorkerClient {
|
|
56
|
+
#private;
|
|
57
|
+
/**
|
|
58
|
+
* @param workerOrUrl - A URL (a Worker is created internally, `type: module`)
|
|
59
|
+
* or a pre-created Worker (for testing via constructor injection).
|
|
60
|
+
* @param opts - Optional {@link ShredWorkerClientOptions}.
|
|
61
|
+
*/
|
|
62
|
+
constructor(workerOrUrl: Worker | URL, opts?: ShredWorkerClientOptions);
|
|
63
|
+
/** Configure the decoder (WASM load + ShredFecBuffer construction happen in the worker). */
|
|
64
|
+
configure(config: ShredWorkerConfig): void;
|
|
65
|
+
/**
|
|
66
|
+
* Hand the worker a stream of raw shred frames to batch-drain. The stream is
|
|
67
|
+
* transferred — the caller must not use it afterward. One active stream at a
|
|
68
|
+
* time; attaching a new one cancels the previous.
|
|
69
|
+
*/
|
|
70
|
+
attachStream(readable: ReadableStream<Uint8Array>): void;
|
|
71
|
+
/**
|
|
72
|
+
* Feed one already-demuxed raw shred frame. `frame` is transferred zero-copy;
|
|
73
|
+
* the caller must not access it afterward.
|
|
74
|
+
*
|
|
75
|
+
* Dropped (frame NOT transferred, so the caller's buffer stays intact) when the
|
|
76
|
+
* in-flight window is full — i.e. the worker is more than `maxInflight` frames
|
|
77
|
+
* behind. This is the memory bound: a slow/wedged consumer costs bounded
|
|
78
|
+
* skip-to-live drops (see {@link droppedBackpressure}), never an unbounded
|
|
79
|
+
* renderer-heap blow-up.
|
|
80
|
+
*/
|
|
81
|
+
feedShred(frame: ArrayBuffer): void;
|
|
82
|
+
/** Explicitly drive loss recovery (in addition to the worker's internal timer). */
|
|
83
|
+
recoverAll(nowMs?: number): void;
|
|
84
|
+
/** Register callback for recovered/completed FEC sets. `data` is a transferred buffer. */
|
|
85
|
+
onRecovered(cb: (data: ArrayBuffer, meta: RecoveredMeta) => void): void;
|
|
86
|
+
/** Register callback for the coalesced stats heartbeat. */
|
|
87
|
+
onStats(cb: (stats: ShredWorkerStats) => void): void;
|
|
88
|
+
/** Register callback for worker errors. */
|
|
89
|
+
onError(cb: (message: string, code?: ShredWorkerErrorCode) => void): void;
|
|
90
|
+
/**
|
|
91
|
+
* Frames dropped by the in-flight backpressure guard since construction
|
|
92
|
+
* (monotonic). Surface this in the UI so sustained loss is visible rather than
|
|
93
|
+
* silent — a rising counter means the consumer can't keep pace with ingest.
|
|
94
|
+
*/
|
|
95
|
+
get droppedBackpressure(): number;
|
|
96
|
+
/**
|
|
97
|
+
* Frames posted via {@link feedShred} but not yet reported drained by the
|
|
98
|
+
* worker's last stats heartbeat (best-effort, heartbeat-granular; never < 0).
|
|
99
|
+
*/
|
|
100
|
+
get inflight(): number;
|
|
101
|
+
/** Dispose the worker. Idempotent. */
|
|
102
|
+
dispose(): void;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=shred-worker-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shred-worker-client.d.ts","sourceRoot":"","sources":["../src/shred-worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAEX,iBAAiB,EAEjB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,MAAM,yBAAyB,CAAC;AAEjC,+DAA+D;AAC/D,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,sFAAsF;AACtF,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,qBAAa,iBAAiB;;IAa7B;;;;OAIG;gBACS,WAAW,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,GAAE,wBAA6B;IAc1E,4FAA4F;IAC5F,SAAS,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAK1C;;;;OAIG;IACH,YAAY,CAAC,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI;IAKxD;;;;;;;;;OASG;IACH,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAUnC,mFAAmF;IACnF,UAAU,CAAC,KAAK,GAAE,MAAmB,GAAG,IAAI;IAK5C,0FAA0F;IAC1F,WAAW,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,aAAa,KAAK,IAAI,GAAG,IAAI;IAIvE,2DAA2D;IAC3D,OAAO,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,GAAG,IAAI;IAIpD,2CAA2C;IAC3C,OAAO,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,oBAAoB,KAAK,IAAI,GAAG,IAAI;IAIzE;;;;OAIG;IACH,IAAI,mBAAmB,IAAI,MAAM,CAEhC;IAED;;;OAGG;IACH,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,sCAAsC;IACtC,OAAO,IAAI,IAAI;CAoCf"}
|