Files
ersatztv/ErsatzTV.Tests/Application/Playouts/BuildPlayoutHandlerTests.cs
T
timothyandClaude Opus 4.8 084d4c4ca1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(locking): release troubleshooting + playout locks on all terminal paths (#233, #234)
Consume the EntityLocker ownership contract (#231/#241: Lock* returns true iff
this caller won the slot) at three lock-leak sites surfaced by adversarial-reviewer#20.

#233 (F3) — troubleshooting playback:
- PrepareTroubleshootingPlaybackHandler: both lock sites now acquire via
  `if (!LockTroubleshootingPlayback())` (kills the check-then-set TOCTOU) and the
  empty-media-path Left return releases the lock it acquired — previously it leaked,
  wedging the status endpoint at "running" forever for a file gone from disk.
- TroubleshootController.TroubleshootPlayback: lock conflict is now 409 ProblemDetails
  (was a bare 404, indistinguishable from a bad id); the Prepare-success -> enqueue
  window releases the lock if we never hand off to StartTroubleshootingPlayback.

#234 (F4 + F5.2) — playout builds:
- ExtractEmbeddedSubtitlesHandler: try/finally releases exactly the playouts it
  locked, on every terminal path (cancellation early-return, swallowed cancellation,
  any exception) — no more permanent leaks after cancelled mid-extraction, and no
  cross-release of playouts held by someone else.
- BuildPlayoutHandler: skips (logs, returns Right) when LockPlayout returns false
  instead of building unlocked and cross-releasing the other owner's lock in finally.

Tests: handler-level release-discipline tests (Prepare empty-path, Extract
cancellation + no-cross-release, BuildPlayout skip + finally-release) via the
InMemoryTvContext harness, a TroubleshootController 409 test, and OpenApi contract
cases for the m3u8 endpoint's 409. OpenAPI regenerated. All non-vacuous (F3 verified
against a negative control).

Fixes #233, #234

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

91 lines
3.3 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class BuildPlayoutHandlerTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
private IEntityLocker _entityLocker = null!;
private IPlayoutTimeShifter _timeShifter = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
_entityLocker = Substitute.For<IEntityLocker>();
_timeShifter = Substitute.For<IPlayoutTimeShifter>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private BuildPlayoutHandler CreateHandler() =>
new(
_db.Factory,
Substitute.For<IPlayoutBuilder>(),
Substitute.For<IBlockPlayoutBuilder>(),
Substitute.For<IBlockPlayoutFillerBuilder>(),
Substitute.For<ISequentialPlayoutBuilder>(),
Substitute.For<IScriptedPlayoutBuilder>(),
Substitute.For<IExternalJsonPlayoutBuilder>(),
Substitute.For<IFFmpegSegmenterService>(),
_entityLocker,
_timeShifter,
_worker,
NullLogger<BuildPlayoutHandler>.Instance);
[Test]
public async Task Build_Should_Skip_And_Not_Cross_Release_When_Playout_Already_Locked()
{
// another operation (a concurrent build or the subtitle extractor) already holds the lock
_entityLocker.LockPlayout(5).Returns(false);
BuildPlayoutHandler handler = CreateHandler();
Either<BaseError, Unit> result =
await handler.Handle(new BuildPlayout(5, PlayoutBuildMode.Continue), CancellationToken.None);
// skipped cleanly (no error surfaced to the caller)
result.IsRight.ShouldBeTrue();
// did not proceed to build the playout...
await _timeShifter.DidNotReceive()
.TimeShift(Arg.Any<int>(), Arg.Any<DateTimeOffset>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
// ...and crucially did not release the other owner's lock (no cross-release)
await _entityLocker.DidNotReceive().UnlockPlayout(5);
}
[Test]
public async Task Build_Should_Release_Lock_When_Owned_Even_If_Validation_Fails()
{
// this caller wins the lock
_entityLocker.LockPlayout(999).Returns(true);
BuildPlayoutHandler handler = CreateHandler();
// playout 999 does not exist -> validation fails -> Left, but the finally must still release
Either<BaseError, Unit> result =
await handler.Handle(new BuildPlayout(999, PlayoutBuildMode.Reset), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await _entityLocker.Received(1).UnlockPlayout(999);
}
}