Files
ersatztv/ErsatzTV.Tests/Application/Subtitles/ExtractEmbeddedSubtitlesHandlerTests.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

156 lines
5.6 KiB
C#

using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Subtitles;
[TestFixture]
public class ExtractEmbeddedSubtitlesHandlerTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
private IEntityLocker _entityLocker = null!;
private IConfigElementRepository _configRepo = null!;
private readonly List<string> _tempFiles = [];
private int _channelNumber = 100;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
_entityLocker = Substitute.For<IEntityLocker>();
_configRepo = Substitute.For<IConfigElementRepository>();
// embedded-subtitle extraction enabled (both feature flags on)
_configRepo.GetValue<bool>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Option<bool>.Some(true)));
}
[TearDown]
public async Task TearDown()
{
await _db.DisposeAsync();
foreach (string tempFile in _tempFiles)
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
private ExtractEmbeddedSubtitlesHandler CreateHandler() =>
new(
_db.Factory,
Substitute.For<IFileSystem>(),
_entityLocker,
_configRepo,
_worker,
NullLogger<ExtractEmbeddedSubtitlesHandler>.Instance);
// FFmpegPathMustExist reads ConfigElements + static File.Exists, so point ffmpeg at a real temp file
private async Task SeedFFmpegPath()
{
string tempFile = Path.GetTempFileName();
_tempFiles.Add(tempFile);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFmpegPath.Key, Value = tempFile });
await context.SaveChangesAsync();
}
private async Task<int> SeedSubtitledPlayout()
{
int number = ++_channelNumber;
await using TvContext context = _db.CreateContext();
var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
{
Number = number.ToString(),
SortNumber = number,
Name = "Subs",
Group = string.Empty,
Categories = string.Empty,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
SubtitleMode = ChannelSubtitleMode.Any,
Playouts = [],
Artwork = [],
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
IsEnabled = true,
ShowInEpg = true
};
var playout = new Playout { Channel = channel };
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return playout.Id;
}
[Test]
public async Task Extract_Should_Release_All_Locks_On_Cancellation()
{
await SeedFFmpegPath();
int playoutId = await SeedSubtitledPlayout();
using var cts = new CancellationTokenSource();
// win the lock, but cancel as we do it so the next cancellable DB call throws mid-run —
// exercising the terminal path that used to skip the unlock loop
_entityLocker.LockPlayout(Arg.Any<int>()).Returns(_ =>
{
cts.Cancel();
return Task.FromResult(true);
});
ExtractEmbeddedSubtitlesHandler handler = CreateHandler();
try
{
await handler.Handle(new ExtractEmbeddedSubtitles(Option<int>.None), cts.Token);
}
catch (OperationCanceledException)
{
// expected: the post-extract ReleaseMemory write observes the cancelled token AFTER
// ExtractAll's finally has already released the locks (which is what we assert below)
}
await _entityLocker.Received(1).UnlockPlayout(playoutId);
}
[Test]
public async Task Extract_Should_Release_Only_Locks_It_Acquired()
{
await SeedFFmpegPath();
int ownedPlayoutId = await SeedSubtitledPlayout();
int otherOwnersPlayoutId = await SeedSubtitledPlayout();
// we win one lock; the other is already held by someone else (LockPlayout -> false)
_entityLocker.LockPlayout(ownedPlayoutId).Returns(true);
_entityLocker.LockPlayout(otherOwnersPlayoutId).Returns(false);
ExtractEmbeddedSubtitlesHandler handler = CreateHandler();
// no playout items seeded -> nothing to extract -> normal completion through the finally
await handler.Handle(new ExtractEmbeddedSubtitles(Option<int>.None), CancellationToken.None);
await _entityLocker.Received(1).UnlockPlayout(ownedPlayoutId);
// must NOT release the lock we never acquired (no cross-release)
await _entityLocker.DidNotReceive().UnlockPlayout(otherOwnersPlayoutId);
}
}