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
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>
91 lines
3.3 KiB
C#
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);
|
|
}
|
|
}
|