---
key: ffmpeg.work-ahead-slot-atomic
title: 2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: '`workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`''s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another.'
signals: 'work-ahead slot, workAheadSegmenterLimit, check-then-act across an await, unthrottled tune-in, TOCTOU · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Run`/`Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #536, #350, #529, #231, #250'
mechanics: '`WorkAheadSlots.TryAcquire(limit)` CAS loop; `Transcode(bool ownsWorkAheadSlot, …)`'
---

**The defect was structural, not a missing `Interlocked`.** The write side already used
`Interlocked.Increment`, which is why the code read as thread-safe. The read side was a separate,
earlier `Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(...)` in `Run`, and the
increment happened later inside `Transcode` — with at least one `await` (a DB-backed config read) in
between. `Interlocked` on one half of a check-then-act buys nothing. Three simultaneous tune-ins on
prod with `workAheadSegmenterLimit: 1` all observed `0 < 1` and all ran with no `-readrate`.

**Why the caller claims and the callee releases.** Moving the whole acquire/release pair inside
`Transcode` would be more symmetric, but `Run` needs the outcome *before* the call: it sets
`_state = SeekAndWorkAhead | SeekAndRealtime`, and `Transcode` reads that state on entry
(`wasSeekAndWorkAhead`) to decide whether the item starts at `DateTimeOffset.Now` or at
`_transcodedUntil`. Folding acquisition inward would have silently changed that branch. Instead the
parameter was inverted — `Transcode(bool ownsWorkAheadSlot, …)` with `realtime = !ownsWorkAheadSlot`
derived on the first line — so the contract is stated in the signature rather than implied by a
double negative, and there is exactly one release site guarded by the same flag.

**Compare-exchange rather than increment-then-back-off.** The issue proposed
`Interlocked.Increment` followed by a decrement when the post-increment value exceeds the limit.
That is correct on holder count, but the counter transiently overshoots, so a concurrent reader of
`Count` can observe a value above the limit. The CAS loop never publishes a state that violates the
invariant, which matters because the QSV hardware-frame pool sizing (`ffmpeg.qsv-extra-hw-frames-floor`)
is derived from that bound.

**Testing shape is inherited from #231/#250.** A single `Barrier(N)` + `Task.WhenAll` round does not
reliably collide on this hardware; the tests hammer 8 threads × 20 000 rounds with a per-round
barrier that validates the winner count and resets the pool. The negative control is documented in
the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under
warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
