# @blockcast/fec-worker

A Web Worker wrapper around `@blockcast/mmt-fec` that moves the RaptorQ decode off the main thread. The main-thread client posts source and repair symbols to a dedicated worker via `postMessage` with `Transferable` `ArrayBuffer` ownership transfer, so packet payloads cross the thread boundary with **zero copies**. Decoded / FEC-recovered frames travel back the same way. Critical for IWA / web players where main-thread blocking on FEC decode would cause frame drops at the renderer.

## Install

```bash
pnpm add @blockcast/fec-worker
```

The package is dual-shaped: the main-thread side (`FecWorkerClient`, types) is the default export; the worker entry is exposed as the `./worker` subpath so consumers can hand its URL to `new Worker(...)` (or to the `FecWorkerClient` constructor, which spawns it for you).

## Quick start

```ts
import { FecWorkerClient, type FecTrackConfig } from "@blockcast/fec-worker"

// Worker URL resolved against your bundler. ?worker / new URL() / import.meta —
// any pattern that gives you a module-Worker URL works.
const workerUrl = new URL("./fec-worker-entry.js", import.meta.url)

const client = new FecWorkerClient(workerUrl)

client.onFrame((data, meta) => {
  // data is an ArrayBuffer transferred from the worker (zero-copy).
  // meta tells you whether this frame was recovered via FEC.
  decoder.decode(data) // hand off to WebCodecs / MSE
})
client.onSnapshot((stats) => fecPanel.render(stats))
client.onError((msg) => console.error("[fec]", msg))

// Configure with a pre-compiled mmt-wasm Module from your loader's importmap.
const config: FecTrackConfig = {
  codec: "avc1.64001f",
  trackName: "video/base",
  trackType: "video",
  resolution: { w: 1920, h: 1080 },
  framerate: 30,
  algorithm: "raptor",
  blockProfile: "iso-ssbg1-v1", // catalog fec.blockProfile: required, never defaulted; the only profile (the retired "k-padded-v1" is refused)
  k: 32, // the maximum SSB_length of a block
  repairCount: 8,
  symbolSize: 1312,
  interleaveDepth: 30,
  interleaveMs: 1000, // 30 frames @ 30 fps
  deliveryWindowMs: 2000, // computeFecDeliveryWindowMs(...) from the catalog
  fecMode: "subframe",
  relayBlockSigEnabled: false,
}
client.configure(config, mmtWasmModule)

// Feed packets as they arrive from transport. ArrayBuffers are transferred —
// caller MUST NOT touch `data` after the call.
client.feedSource(ssId, packetBuffer, captureTs)
client.feedRepair(ssStart, ssbLength, rsId, repairBuffer, captureTs)
```

`FecWorkerClient` accepts either a `URL` (it spawns the worker for you with `{ type: "module" }`) or a pre-created `Worker` instance (used in tests for constructor injection).

## Why a Worker

A 1080p30 stream with `K=32, P=8, symbolSize=1312` produces ~1 RaptorQ source-block decode every second per track. Decode time is amortized across SIMD lanes inside the WASM, but the *worst-case* block — one with the maximum recoverable loss pattern — can spike past 16 ms. Run that on the main thread and you've blown a frame; the renderer drops, the user sees a stutter, and CMCD reports a pacing violation.

Pushing the decode into a dedicated Worker keeps the main thread free for input handling, MSE buffer maintenance, and frame pacing. The same module compiled into both contexts decodes identically; what changes is who's blocked when a worst-case block hits.

This is the **Worker-per-track FEC pipeline** architectural decision tracked in the parent monorepo as a v2.0 milestone (`pim-multicast-gateway/.planning/MILESTONES.md` — "Worker-per-track FEC pipeline with zero-copy ArrayBuffer transfers"). Each track (base / delta / repair / audio) runs in its own Worker, so a stall on one track's decode never starves the others.

## Architecture

```
   Main thread                              │   FEC Worker (per track)
                                            │
   transport packet                         │
        │                                   │
        ▼                                   │
   FecWorkerClient.feedSource(...)          │
        │                                   │
        │  postMessage(cmd, [data, ...])    │
        │   ─ data: ArrayBuffer (transferred, neutered on sender)
        │   ─ wasmModule: WebAssembly.Module (structured-cloned)
        ├──────────────────────────────────►│  self.onmessage
        │                                   │       │
        │                                   │       ▼
        │                                   │  WasmFecDecoder.add_source(...)
        │                                   │       │
        │                                   │       ▼  (recovered or pass-through)
        │                                   │  postMessage({type:"frame", data}, [data])
        │◄──────────────────────────────────┤
        │                                   │
        ▼                                   │
   FecWorkerClient.onFrame(data, meta)      │
        │                                   │
        ▼                                   │
   WebCodecs / MSE                          │
```

