@blockcast/fec-worker 0.1.0-main.c71423b8a4e4 → 0.1.0-main.ca0fdf0ae087
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 +9 -5
- package/dist/fec-worker-client.d.ts +8 -4
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +88 -23
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +49 -10
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker.js +59 -41
- package/dist/fec-worker.js.map +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/fec-worker-client.ts +95 -24
- package/src/fec-worker-types.ts +47 -6
- package/src/fec-worker.ts +93 -61
- package/src/index.ts +2 -0
package/src/fec-worker-client.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
FecWorkerCommand,
|
|
13
|
+
FecSymbolCommand,
|
|
13
14
|
FecWorkerEvent,
|
|
14
15
|
FecTrackConfig,
|
|
15
16
|
FecTrackStats,
|
|
@@ -23,7 +24,6 @@ export class FecWorkerClient {
|
|
|
23
24
|
#onFrame: ((data: ArrayBuffer, meta: FrameMeta) => void) | null = null;
|
|
24
25
|
#onSnapshot: ((stats: FecTrackStats) => void) | null = null;
|
|
25
26
|
#onBlockUpdate: ((block: FecBlockSnapshot) => void) | null = null;
|
|
26
|
-
#onGreenFill: ((sbn: number, data: ArrayBuffer) => void) | null = null;
|
|
27
27
|
#onCleanupComplete: ((sbn: number) => void) | null = null;
|
|
28
28
|
#onError: ((message: string, code?: FecWorkerErrorCode) => void) | null = null;
|
|
29
29
|
#cleanupInFlight: Set<number> | null = null;
|
|
@@ -31,6 +31,20 @@ export class FecWorkerClient {
|
|
|
31
31
|
#pendingResets = 0;
|
|
32
32
|
#resetFailed = false;
|
|
33
33
|
#disposed = false;
|
|
34
|
+
// Symbols fed during the current task, sent as one feedSymbols message
|
|
35
|
+
// when the task's microtasks run (BLO-36599). A packet stream delivers many
|
|
36
|
+
// symbols per task, and each postMessage is a structured-clone round trip
|
|
37
|
+
// on the page thread; the batch boundary is the task, not a size or timer.
|
|
38
|
+
#pendingSymbols: FecSymbolCommand[] = [];
|
|
39
|
+
#pendingTransfer: Transferable[] = [];
|
|
40
|
+
#flushQueued = false;
|
|
41
|
+
// Batching starts only once the Worker announced it accepts feedSymbols.
|
|
42
|
+
#batchSymbols = false;
|
|
43
|
+
// The Worker posts blockUpdate events only to a client that subscribed,
|
|
44
|
+
// which this client does once a callback is registered and the Worker
|
|
45
|
+
// announced the command (BLO-36599).
|
|
46
|
+
#blockUpdatesAnnounced = false;
|
|
47
|
+
#blockUpdatesSubscribed = false;
|
|
34
48
|
|
|
35
49
|
/**
|
|
36
50
|
* Create a FecWorkerClient.
|
|
@@ -66,6 +80,7 @@ export class FecWorkerClient {
|
|
|
66
80
|
altaWasmModule?: WebAssembly.Module,
|
|
67
81
|
): void {
|
|
68
82
|
if (this.#disposed) throw new Error("[FecWorkerClient] disposed");
|
|
83
|
+
this.#flushSymbols();
|
|
69
84
|
const cmd: FecWorkerCommand = {
|
|
70
85
|
type: "configure",
|
|
71
86
|
config,
|
|
@@ -84,7 +99,8 @@ export class FecWorkerClient {
|
|
|
84
99
|
|
|
85
100
|
/**
|
|
86
101
|
* Feed a source symbol to the Worker. data ArrayBuffer is transferred (zero-copy).
|
|
87
|
-
* Caller must not access data after this call — it is
|
|
102
|
+
* Caller must not access data after this call — it is transferred with the
|
|
103
|
+
* task's symbol batch, before any later command of this client.
|
|
88
104
|
*
|
|
89
105
|
* Optional `alta` carries the detached ALTA authenticator bytes and the
|
|
90
106
|
* byte offset in `data` where the signed payload ends. The auth buffer is
|
|
@@ -98,18 +114,22 @@ export class FecWorkerClient {
|
|
|
98
114
|
alta?: { auth: ArrayBuffer; payloadEnd: number },
|
|
99
115
|
): void {
|
|
100
116
|
if (this.#disposed) throw new Error("[FecWorkerClient] feedSource on a disposed client");
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
if (alta) {
|
|
118
|
+
this.#queueSymbol(
|
|
119
|
+
{
|
|
103
120
|
type: "feedSource",
|
|
104
121
|
ssId,
|
|
105
122
|
data,
|
|
106
123
|
ts,
|
|
107
124
|
altaAuth: alta.auth,
|
|
108
125
|
altaPayloadEnd: alta.payloadEnd,
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
},
|
|
127
|
+
data,
|
|
128
|
+
alta.auth,
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
this.#queueSymbol({ type: "feedSource", ssId, data, ts }, data);
|
|
132
|
+
}
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/**
|
|
@@ -129,16 +149,38 @@ export class FecWorkerClient {
|
|
|
129
149
|
ts: number,
|
|
130
150
|
): void {
|
|
131
151
|
if (this.#disposed) throw new Error("[FecWorkerClient] feedRepair on a disposed client");
|
|
152
|
+
this.#queueSymbol({ type: "feedRepair", ssStart, ssbLength, rsId, data, ts }, data);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#queueSymbol(symbol: FecSymbolCommand, data: ArrayBuffer, auth?: ArrayBuffer): void {
|
|
156
|
+
if (!this.#batchSymbols) {
|
|
157
|
+
this.#worker.postMessage(symbol, auth ? [data, auth] : [data]);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.#pendingSymbols.push(symbol);
|
|
161
|
+
this.#pendingTransfer.push(data);
|
|
162
|
+
if (auth) this.#pendingTransfer.push(auth);
|
|
163
|
+
if (this.#flushQueued) return;
|
|
164
|
+
this.#flushQueued = true;
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
this.#flushQueued = false;
|
|
167
|
+
this.#flushSymbols();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send the symbols fed so far. Every other command calls this first, so a
|
|
173
|
+
* cleanup barrier, reset or signature still follows all earlier symbols.
|
|
174
|
+
*/
|
|
175
|
+
#flushSymbols(): void {
|
|
176
|
+
if (this.#pendingSymbols.length === 0 || this.#disposed) return;
|
|
177
|
+
const symbols = this.#pendingSymbols;
|
|
178
|
+
const transfer = this.#pendingTransfer;
|
|
179
|
+
this.#pendingSymbols = [];
|
|
180
|
+
this.#pendingTransfer = [];
|
|
132
181
|
this.#worker.postMessage(
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
ssStart,
|
|
136
|
-
ssbLength,
|
|
137
|
-
rsId,
|
|
138
|
-
data,
|
|
139
|
-
ts,
|
|
140
|
-
} satisfies FecWorkerCommand,
|
|
141
|
-
[data],
|
|
182
|
+
{ type: "feedSymbols", symbols } satisfies FecWorkerCommand,
|
|
183
|
+
transfer,
|
|
142
184
|
);
|
|
143
185
|
}
|
|
144
186
|
|
|
@@ -147,6 +189,7 @@ export class FecWorkerClient {
|
|
|
147
189
|
*/
|
|
148
190
|
relayBlockSig(sbn: number, sig: ArrayBuffer, hash: ArrayBuffer): void {
|
|
149
191
|
if (this.#disposed) throw new Error("[FecWorkerClient] relayBlockSig on a disposed client");
|
|
192
|
+
this.#flushSymbols();
|
|
150
193
|
this.#worker.postMessage(
|
|
151
194
|
{ type: "relayBlockSig", sbn, sig, hash } satisfies FecWorkerCommand,
|
|
152
195
|
[sig, hash],
|
|
@@ -178,6 +221,7 @@ export class FecWorkerClient {
|
|
|
178
221
|
/** Reset this track's decoder epoch while preserving Worker configuration. */
|
|
179
222
|
reset(): void {
|
|
180
223
|
if (this.#disposed) throw new Error("[FecWorkerClient] reset on a disposed client");
|
|
224
|
+
this.#flushSymbols();
|
|
181
225
|
this.#pendingResets++;
|
|
182
226
|
this.#cleanupInFlight = null;
|
|
183
227
|
this.#queuedCleanupSbns.clear();
|
|
@@ -185,6 +229,9 @@ export class FecWorkerClient {
|
|
|
185
229
|
}
|
|
186
230
|
|
|
187
231
|
#postCleanup(sbns: Set<number>): void {
|
|
232
|
+
// The cleanup is a queue-drain barrier: every symbol fed before it is
|
|
233
|
+
// posted must reach the Worker first.
|
|
234
|
+
this.#flushSymbols();
|
|
188
235
|
this.#cleanupInFlight = sbns;
|
|
189
236
|
const orderedSbns = [...sbns].sort((a, b) => a - b);
|
|
190
237
|
this.#worker.postMessage(
|
|
@@ -202,14 +249,28 @@ export class FecWorkerClient {
|
|
|
202
249
|
this.#onSnapshot = cb;
|
|
203
250
|
}
|
|
204
251
|
|
|
205
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Register callback for individual block state changes. The Worker posts
|
|
254
|
+
* them only once asked, so registering subscribes (after the Worker
|
|
255
|
+
* announces `subscribeBlockUpdates`). An older Worker that does not
|
|
256
|
+
* announce it posts them unasked.
|
|
257
|
+
*/
|
|
206
258
|
onBlockUpdate(cb: (block: FecBlockSnapshot) => void): void {
|
|
207
259
|
this.#onBlockUpdate = cb;
|
|
260
|
+
this.#subscribeBlockUpdates();
|
|
208
261
|
}
|
|
209
262
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
263
|
+
#subscribeBlockUpdates(): void {
|
|
264
|
+
if (
|
|
265
|
+
this.#disposed ||
|
|
266
|
+
this.#blockUpdatesSubscribed ||
|
|
267
|
+
!this.#blockUpdatesAnnounced ||
|
|
268
|
+
!this.#onBlockUpdate
|
|
269
|
+
) return;
|
|
270
|
+
this.#blockUpdatesSubscribed = true;
|
|
271
|
+
this.#worker.postMessage(
|
|
272
|
+
{ type: "subscribeBlockUpdates" } satisfies FecWorkerCommand,
|
|
273
|
+
);
|
|
213
274
|
}
|
|
214
275
|
|
|
215
276
|
/** Register callback fired after the Worker drains all commands queued before cleanup. */
|
|
@@ -239,6 +300,9 @@ export class FecWorkerClient {
|
|
|
239
300
|
dispose(): void {
|
|
240
301
|
if (this.#disposed) return;
|
|
241
302
|
this.#disposed = true;
|
|
303
|
+
// A terminated Worker decodes nothing; symbols still batched are dropped.
|
|
304
|
+
this.#pendingSymbols = [];
|
|
305
|
+
this.#pendingTransfer = [];
|
|
242
306
|
this.#cleanupInFlight = null;
|
|
243
307
|
this.#queuedCleanupSbns.clear();
|
|
244
308
|
this.#worker.postMessage({ type: "dispose" } satisfies FecWorkerCommand);
|
|
@@ -246,6 +310,16 @@ export class FecWorkerClient {
|
|
|
246
310
|
}
|
|
247
311
|
|
|
248
312
|
#handleEvent(event: FecWorkerEvent): void {
|
|
313
|
+
if (event.type === "capabilities") {
|
|
314
|
+
if (Array.isArray(event.commands) && event.commands.includes("feedSymbols")) {
|
|
315
|
+
this.#batchSymbols = true;
|
|
316
|
+
}
|
|
317
|
+
if (Array.isArray(event.commands) && event.commands.includes("subscribeBlockUpdates")) {
|
|
318
|
+
this.#blockUpdatesAnnounced = true;
|
|
319
|
+
this.#subscribeBlockUpdates();
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
249
323
|
if (event.type === "resetComplete") {
|
|
250
324
|
this.#pendingResets = Math.max(0, this.#pendingResets - 1);
|
|
251
325
|
this.#resetFailed = false;
|
|
@@ -271,9 +345,6 @@ export class FecWorkerClient {
|
|
|
271
345
|
case "blockUpdate":
|
|
272
346
|
this.#onBlockUpdate?.(event.block);
|
|
273
347
|
break;
|
|
274
|
-
case "greenFill":
|
|
275
|
-
this.#onGreenFill?.(event.sbn, event.data);
|
|
276
|
-
break;
|
|
277
348
|
case "cleanupComplete":
|
|
278
349
|
for (const sbn of event.sbns) {
|
|
279
350
|
this.#onCleanupComplete?.(sbn);
|
package/src/fec-worker-types.ts
CHANGED
|
@@ -57,13 +57,15 @@ export interface FecBlockSnapshot {
|
|
|
57
57
|
/** Source block number */
|
|
58
58
|
sbn: number;
|
|
59
59
|
/** Current block state */
|
|
60
|
-
state: "collecting" | "complete" | "recovered" | "failed"
|
|
60
|
+
state: "collecting" | "complete" | "recovered" | "failed";
|
|
61
61
|
/** Number of source symbols received */
|
|
62
62
|
sourceReceived: number;
|
|
63
63
|
/** Number of repair symbols received */
|
|
64
64
|
repairReceived: number;
|
|
65
|
-
/**
|
|
65
|
+
/** Configured source-block geometry (K) */
|
|
66
66
|
k: number;
|
|
67
|
+
/** Authored source symbols (SSB_length) once a repair symbol declared it */
|
|
68
|
+
ssbLength?: number;
|
|
67
69
|
/** Expected repair symbols (P) */
|
|
68
70
|
repairCount: number;
|
|
69
71
|
/** Timestamp of first symbol arrival (ms) */
|
|
@@ -97,8 +99,6 @@ export interface FecTrackStats {
|
|
|
97
99
|
blocksRecovered: number;
|
|
98
100
|
/** Blocks that failed (unrecoverable) */
|
|
99
101
|
blocksFailed: number;
|
|
100
|
-
/** Frames replaced with green-fill */
|
|
101
|
-
greenFillFrames: number;
|
|
102
102
|
/** Recovery success rate 0-100 */
|
|
103
103
|
recoveryRate: number;
|
|
104
104
|
|
|
@@ -168,7 +168,9 @@ export interface FecTrackStats {
|
|
|
168
168
|
export type FecWorkerErrorCode =
|
|
169
169
|
| "recovered-symbol-size-unconfigured"
|
|
170
170
|
| "recovered-block-too-short"
|
|
171
|
+
| "recovered-symbol-ssb-length-invalid"
|
|
171
172
|
| "repair-symbol-size-mismatch"
|
|
173
|
+
| "repair-ssb-length-conflict"
|
|
172
174
|
| "reset-failed"
|
|
173
175
|
| "unknown-command"
|
|
174
176
|
| "command-failed"
|
|
@@ -185,8 +187,14 @@ export interface FrameMeta {
|
|
|
185
187
|
recovered: boolean;
|
|
186
188
|
/** Source block number this frame belongs to */
|
|
187
189
|
sbn: number;
|
|
188
|
-
/** Encoding Symbol ID within the recovered source block (0..
|
|
190
|
+
/** Encoding Symbol ID within the recovered source block (0..ssbLength-1). */
|
|
189
191
|
esi?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Authored source-symbol count of the recovered block (the repair
|
|
194
|
+
* symbols' SSB_length, 1..K). Recovered frames are ESI 0..ssbLength-1;
|
|
195
|
+
* ESIs ssbLength..K-1 are encoder zero padding and are never emitted.
|
|
196
|
+
*/
|
|
197
|
+
ssbLength?: number;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
// --- Worker commands (main -> worker) ---
|
|
@@ -241,6 +249,17 @@ export type FecWorkerCommand =
|
|
|
241
249
|
data: ArrayBuffer;
|
|
242
250
|
ts: number;
|
|
243
251
|
}
|
|
252
|
+
| {
|
|
253
|
+
/**
|
|
254
|
+
* Every source and repair symbol the page fed in one task, in feed
|
|
255
|
+
* order, sharing one transfer list. The client sends symbols this
|
|
256
|
+
* way instead of one message per symbol (BLO-36599); the Worker
|
|
257
|
+
* handles each record exactly like the single-symbol command of the
|
|
258
|
+
* same `type`, and a record that fails does not stop the others.
|
|
259
|
+
*/
|
|
260
|
+
type: "feedSymbols";
|
|
261
|
+
symbols: FecSymbolCommand[];
|
|
262
|
+
}
|
|
244
263
|
| {
|
|
245
264
|
type: "relayBlockSig";
|
|
246
265
|
sbn: number;
|
|
@@ -253,8 +272,21 @@ export type FecWorkerCommand =
|
|
|
253
272
|
sbns: number[];
|
|
254
273
|
}
|
|
255
274
|
| { type: "reset" }
|
|
275
|
+
/**
|
|
276
|
+
* Ask the Worker to post a blockUpdate on every block transition. Without
|
|
277
|
+
* it the Worker posts none: each one is a structured clone and a task on
|
|
278
|
+
* the page thread, and the player does not consume them (BLO-36599).
|
|
279
|
+
* Sent only to a Worker that announced it in `capabilities`.
|
|
280
|
+
*/
|
|
281
|
+
| { type: "subscribeBlockUpdates" }
|
|
256
282
|
| { type: "dispose" };
|
|
257
283
|
|
|
284
|
+
/** One symbol record of a `feedSymbols` batch. */
|
|
285
|
+
export type FecSymbolCommand = Extract<
|
|
286
|
+
FecWorkerCommand,
|
|
287
|
+
{ type: "feedSource" } | { type: "feedRepair" }
|
|
288
|
+
>;
|
|
289
|
+
|
|
258
290
|
// --- Worker events (worker -> main) ---
|
|
259
291
|
|
|
260
292
|
/** Discriminated union of all events emitted from Worker to main thread. */
|
|
@@ -262,7 +294,16 @@ export type FecWorkerEvent =
|
|
|
262
294
|
| { type: "frame"; data: ArrayBuffer; meta: FrameMeta }
|
|
263
295
|
| { type: "snapshot"; stats: FecTrackStats }
|
|
264
296
|
| { type: "blockUpdate"; block: FecBlockSnapshot }
|
|
265
|
-
| { type: "greenFill"; sbn: number; data: ArrayBuffer }
|
|
266
297
|
| { type: "cleanupComplete"; sbns: number[] }
|
|
267
298
|
| { type: "resetComplete" }
|
|
299
|
+
/**
|
|
300
|
+
* Optional commands this Worker accepts, posted once it is configured.
|
|
301
|
+
* The client uses `feedSymbols` only after the Worker names it, so a page
|
|
302
|
+
* that meets an older cached Worker bundle keeps sending one command per
|
|
303
|
+
* symbol instead of a batch that Worker would reject (BLO-36599).
|
|
304
|
+
*/
|
|
305
|
+
| { type: "capabilities"; commands: FecWorkerOptionalCommand[] }
|
|
268
306
|
| { type: "error"; message: string; code?: FecWorkerErrorCode };
|
|
307
|
+
|
|
308
|
+
/** Commands a client may send only after the Worker announced them. */
|
|
309
|
+
export type FecWorkerOptionalCommand = "feedSymbols" | "subscribeBlockUpdates";
|
package/src/fec-worker.ts
CHANGED
|
@@ -40,8 +40,12 @@ interface WasmFecDecoder {
|
|
|
40
40
|
timestamp: number,
|
|
41
41
|
): Uint8Array | undefined;
|
|
42
42
|
configure_params(k: number, symbolSize: number): void;
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Retire the named blocks (last-chance decode + stats), driven by the
|
|
45
|
+
* manager's exact deadlines. Nothing is synthesised for a lost block:
|
|
46
|
+
* concealment is the sink's hold-last-good-frame.
|
|
47
|
+
*/
|
|
48
|
+
cleanup_blocks_detailed(sbns: Uint32Array): void;
|
|
45
49
|
/** Remove decoder blocks below its configured live-SBN interleave horizon. */
|
|
46
50
|
cleanup_by_sbn(current_sbn: number): Uint32Array;
|
|
47
51
|
flush(): void;
|
|
@@ -61,10 +65,7 @@ interface WasmFecDecoder {
|
|
|
61
65
|
|
|
62
66
|
interface WasmBindings {
|
|
63
67
|
initSync(module: { module: WebAssembly.Module }): unknown;
|
|
64
|
-
MmtFecDecoder: new (
|
|
65
|
-
interleaveDepth: number,
|
|
66
|
-
greenFillEnabled: boolean,
|
|
67
|
-
) => WasmFecDecoder;
|
|
68
|
+
MmtFecDecoder: new (interleaveDepth: number) => WasmFecDecoder;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// --- Worker state (local to this Worker instance) ---
|
|
@@ -75,6 +76,8 @@ let altaEnabled = false;
|
|
|
75
76
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
77
|
let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
|
|
77
78
|
let snapshotTimer: ReturnType<typeof setInterval> | null = null;
|
|
79
|
+
// blockUpdate events are posted only after a client subscribes (BLO-36599).
|
|
80
|
+
let blockUpdatesSubscribed = false;
|
|
78
81
|
|
|
79
82
|
// Block tracking
|
|
80
83
|
const blocks = new Map<number, FecBlockSnapshot>();
|
|
@@ -95,7 +98,6 @@ let repairSymbols = 0;
|
|
|
95
98
|
let blocksComplete = 0;
|
|
96
99
|
let blocksRecovered = 0;
|
|
97
100
|
let blocksFailed = 0;
|
|
98
|
-
let greenFillFrames = 0;
|
|
99
101
|
let altaVerified = 0;
|
|
100
102
|
let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
|
|
101
103
|
const altaFailedDetails: Array<{
|
|
@@ -444,7 +446,6 @@ function buildStatsSnapshot(): FecTrackStats {
|
|
|
444
446
|
blocksComplete,
|
|
445
447
|
blocksRecovered,
|
|
446
448
|
blocksFailed,
|
|
447
|
-
greenFillFrames,
|
|
448
449
|
recoveryRate:
|
|
449
450
|
blocksComplete + blocksRecovered > 0
|
|
450
451
|
? Math.round(
|
|
@@ -478,6 +479,7 @@ function emitSnapshot(): void {
|
|
|
478
479
|
}
|
|
479
480
|
|
|
480
481
|
function emitBlockUpdate(block: FecBlockSnapshot): void {
|
|
482
|
+
if (!blockUpdatesSubscribed) return;
|
|
481
483
|
emit({ type: "blockUpdate", block: { ...block } });
|
|
482
484
|
}
|
|
483
485
|
|
|
@@ -496,16 +498,19 @@ function emitRecoveredSymbols(
|
|
|
496
498
|
});
|
|
497
499
|
return;
|
|
498
500
|
}
|
|
499
|
-
|
|
501
|
+
// The decoder returns only the block's authored source symbols
|
|
502
|
+
// (SSB_length x T); the encoder's zero padding is not media.
|
|
503
|
+
const ssbLength = block.ssbLength ?? config.k;
|
|
504
|
+
if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
|
|
500
505
|
emit({
|
|
501
506
|
type: "error",
|
|
502
507
|
code: "recovered-block-too-short",
|
|
503
|
-
message: `[FecWorker] recovered block
|
|
508
|
+
message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`,
|
|
504
509
|
});
|
|
505
510
|
return;
|
|
506
511
|
}
|
|
507
512
|
|
|
508
|
-
for (let i = 0; i <
|
|
513
|
+
for (let i = 0; i < ssbLength; i++) {
|
|
509
514
|
const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
|
|
510
515
|
|
|
511
516
|
// Source authentication now runs once at router admission in
|
|
@@ -520,6 +525,7 @@ function emitRecoveredSymbols(
|
|
|
520
525
|
recovered: true,
|
|
521
526
|
sbn,
|
|
522
527
|
esi: i,
|
|
528
|
+
ssbLength,
|
|
523
529
|
};
|
|
524
530
|
emit({ type: "frame", data: buf, meta }, [buf]);
|
|
525
531
|
}
|
|
@@ -611,10 +617,7 @@ async function handleConfigure(
|
|
|
611
617
|
|
|
612
618
|
// Decoder policy and geometry are explicit at the epoch boundary. The WASM
|
|
613
619
|
// module has no guessed K/T/interleave slow path.
|
|
614
|
-
wasmDecoder = new wasm.MmtFecDecoder(
|
|
615
|
-
cmd.config.interleaveDepth,
|
|
616
|
-
cmd.config.trackType === "video",
|
|
617
|
-
);
|
|
620
|
+
wasmDecoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth);
|
|
618
621
|
config = cmd.config;
|
|
619
622
|
altaEnabled = !!cmd.altaPublicKey;
|
|
620
623
|
|
|
@@ -662,6 +665,10 @@ async function handleConfigure(
|
|
|
662
665
|
|
|
663
666
|
wasmDecoder.configure_params(config.k, config.symbolSize);
|
|
664
667
|
|
|
668
|
+
// Tell the client which optional commands this Worker accepts, so it can
|
|
669
|
+
// batch symbols without risking a Worker that predates the batch command.
|
|
670
|
+
emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
|
|
671
|
+
|
|
665
672
|
// Start periodic snapshot emission (200ms floor)
|
|
666
673
|
startSnapshotTimer();
|
|
667
674
|
|
|
@@ -716,9 +723,11 @@ function handleFeedSource(
|
|
|
716
723
|
// authenticated by the canonical manifest verifier at router admission.
|
|
717
724
|
if (altaEnabled) altaPending++;
|
|
718
725
|
|
|
719
|
-
// Check if block is complete (all
|
|
726
|
+
// Check if block is complete (all authored source symbols received:
|
|
727
|
+
// SSB_length once a repair symbol declared it, otherwise K)
|
|
728
|
+
const authoredLength = block.ssbLength ?? block.k;
|
|
720
729
|
if (
|
|
721
|
-
block.sourceReceived >=
|
|
730
|
+
block.sourceReceived >= authoredLength &&
|
|
722
731
|
block.state === "collecting"
|
|
723
732
|
) {
|
|
724
733
|
markBlockTerminal(block, "complete");
|
|
@@ -730,7 +739,7 @@ function handleFeedSource(
|
|
|
730
739
|
// of earlier missing symbols after repair arrived. Clean completion is
|
|
731
740
|
// already routed by the source path, so only emit when the block did not
|
|
732
741
|
// reach K source arrivals.
|
|
733
|
-
if (result && result.byteLength > 0 && block.sourceReceived <
|
|
742
|
+
if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
|
|
734
743
|
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
735
744
|
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
736
745
|
recoveryTimes.push(recoveryMs);
|
|
@@ -786,6 +795,16 @@ function handleFeedRepair(
|
|
|
786
795
|
const existingBlock = blocks.get(sbn);
|
|
787
796
|
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
788
797
|
if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
|
|
798
|
+
// One block has one authored SSB_length; a repair symbol naming another
|
|
799
|
+
// is dropped before it reaches the decoder (which rejects it too).
|
|
800
|
+
if (existingBlock?.ssbLength !== undefined && existingBlock.ssbLength !== cmd.ssbLength) {
|
|
801
|
+
emit({
|
|
802
|
+
type: "error",
|
|
803
|
+
code: "repair-ssb-length-conflict",
|
|
804
|
+
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (sbn=${sbn})`,
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
789
808
|
|
|
790
809
|
const result = wasmDecoder.add_repair(
|
|
791
810
|
cmd.ssStart,
|
|
@@ -802,13 +821,20 @@ function handleFeedRepair(
|
|
|
802
821
|
}
|
|
803
822
|
repairEsis.add(repairEsi);
|
|
804
823
|
const block = getOrCreateBlock(sbn);
|
|
824
|
+
block.ssbLength = cmd.ssbLength;
|
|
805
825
|
block.repairReceived++;
|
|
806
826
|
block.lastSymbolTs = performance.now();
|
|
807
827
|
repairSymbols++;
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
|
|
828
|
+
// Every authored source symbol already arrived: knowing SSB_length makes
|
|
829
|
+
// the block clean. The decoder's return is the source already routed.
|
|
830
|
+
// Otherwise, returned data is the recovered source block: its SSB_length
|
|
831
|
+
// fixed-width authored symbols concatenated. Split it before emitting so
|
|
832
|
+
// MmtpRouter gets the same one-symbol shape as a direct source packet.
|
|
833
|
+
if (block.sourceReceived >= cmd.ssbLength && block.state === "collecting") {
|
|
834
|
+
markBlockTerminal(block, "complete");
|
|
835
|
+
blocksComplete++;
|
|
836
|
+
emitBlockUpdate(block);
|
|
837
|
+
} else if (result && result.byteLength > 0) {
|
|
812
838
|
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
813
839
|
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
814
840
|
recoveryTimes.push(recoveryMs);
|
|
@@ -855,21 +881,19 @@ function handleCleanup(
|
|
|
855
881
|
throw new Error("[FecWorker] cleanup: not configured");
|
|
856
882
|
}
|
|
857
883
|
|
|
858
|
-
// Call WASM cleanup only after all earlier symbol commands have drained
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
: new Uint8Array();
|
|
884
|
+
// Call WASM cleanup only after all earlier symbol commands have drained,
|
|
885
|
+
// and only for the exact SBNs the manager's deadlines named; command
|
|
886
|
+
// coalescing must not infer ownership from the largest timed-out SBN.
|
|
887
|
+
if (pendingSbns.length > 0) {
|
|
888
|
+
wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
|
|
889
|
+
}
|
|
865
890
|
// Manager deadlines are block-scoped and may expire a newer SBN while an
|
|
866
891
|
// older interleaved block is still live. Only accepted media progression
|
|
867
892
|
// advances the shared JS/Rust horizon; cleanup creates exact tombstones.
|
|
868
893
|
|
|
869
894
|
// Rust already finalized every SBN behind the media frontier. For each
|
|
870
895
|
// remaining exact request, mirror the block-scoped WASM boundary immediately
|
|
871
|
-
// so JS never retains state for a decoder block that is gone
|
|
872
|
-
// the trusted cleanup framing were to fail.
|
|
896
|
+
// so JS never retains state for a decoder block that is gone.
|
|
873
897
|
for (const sbn of pendingSbns) {
|
|
874
898
|
let currentBlock = blocks.get(sbn);
|
|
875
899
|
if (currentBlock && isTerminalBlock(currentBlock)) {
|
|
@@ -886,12 +910,6 @@ function handleCleanup(
|
|
|
886
910
|
}
|
|
887
911
|
emitBlockUpdate(currentBlock);
|
|
888
912
|
}
|
|
889
|
-
|
|
890
|
-
const greenFills = decodeCleanupRecords(cleanupRecords);
|
|
891
|
-
for (const { sbn, data } of greenFills) {
|
|
892
|
-
greenFillFrames++;
|
|
893
|
-
emit({ type: "greenFill", sbn, data }, [data]);
|
|
894
|
-
}
|
|
895
913
|
} finally {
|
|
896
914
|
// Message ordering makes this an acknowledgement that every source/repair
|
|
897
915
|
// command posted before cleanup has completed. The main thread must not
|
|
@@ -900,29 +918,6 @@ function handleCleanup(
|
|
|
900
918
|
}
|
|
901
919
|
}
|
|
902
920
|
|
|
903
|
-
function decodeCleanupRecords(
|
|
904
|
-
framed: Uint8Array,
|
|
905
|
-
): Array<{ sbn: number; data: ArrayBuffer }> {
|
|
906
|
-
const records: Array<{ sbn: number; data: ArrayBuffer }> = [];
|
|
907
|
-
const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
|
|
908
|
-
let offset = 0;
|
|
909
|
-
while (offset < framed.byteLength) {
|
|
910
|
-
if (framed.byteLength - offset < 8) {
|
|
911
|
-
throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record header");
|
|
912
|
-
}
|
|
913
|
-
const sbn = view.getUint32(offset, false);
|
|
914
|
-
const length = view.getUint32(offset + 4, false);
|
|
915
|
-
offset += 8;
|
|
916
|
-
if (length > framed.byteLength - offset) {
|
|
917
|
-
throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record payload");
|
|
918
|
-
}
|
|
919
|
-
const data = toOwnedArrayBuffer(framed.subarray(offset, offset + length));
|
|
920
|
-
records.push({ sbn, data });
|
|
921
|
-
offset += length;
|
|
922
|
-
}
|
|
923
|
-
return records;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
921
|
function handleDispose(): void {
|
|
927
922
|
// Clear snapshot timer (MUST clear to prevent leaks)
|
|
928
923
|
if (snapshotTimer !== null) {
|
|
@@ -979,7 +974,6 @@ function handleReset(): void {
|
|
|
979
974
|
blocksComplete = 0;
|
|
980
975
|
blocksRecovered = 0;
|
|
981
976
|
blocksFailed = 0;
|
|
982
|
-
greenFillFrames = 0;
|
|
983
977
|
altaVerified = 0;
|
|
984
978
|
altaFailed = 0;
|
|
985
979
|
altaFailedDetails.length = 0;
|
|
@@ -994,6 +988,38 @@ function handleReset(): void {
|
|
|
994
988
|
if (config) emitSnapshot();
|
|
995
989
|
}
|
|
996
990
|
|
|
991
|
+
/**
|
|
992
|
+
* A batch of symbols fed in one page task (BLO-36599). Each record is
|
|
993
|
+
* handled exactly as its single-symbol command would be, in order, and a
|
|
994
|
+
* failing record reports the same `command-failed` error that command would
|
|
995
|
+
* without stopping the records after it.
|
|
996
|
+
*/
|
|
997
|
+
function handleFeedSymbols(
|
|
998
|
+
cmd: Extract<FecWorkerCommand, { type: "feedSymbols" }>,
|
|
999
|
+
): void {
|
|
1000
|
+
if (!Array.isArray(cmd.symbols)) {
|
|
1001
|
+
throw new Error("[FecWorker] feedSymbols: symbols must be an array");
|
|
1002
|
+
}
|
|
1003
|
+
for (const symbol of cmd.symbols) {
|
|
1004
|
+
try {
|
|
1005
|
+
if (symbol?.type === "feedSource") handleFeedSource(symbol);
|
|
1006
|
+
else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
|
|
1007
|
+
else {
|
|
1008
|
+
throw new Error(
|
|
1009
|
+
`[FecWorker] feedSymbols: unknown symbol type ${String((symbol as { type?: unknown } | null)?.type)}`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
emit({
|
|
1014
|
+
type: "error",
|
|
1015
|
+
code: "command-failed",
|
|
1016
|
+
message:
|
|
1017
|
+
err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
997
1023
|
// --- Main message handler ---
|
|
998
1024
|
|
|
999
1025
|
async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
|
|
@@ -1011,6 +1037,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
|
|
|
1011
1037
|
case "feedRepair":
|
|
1012
1038
|
handleFeedRepair(cmd);
|
|
1013
1039
|
break;
|
|
1040
|
+
case "feedSymbols":
|
|
1041
|
+
handleFeedSymbols(cmd);
|
|
1042
|
+
break;
|
|
1014
1043
|
case "relayBlockSig":
|
|
1015
1044
|
handleRelayBlockSig(cmd);
|
|
1016
1045
|
break;
|
|
@@ -1020,6 +1049,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
|
|
|
1020
1049
|
case "reset":
|
|
1021
1050
|
handleReset();
|
|
1022
1051
|
break;
|
|
1052
|
+
case "subscribeBlockUpdates":
|
|
1053
|
+
blockUpdatesSubscribed = true;
|
|
1054
|
+
break;
|
|
1023
1055
|
case "dispose":
|
|
1024
1056
|
handleDispose();
|
|
1025
1057
|
break;
|