/** * @blockcast/fec-worker — Shred (Reed-Solomon) Worker Protocol * * A sibling codec to the RaptorQ/MMTP worker (`fec-worker.ts`). Solana-style * shred streams carry a different FEC payload identity than MMTP source/repair * symbols — a shred is `(slot, fecSetIndex, localIndex, numData, numCoding, * isCoding, lastInSet)`, not a flat `ssId` — so they need their own command * shape. The transport harness (Worker spawn, onmessage plumbing, zero-copy * `toOwnedArrayBuffer` transfers) is shared; the decoder WASM is not. * * The worker owns the batch-drain: the consumer transfers a WebTransport * `datagrams.readable` (or any `ReadableStream` of raw shred * frames) into the Worker via {@link ShredWorkerCommand} `attachStream`, and * the read loop + wire parse + `ShredFecBuffer.add_shred` all run off the main * thread. Only recovered FEC sets (and coalesced stats) cross back. A * `feedShred` command covers sources that have already demuxed the shred bytes * on the main thread (e.g. a moqtail OBJECT_DATAGRAM callback, or the IWA * multicast bridge). * * Types here are importable from both main thread and Worker contexts. */ // --- Wire format ------------------------------------------------------------ /** * Wire header layout the worker parses. Mirrors the deployed shred player's * `onShredBytes` exactly (libmmt shred-forwarder `emit_frame`) so the off-thread * path is bit-identical to the proven on-thread path. * * v3 (28-byte header): * [0] version u8 (3 or 4; any other version is dropped, not misframed) * [1-8] slot u64 LE * [9-12] fecSetIndex u32 LE * [13-16] localIndex u32 LE (data: 0..numData; coding: numData+position) * [17] flags u8 (bit0 = DATA_COMPLETE, bit1 = IS_CODING_SHRED) * [18] numData u8 (coding shreds only; 0 on data — WASM infers) * [19] numCoding u8 (coding shreds only; 0 on data) * [20-27] sendTsUs u64 LE (µs since epoch, 0 when unstamped) * [28+] erasureShard (equal length across the FEC set) * * DATA_COMPLETE (last data shred in the set) is passed to `add_shred` as its * `lastInSet` argument — the WASM uses it to infer numData for a set that * received only data shreds (no coding). * * v4 has the same 28-byte header, but its body is the full canonical Agave * shred (signature, headers, erasure shard, chained Merkle root, proof, and * the retransmitter signature on resigned variants) — the form a validator TVU * accepts (BLO-41383). The worker recovers the exact v3 erasure-shard view * from it with the forwarder's own `erasure_shard()` slice, so both versions * reach `add_shred` byte-identical: * variant = shred[64]; data 0x9_/0xb_, coding 0x6_/0x7_ (chained merkle; * 0xb_/0x7_ resigned), must agree with IS_CODING_SHRED * length = 1203 (data) / 1228 (coding), exactly * shard start = 64 (data: after the signature) / 89 (coding headers) * shard end = length - (32 root + 20 * (variant & 0x0f) proof * + 64 retransmitter signature if resigned) */ export const SHRED_WIRE_VERSION = 3; /** Wire version whose body is the full canonical shred (see above). */ export const SHRED_WIRE_VERSION_FULL = 4; export const SHRED_HEADER_LEN = 28; export const SHRED_FLAG_DATA_COMPLETE = 0x01; export const SHRED_FLAG_IS_CODING = 0x02; // --- Config ----------------------------------------------------------------- /** Per-decoder configuration for the shred FEC worker. */ export interface ShredWorkerConfig { /** Ring capacity in slots (matches `new ShredFecBuffer(maxSlots, symbolSize)`). */ maxSlots: number; /** Symbol size in bytes (Solana shred payloads are fixed-size within a set). */ symbolSize: number; /** * Module URL for the shred WASM (wasm-bindgen ESM exporting `default` init + * `ShredFecBuffer`). The worker `import()`s it and calls `await default()`. * Ignored when `self.__SHRED_WASM_BINDINGS__` is pre-injected (tests / custom * loaders), mirroring the RaptorQ worker's `__MMT_WASM_BINDINGS__` contract. */ wasmUrl?: string; /** * How often (ms) the worker coalesces and posts a `stats` event. The read * loop never posts per-shred; only recovered frames and this heartbeat cross * the boundary. Default 250ms. */ statsIntervalMs?: number; /** * How often (ms) the worker runs `try_recover_all(now)` to drain timed-out * FEC sets that `add_shred` didn't complete (loss scenarios). Default 50ms, * matching the player's on-thread recovery timer. Set to 0 to disable (the * consumer may instead drive it explicitly with `recoverAll`). */ recoverIntervalMs?: number; } // --- Stats ------------------------------------------------------------------ /** * First-shred-per-slot sample, collected by the worker and drained into each * coalesced `stats` heartbeat. Lets the main thread run app-specific, * slot-derived stats (e.g. the turbine-leg latency estimator, per-slot latency * percentiles) WITHOUT re-parsing the wire header — the worker stays the single * source of truth for parsing, and these cross the boundary aggregated (a * handful per heartbeat), never per-packet. */ export interface SlotSample { /** Slot number (first shred of this slot the worker saw). */ slot: bigint; /** Publisher send timestamp (µs since epoch) of that first shred, 0 when unstamped. */ sendTsUs: bigint; /** Frame byte length of that first shred. */ bytes: number; } /** Aggregate shred-decode counters, emitted on the coalesced `stats` heartbeat. */ export interface ShredWorkerStats { /** Total shred frames parsed (data + coding). */ shredsRx: number; /** Data (source) shreds observed. */ sourceShreds: number; /** Coding (repair) shreds observed. */ repairShreds: number; /** FEC sets completed (direct completion + FEC recovery). */ fecSetsCompleted: number; /** Bytes handed back as recovered/completed sets. */ bytesReassembled: number; /** Sets recovered specifically via `try_recover_all` (loss recovery). */ fecSetsRecovered: number; /** Current `ShredFecBuffer.pending_fec_sets()`, or -1 when unavailable. */ pendingFecSets: number; /** Frames dropped for being shorter than the v3 header. */ malformed: number; /** Frames dropped for carrying a non-v3 version byte (forwarder/worker skew). */ versionMismatch: number; /** Highest slot number observed (as a string — bigint isn't JSON-safe for UI). */ currentSlot: string; /** First-shred-per-slot samples parsed since the previous heartbeat (drained each emit). */ slotSamples: SlotSample[]; } /** Stable identities for worker failures whose message text carries live values. */ export type ShredWorkerErrorCode = | "wasm-load-failed" | "wasm-bindings-missing" | "not-configured" | "stream-read-failed" | "unknown-command" | "worker-runtime" | "worker-message"; // --- Recovered frame metadata ---------------------------------------------- /** Metadata attached to each recovered set the worker emits. */ export interface RecoveredMeta { /** * Slot the recovered set belongs to. Attributed on both the direct-completion * path and the identity-carrying recovery drain * (`try_recover_all_with_identity`); only 0n on the legacy `ShredReassembler` * fallback, which has no identity variant. Do NOT branch on `slot === 0n` as * "unattributed" — that would drop the majority of recovered sets. */ slot: bigint; /** FEC set index within the slot (0 only on the legacy identity-less fallback). */ fecSetIndex: number; /** * Publisher send timestamp (µs since epoch), 0 when unstamped/unknown. On * direct completion this is the completing shred's stamp; on the recovery * drain it is the set's first-seen shred stamp (shreds of a set are sent µs * apart, so the two are equivalent latency baselines). */ sendTsUs: bigint; /** True when produced by loss recovery (`try_recover_all`) rather than direct completion. */ recovered: boolean; } // --- Commands (main -> worker) ---------------------------------------------- /** Discriminated union of all commands sent from the main thread to the shred worker. */ export type ShredWorkerCommand = | { type: "configure"; config: ShredWorkerConfig } | { /** * Hand the worker a stream of raw shred frames to batch-drain. Each * chunk is one wire frame (one WebTransport datagram). The stream is * transferred (add to the postMessage transfer list) — the main thread * must not touch it afterward. */ type: "attachStream"; readable: ReadableStream; } | { /** * Feed one already-demuxed raw shred frame. `frame` is transferred * zero-copy — the caller must not access it after posting. Use this for * sources whose demux stays on the main thread (moqtail OBJECT_DATAGRAM * callback, IWA multicast bridge). */ type: "feedShred"; frame: ArrayBuffer; } | { type: "recoverAll"; nowMs: number } | { type: "dispose" }; // --- Events (worker -> main) ------------------------------------------------ /** Discriminated union of all events emitted from the shred worker to the main thread. */ export type ShredWorkerEvent = | { type: "recovered"; data: ArrayBuffer; meta: RecoveredMeta } | { type: "stats"; stats: ShredWorkerStats } | { type: "error"; message: string; code?: ShredWorkerErrorCode };