Buffers cross the boundary via the `Transferable` list. After `postMessage`, the sender's `ArrayBuffer` is detached — touching it throws. This is the contract that makes zero-copy safe: there is no aliased view that could be mutated mid-decode.

`WebAssembly.Module` is structured-cloneable (not transferable) and is *cloned* (cheap — the underlying compiled module is shared across realms).

Errors that escape the worker's `try/catch` (in either the sync command path or the async configure path) are emitted as `{type: "error", message}` events rather than thrown — the main thread surfaces them through `onError`.

## Message protocol

All messages are typed as discriminated unions in `fec-worker-types.ts`. Both the worker and the client import the same definitions, so adding a message variant is a single-file change that the compiler enforces on both sides.

### Inbound — `FecWorkerCommand` (main → worker)

| `type` | Payload | Transferred |
|--------|---------|-------------|
| `configure` | `config: FecTrackConfig`, `wasmModule: WebAssembly.Module` | none — WASM modules are structured-cloned |
| `feedSource` | `ssId: number`, `data: ArrayBuffer`, `ts: number` | `data` |
| `feedRepair` | `ssStart: number`, `ssbLength: number`, `rsId: number`, `data: ArrayBuffer`, `ts: number` | `data` |
| `relayBlockSig` | `sbn: number`, `sig: ArrayBuffer`, `hash: ArrayBuffer` | `sig`, `hash` |
| `cleanup` | `sbns: number[]` (all timed-out blocks covered by this coalesced barrier) | none |
| `subscribeBlockUpdates` | — (send only after `capabilities` lists it) | none |
| `dispose` | — | none |

`feedSource` carries the **32-bit Source Symbol ID** from the MMTP Source FEC Payload ID (ISO 23008-1 §C.5.2), consecutive per FEC source packet and wrapping at 2^32, and as `data` the protected packet itself, at most T-2 bytes (ssbg_mode1, §C.3.3). A source belongs to the block a repair states; until one does, the Worker and the decoder hold it unplaced. Never derive a block from an SS_ID.

`feedRepair` carries the wire-level Repair FEC Payload ID fields directly (ISO 23008-1 §C.5.3): `ssStart` is the SS_ID of the first source symbol in the block (any uint32; the block key), `ssbLength` is the authored source-symbol count in `1..configured K`, and `rsId` is the repair index within the block. The block is `[ssStart, ssStart + ssbLength)` with K' = `ssbLength` and repair ESI = `ssbLength + rsId`; the configured K is only the maximum `ssbLength`; K is never inferred from repair traffic.

### Outbound — `FecWorkerEvent` (worker → main)

| `type` | Payload | Transferred back |
|--------|---------|------------------|
| `frame` | `data: ArrayBuffer`, `meta: FrameMeta` | `data` |
| `snapshot` | `stats: FecTrackStats` | none (structured clone) |
| `capabilities` | `commands: FecWorkerOptionalCommand[]` (after `configure`) | none |
| `blockUpdate` | `block: FecBlockSnapshot` (only after `subscribeBlockUpdates`) | none |
| `cleanupComplete` | `sbns: number[]` | none |
| `error` | `message: string` | none |

The Worker posts `blockUpdate` only to a client that sent `subscribeBlockUpdates`: each one is a structured clone and a page-thread task, and the snapshot already counts every transition. `FecWorkerClient` subscribes when `onBlockUpdate` is registered and the Worker has announced the command. A Worker that predates the command posts `blockUpdate` unasked and is never sent it (BLO-36599).

`FrameMeta` carries `recovered: boolean` (this frame came out of the FEC decode, not the direct path), `sbn: number`, and for recovered frames `esi` / `ssbLength`. Source provenance is verified upstream at router admission by `@blockcast/mmt-manifest-verify`, not in the Worker.

## Lifecycle

```
new FecWorkerClient(url)
        │  worker spawned with { type: "module" }
        ▼
client.configure(config, wasmModule)
        │  worker initSyncs WASM, instantiates MmtFecDecoder(interleaveDepth, blockProfile),
        │  refuses a decoder whose get_block_profile() differs,
        │  immediately configures exact catalog K/T (no decoder defaults),
        │  rejects invalid geometry or negative spans; zero keeps in-band FEC,
        │  starts the 200 ms snapshot timer, emits initial snapshot
        ▼
client.feedSource(...) / feedRepair(...)         ◄── steady state
        │  every transferred ArrayBuffer detaches on the main thread
        │  decoded / recovered frames flow back as {type:"frame"}
        │  every 200 ms a {type:"snapshot"} carries FecTrackStats
        ▼
client.cleanup(sbn)                              ◄── per-block inactivity deadline
        │  coalesces queued SBNs behind one worker queue-drain barrier
        │  evicts blocks relative to max(sbns) − D × 3
        │  retires exactly those SBNs via WASM cleanup_blocks_detailed(); a lost
        │  block is never answered with a synthesised frame (sinks hold the last
        │  good frame, per @blockcast/mmt-container conceal-timeline)
        │  acknowledges every SBN in {type:"cleanupComplete", sbns}
        │  any block still in "collecting" at eviction time is counted as failed
        ▼
client.dispose()
           sends {type:"dispose"}, terminates worker. The WASM decoder is
           explicitly free()'d before terminate so its linear
           memory is released (FinalizationRegistry would not run between
           dispose and terminate). Idempotent.
```

