Files
ersatztv/ErsatzTV.Infrastructure.Tests/Locking/EntityLockerTests.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

271 lines
9.4 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Locking;
using MediatR;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Locking;
// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
[TestFixture]
public class EntityLockerTests
{
private const int ParallelCallers = 64;
private static EntityLocker CreateLocker() =>
new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);
// Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
// Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
// (tested separately) and so is not in this source.
private static IEnumerable<LockKind> ExclusiveWinnerKinds()
{
foreach (LockKind kind in EventCountingKinds())
{
yield return kind;
}
yield return new LockKind
{
Name = "RemoteMediaSource",
Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
};
}
// The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
private static IEnumerable<LockKind> EventCountingKinds()
{
yield return new LockKind
{
Name = "Plex",
Lock = l => l.LockPlex(),
Unlock = l => l.UnlockPlex(),
IsLocked = l => l.IsPlexLocked(),
Subscribe = (l, h) => l.OnPlexChanged += h
};
yield return new LockKind
{
Name = "Trakt",
Lock = l => l.LockTrakt(),
Unlock = l => l.UnlockTrakt(),
IsLocked = l => l.IsTraktLocked(),
Subscribe = (l, h) => l.OnTraktChanged += h
};
yield return new LockKind
{
Name = "EmbyCollections",
Lock = l => l.LockEmbyCollections(),
Unlock = l => l.UnlockEmbyCollections(),
IsLocked = l => l.AreEmbyCollectionsLocked(),
Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
};
yield return new LockKind
{
Name = "JellyfinCollections",
Lock = l => l.LockJellyfinCollections(),
Unlock = l => l.UnlockJellyfinCollections(),
IsLocked = l => l.AreJellyfinCollectionsLocked(),
Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
};
yield return new LockKind
{
Name = "PlexCollections",
Lock = l => l.LockPlexCollections(),
Unlock = l => l.UnlockPlexCollections(),
IsLocked = l => l.ArePlexCollectionsLocked(),
Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
};
yield return new LockKind
{
Name = "TroubleshootingPlayback",
Lock = l => l.LockTroubleshootingPlayback(),
Unlock = l => l.UnlockTroubleshootingPlayback(),
IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
};
yield return new LockKind
{
Name = "Library",
Lock = l => l.LockLibrary(1),
Unlock = l => l.UnlockLibrary(1),
IsLocked = l => l.IsLibraryLocked(1),
Subscribe = (l, h) => l.OnLibraryChanged += h
};
}
// Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
// collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
// deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
// thread races one acquire, exactly one must win, then the winner releases to reset. Any round
// with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
// against a non-atomic flag some round double-acquires.
[Test]
[TestCaseSource(nameof(ExclusiveWinnerKinds))]
public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
{
const int threads = 8;
const int rounds = 20_000;
EntityLocker locker = CreateLocker();
var winners = 0;
var badRounds = 0;
using var startRound = new Barrier(threads);
using var endRound = new Barrier(
threads,
_ =>
{
if (Volatile.Read(ref winners) != 1)
{
Interlocked.Increment(ref badRounds);
}
// reset for the next round: release the (single) held lock and clear the counter
kind.Unlock(locker);
Volatile.Write(ref winners, 0);
});
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
startRound.SignalAndWait();
if (kind.Lock(locker))
{
Interlocked.Increment(ref winners);
}
endRound.SignalAndWait();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
badRounds.ShouldBe(0);
kind.IsLocked(locker).ShouldBeFalse();
}
[Test]
public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
{
var mediator = Substitute.For<IMediator>();
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
const int playoutId = 7;
using var barrier = new Barrier(ParallelCallers);
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
.Select(_ => Task.Run(async () =>
{
barrier.SignalAndWait();
return await locker.LockPlayout(playoutId);
}))
.ToArray();
bool[] results = await Task.WhenAll(tasks);
results.Count(r => r).ShouldBe(1);
locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
await mediator.Received(1).Publish(
Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
Arg.Any<CancellationToken>());
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Lock(locker).ShouldBeTrue();
kind.Lock(locker).ShouldBeFalse();
kind.IsLocked(locker).ShouldBeTrue();
eventCount.ShouldBe(1);
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Unlock(locker).ShouldBeFalse();
eventCount.ShouldBe(0);
kind.IsLocked(locker).ShouldBeFalse();
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Lock(locker).ShouldBeTrue();
kind.Unlock(locker).ShouldBeTrue();
kind.Unlock(locker).ShouldBeFalse();
kind.IsLocked(locker).ShouldBeFalse();
eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
{
EntityLocker locker = CreateLocker();
kind.Lock(locker).ShouldBeTrue();
var unlockEvents = 0;
kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
using var barrier = new Barrier(ParallelCallers);
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
.Select(_ => Task.Run(() =>
{
barrier.SignalAndWait();
return kind.Unlock(locker);
}))
.ToArray();
bool[] results = await Task.WhenAll(tasks);
results.Count(r => r).ShouldBe(1);
kind.IsLocked(locker).ShouldBeFalse();
unlockEvents.ShouldBe(1);
}
public sealed class LockKind
{
public required string Name { get; init; }
public required Func<EntityLocker, bool> Lock { get; init; }
public required Func<EntityLocker, bool> Unlock { get; init; }
public required Func<EntityLocker, bool> IsLocked { get; init; }
// null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
public Action<EntityLocker, EventHandler>? Subscribe { get; init; }
public override string ToString() => Name;
}
}