---
key: locking.entitylocker-atomic-flags
title: '2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: '`EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing.'
signals: 'six `bool`→`int` flags, `TraktController.EnqueueWithTraktLock`, `unlock: last` tail pattern · paths: `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`, `IEntityLocker` · issues: #231, #232, #234, adversarial-reviewer#20/F5'
mechanics: '`ErsatzTV.Infrastructure/Locking/EntityLocker.cs`'
---

`EntityLocker` (process-wide singleton, `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`) is the
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
found three defects: six plain-`bool` flags with a non-atomic check-then-set (two threads could both
acquire and both return `true`), tokenless `Unlock*` letting any caller release another owner's lock
(e.g. `BuildPlayoutHandler` ignored `LockPlayout`'s return then unconditionally unlocked in
`finally`), and one batch taking a single `LockLibrary` released after the *first* of two enqueued
scan units (so the second ran unlocked).

**Model chosen: tokenless atomic flag + documented single-owner release discipline.** The six bools
became `int`s guarded by `Interlocked.CompareExchange` (0/1), so `Lock*`'s `bool` return is now a
reliable "this call won the transition" signal and the change event fires exactly once per
transition. The contract (XML-doc'd on `IEntityLocker`): a `true` from `Lock*` confers ownership of
exactly one release — performed either in the acquiring scope (`finally`, gated on the captured
bool) or by the single designated releaser the acquirer hands off to (the consumer of the message
enqueued while holding the lock, with a compensating unlock if the enqueue throws — the established
`TraktController.EnqueueWithTraktLock` / scheduler `unlock: last` tail pattern). Callers must never
release a lock they did not acquire. `Unlock*` on an already-unlocked slot returns `false`, fires no
event, and logs a Warning — the loud tripwire for double-release bugs; it does not throw or
`Debug.Assert`, because an advisory flag must stay safe to release in `finally` paths. The three
`ConcurrentDictionary`-backed kinds (Library/Playout/RemoteMediaSource) were already atomic
(`TryAdd`/`TryRemove`) and kept their semantics (the redundant `ContainsKey` pre-checks were dropped
as tidy-up); the interface signature is unchanged across its ~40 call sites.

**Rejected:** owner tokens/leases — the acquirer and releaser for Library/Trakt/Plex/Collections
locks are different code correlated only by entity id across in-memory `Channel<T>` queues, so a
token would have to travel inside ~8 background-request message types for a defect that discipline
plus the now-trustworthy atomic return value already prevents. Counted/reentrant locks — wrong
semantics: these are exclusive in-progress flags; two holders is the failure mode, not a feature.
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
