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>
156 lines
5.6 KiB
C#
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);
|
|
}
|
|
}
|