--- 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` 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).