Files
ersatztv/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
T
timothyandClaude Opus 4.8 275908ec11 fix(locking): atomic EntityLocker flags + single-owner release contract (#231)
The six plain-bool lock flags (Plex, Trakt, Emby/Jellyfin/Plex collections,
troubleshooting playback) used a non-atomic check-then-set, so two concurrent
Lock* callers could both win. Convert them to int flags mutated only via
Interlocked.CompareExchange, so the caller that wins the 0->1 transition is the
sole owner and the only one that fires the change event. The three
ConcurrentDictionary-backed kinds (Library/Playout/RemoteMediaSource) were
already atomic; drop their redundant ContainsKey pre-checks.

Define the ownership contract (tokenless single-owner discipline, no interface
change) on IEntityLocker and in docs/decisions.md: a true from Lock* confers
ownership of exactly one release; Unlock* on an unlocked slot returns false,
fires no event, and logs a warning (the double-release / non-owner tripwire).

Adds EntityLockerTests (real locker, parallel-caller races) proving exactly one
winner per kind, one-releaser-per-slot, and event-fires-once-per-transition.

Ref #231. Scan-lifecycle call-site fixes that consume this contract land in the
same PR (#232); the BuildPlayout/subtitle finally-gating is #234.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:30:36 +02:00

55 lines
2.5 KiB
C#

namespace ErsatzTV.Core.Interfaces.Locking;
/// <summary>
/// Advisory "an operation on entity X is in progress" mutex layer, registered as a process-wide singleton.
/// Each lock kind is a single exclusive slot (not reentrant or counted): a second acquire while held returns false.
/// </summary>
/// <remarks>
/// Ownership contract (see docs/decisions.md 2026-07-11, issue #231): every <c>Lock*</c> call returns
/// <c>true</c> iff it transitioned the slot to locked. A <c>true</c> confers ownership of exactly one release —
/// performed either in the acquiring scope (release in a <c>finally</c> gated on the captured return value) or by
/// the single designated releaser it hands off to (the consumer of a message enqueued while holding the lock, with
/// a compensating unlock if the enqueue itself throws). Callers must never release a lock they did not acquire.
/// <c>Unlock*</c> on an already-unlocked slot is a no-op that returns <c>false</c>, raises no event, and logs a
/// warning — the tripwire for double-release / non-owner-release bugs; it deliberately does not throw, so the
/// primitive stays safe to call from <c>finally</c> paths.
/// </remarks>
public interface IEntityLocker
{
event EventHandler OnLibraryChanged;
event EventHandler OnPlexChanged;
event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged;
event EventHandler OnTroubleshootingPlaybackChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
bool LockPlex();
bool UnlockPlex();
bool IsPlexLocked();
bool IsRemoteMediaSourceLocked<TMediaSource>();
bool LockRemoteMediaSource<TMediaSource>();
bool UnlockRemoteMediaSource<TMediaSource>();
bool IsTraktLocked();
bool LockTrakt();
bool UnlockTrakt();
bool LockEmbyCollections();
bool UnlockEmbyCollections();
bool AreEmbyCollectionsLocked();
bool LockJellyfinCollections();
bool UnlockJellyfinCollections();
bool AreJellyfinCollectionsLocked();
bool LockPlexCollections();
bool UnlockPlexCollections();
bool ArePlexCollectionsLocked();
Task<bool> LockPlayout(int playoutId);
Task<bool> UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);
bool LockTroubleshootingPlayback();
bool UnlockTroubleshootingPlayback();
bool IsTroubleshootingPlaybackLocked();
}