### Backpressure

`postMessage` is fire-and-forget at the JS level — there is no built-in flow control. If the main thread can't drain `frame` events fast enough, the worker's outbound queue grows. The remedy is upstream of this package: pace your input (`feedSource` / `feedRepair`) against actual decode capacity by watching the `avgRecoveryMs` / `maxRecoveryMs` fields of each `snapshot`. Spikes in `maxRecoveryMs` past your frame budget are the early signal.

## Stats

The `snapshot` event delivers a `FecTrackStats` every 200 ms (catalog-derived; not configurable from this layer). Key fields:

| Field | Meaning |
|-------|---------|
| `sourceSymbols`, `repairSymbols` | Raw ingress counters |
| `blocksComplete` | Block had all K source symbols without needing repair |
| `blocksRecovered` | Block was recovered using ≥1 repair symbol |
| `blocksFailed` | Block aged out before reaching K |
| `recoveryRate` | `recovered / (recovered + failed)`, 0–100 |
| `avgRecoveryMs`, `maxRecoveryMs` | Rolling decode latency (capped at 1000 samples) |
| `liveSbn` | Newest accepted SS_ID or SS_Start, in RFC 1982 serial order |
| `repairSymbolSizeMismatch` | Repair symbols rejected before WASM because their byte length did not match `T` |
| `sourceDroppedRetired` / `sourceDroppedTerminal` / `sourceDroppedDuplicate` | Source symbols dropped before WASM, by **first matching** cause (see below) |
| `blocks` | Up to `max(D × 3, 24)` recent `FecBlockSnapshot`s for live visualization |
| `interleaveWindowBlocks` | `D` from config |

The three `sourceDropped*` counters split by cause so a dashboard can tell the failure modes apart. They are tested in a fixed order, each returning early — `Retired` → `Terminal` → `Duplicate` — so a symbol counts once, under the **first** cause that matches: a symbol that is both past retention and a repeat counts only as `Retired`, and `Terminal` absorbs every post-settle duplicate. A block that settles at K-1 source symbols (BLO-35597 defect 2) is explained by a late symbol past retention (`Retired`), a symbol for a block that had already settled — complete, recovered **or failed** (`Terminal`), or a repeated SS_ID (`Duplicate`). `Retired` and `Terminal` narrow the cause; they do not attribute it. `Duplicate` does: it cannot fire once a block settles — the `Terminal` check returns first, and `markBlockTerminal` retires the block's accepted-ESI set anyway — so a non-zero `Duplicate` is positive evidence that a repeated SS_ID reached the worker while the block was **still collecting** — a duplicate packet on the wire (an ESI is the SS_ID's serial distance from SS_Start, so no derivation can collide). It attributes the **timing**, not the origin. A symbol lost in transit never reaches the worker and increments nothing, so **all three reading zero is not evidence of a publisher defect** — wire loss produces an identical shape. Separate those with `FecBlockSnapshot.ssbLength`: below `k` is a genuinely short authored block, equal to `k` with fewer accepted is loss-or-late, undefined (no repair symbol yet) leaves both open. All three are cumulative across decoder epochs, so they stay comparable against the `sourceSymbols` denominator; only a reset zeroes them.


`FecTrackStats` is also the shape consumed downstream by `@blockcast/mmt-container`'s `CmcdProducer` and the diagnostics `fec-panel` — keep it stable when you extend it.

## Related packages

- **`@blockcast/mmt-fec`** — the wrapped library. Container-agnostic RaptorQ decoder + block manager + reorder buffer. Lives on the main thread when you don't need a Worker; this package is the off-thread variant of the same decoder.
- **`@blockcast/mmt-container`** — the consumer that wires this Worker into the data path. `FecManager` instantiates one `FecWorkerClient` per track, routes MMTP source/repair payloads in, and pumps recovered frames into the MFU reassembler / fmp4 segmenter.

## Testing

```bash
pnpm test           # vitest run
pnpm test:watch     # vitest in watch mode
pnpm typecheck      # tsc --noEmit
```

Tests cover:
- The message-protocol type narrowing (`fec-worker-types.test.ts`)
- The main-thread client's transfer-list construction, callback dispatch, and dispose idempotence (`fec-worker-client.test.ts`) — the Worker is injected via constructor as a stub so the tests don't need a real WASM build

## License

Apache-2.0 — see `LICENSE`.
