fix(locking): release troubleshooting + playout locks on all terminal paths (#233, #234)
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
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>
This commit is contained in:
@@ -62,9 +62,20 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
// respect the EntityLocker ownership contract: LockPlayout returns true only for the caller
|
||||
// that performed the 0->1 transition. If another operation already holds this playout's lock
|
||||
// (a concurrent build, or a subtitle extraction), skip rather than build unlocked and then
|
||||
// cross-release the other owner's lock in the finally.
|
||||
if (!await _entityLocker.LockPlayout(request.PlayoutId))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} is already locked; skipping this build request",
|
||||
request.PlayoutId);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _entityLocker.LockPlayout(request.PlayoutId);
|
||||
if (request.Mode is not PlayoutBuildMode.Reset)
|
||||
{
|
||||
// this needs to happen before we load the playout in this handler because it modifies items, etc
|
||||
|
||||
@@ -59,6 +59,9 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// track exactly the playouts this handler locked so the finally releases only those,
|
||||
// on every terminal path (cancellation early-return, swallowed cancellation, any exception).
|
||||
var lockedPlayoutIds = new List<int>();
|
||||
try
|
||||
{
|
||||
bool useEmbeddedSubtitles = await _configElementRepository
|
||||
@@ -124,7 +127,10 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
|
||||
foreach (int playoutId in playoutIdsToCheck)
|
||||
{
|
||||
await _entityLocker.LockPlayout(playoutId);
|
||||
if (await _entityLocker.LockPlayout(playoutId))
|
||||
{
|
||||
lockedPlayoutIds.Add(playoutId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
|
||||
@@ -179,16 +185,18 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
}
|
||||
|
||||
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
|
||||
|
||||
foreach (int playoutId in playoutIdsToCheck)
|
||||
{
|
||||
await _entityLocker.UnlockPlayout(playoutId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (int playoutId in lockedPlayoutIds)
|
||||
{
|
||||
await _entityLocker.UnlockPlayout(playoutId);
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
+7
-4
@@ -64,12 +64,13 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
return BaseError.New("Channel start is required");
|
||||
}
|
||||
|
||||
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
||||
// acquire atomically: LockTroubleshootingPlayback returns false if another session
|
||||
// already holds it (no check-then-set race, no double-owner)
|
||||
if (!entityLocker.LockTroubleshootingPlayback())
|
||||
{
|
||||
return BaseError.New("Troubleshooting playback is locked");
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -163,12 +164,11 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
FFmpegProfile ffmpegProfile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
||||
if (!entityLocker.LockTroubleshootingPlayback())
|
||||
{
|
||||
return BaseError.New("Troubleshooting playback is locked");
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -181,6 +181,9 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
string mediaPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken);
|
||||
if (string.IsNullOrEmpty(mediaPath))
|
||||
{
|
||||
// this Left return bypasses the Handle-level catch; release the lock we just acquired
|
||||
// so troubleshooting isn't wedged "running" forever for a file that's gone from disk
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id);
|
||||
return BaseError.New("Media item does not exist on disk");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Application.Troubleshooting;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Troubleshooting;
|
||||
|
||||
[TestFixture]
|
||||
public class PrepareTroubleshootingPlaybackHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private readonly List<string> _tempFiles = [];
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
foreach (string tempFile in _tempFiles)
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetProcess_Should_Release_Lock_When_Media_Not_On_Disk()
|
||||
{
|
||||
await SeedFFmpegConfig();
|
||||
int profileId = await SeedFFmpegProfile();
|
||||
// media item whose file is gone from disk -> GetMediaItemPath returns null
|
||||
int movieId = await SeedMovieWithMissingFile();
|
||||
|
||||
// this caller wins the troubleshooting lock
|
||||
_entityLocker.LockTroubleshootingPlayback().Returns(true);
|
||||
|
||||
PrepareTroubleshootingPlaybackHandler handler = CreateHandler();
|
||||
|
||||
var request = new PrepareTroubleshootingPlayback(
|
||||
Guid.NewGuid(),
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
movieId,
|
||||
ChannelId: 0,
|
||||
profileId,
|
||||
StreamSelector: string.Empty,
|
||||
WatermarkIds: [],
|
||||
GraphicsElementIds: [],
|
||||
SubtitleId: null,
|
||||
SeekSeconds: Option<int>.None,
|
||||
Start: Option<DateTimeOffset>.None);
|
||||
|
||||
Either<BaseError, PlayoutItemResult> result = await handler.Handle(request, CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
// the lock acquired at the top of GetProcess must be released on this early Left return,
|
||||
// otherwise troubleshooting is wedged "running" forever (reviewer#20 F3)
|
||||
_entityLocker.Received(1).UnlockTroubleshootingPlayback();
|
||||
}
|
||||
|
||||
private PrepareTroubleshootingPlaybackHandler CreateHandler()
|
||||
{
|
||||
var fileSystem = Substitute.For<IFileSystem>();
|
||||
fileSystem.File.Exists(Arg.Any<string>()).Returns(false);
|
||||
|
||||
return new PrepareTroubleshootingPlaybackHandler(
|
||||
_db.Factory,
|
||||
Substitute.For<IPlexPathReplacementService>(),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IEmbyPathReplacementService>(),
|
||||
Substitute.For<IFFmpegProcessService>(),
|
||||
fileSystem,
|
||||
Substitute.For<ILocalFileSystem>(),
|
||||
Substitute.For<ISongVideoGenerator>(),
|
||||
Substitute.For<IWatermarkSelector>(),
|
||||
_entityLocker,
|
||||
new TroubleshootingPlaybackStatusStore(),
|
||||
Substitute.For<IMediator>(),
|
||||
new LoggingLevelSwitches(),
|
||||
NullLogger<PrepareTroubleshootingPlaybackHandler>.Instance);
|
||||
}
|
||||
|
||||
private async Task SeedFFmpegConfig()
|
||||
{
|
||||
string ffmpeg = Path.GetTempFileName();
|
||||
string ffprobe = Path.GetTempFileName();
|
||||
_tempFiles.Add(ffmpeg);
|
||||
_tempFiles.Add(ffprobe);
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFmpegPath.Key, Value = ffmpeg });
|
||||
context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFprobePath.Key, Value = ffprobe });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<int> SeedFFmpegProfile()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var profile = new FFmpegProfile
|
||||
{
|
||||
Name = "Test",
|
||||
Resolution = new Resolution { Name = "1080p", Width = 1920, Height = 1080 }
|
||||
};
|
||||
context.FFmpegProfiles.Add(profile);
|
||||
await context.SaveChangesAsync();
|
||||
return profile.Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedMovieWithMissingFile()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions =
|
||||
[
|
||||
new MediaVersion
|
||||
{
|
||||
Name = "Main",
|
||||
Duration = TimeSpan.FromMinutes(42),
|
||||
MediaFiles = [new MediaFile { Path = "/gone/movie.mkv", PathHash = "gone" }],
|
||||
Streams = []
|
||||
}
|
||||
],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata { Title = "Gone", SortTitle = "Gone", Subtitles = [] }
|
||||
]
|
||||
};
|
||||
context.Movies.Add(movie);
|
||||
await context.SaveChangesAsync();
|
||||
return movie.Id;
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,8 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/troubleshoot/playback.m3u8", "get", "409")]
|
||||
[TestCase("/api/troubleshoot/playback.m3u8", "head", "409")]
|
||||
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "409")]
|
||||
|
||||
@@ -248,6 +248,32 @@ public class TroubleshootControllerTests
|
||||
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TroubleshootPlayback_Should_Return_409_When_Already_Locked()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.TroubleshootPlayback(
|
||||
mediaItem: 1,
|
||||
channel: 0,
|
||||
ffmpegProfile: 1,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
watermark: [],
|
||||
graphicsElement: [],
|
||||
streamSelector: string.Empty,
|
||||
subtitleId: null,
|
||||
seekSeconds: 0,
|
||||
start: null,
|
||||
CancellationToken.None);
|
||||
|
||||
ConflictObjectResult conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
|
||||
// pre-check short-circuits before dispatching any work
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
||||
{
|
||||
|
||||
@@ -114,6 +114,7 @@ public class TroubleshootController(
|
||||
[Tags("Troubleshooting")]
|
||||
[EndpointSummary("Start a troubleshooting playback session")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> TroubleshootPlayback(
|
||||
[FromQuery]
|
||||
int mediaItem,
|
||||
@@ -140,8 +141,22 @@ public class TroubleshootController(
|
||||
var sessionId = Guid.NewGuid();
|
||||
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, sessionId);
|
||||
|
||||
// acquiredLock: the handler took the troubleshooting lock on a successful Prepare.
|
||||
// startEnqueued: we handed off to StartTroubleshootingPlayback, which becomes the lock's releaser.
|
||||
var acquiredLock = false;
|
||||
var startEnqueued = false;
|
||||
|
||||
try
|
||||
{
|
||||
// fast lock-conflict signal so the client gets a 409 (not a 404) while another
|
||||
// troubleshooting session is active; the atomic acquire in the handler is the real guard
|
||||
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Troubleshooting playback in progress",
|
||||
"Another troubleshooting playback session is currently running. Try again once it completes.");
|
||||
}
|
||||
|
||||
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
|
||||
|
||||
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
|
||||
@@ -164,6 +179,9 @@ public class TroubleshootController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Prepare returned a process, so the handler holds the troubleshooting lock now
|
||||
acquiredLock = true;
|
||||
|
||||
foreach (PlayoutItemResult playoutItemResult in result.RightToSeq())
|
||||
{
|
||||
Either<BaseError, MediaItemInfo> maybeMediaInfo =
|
||||
@@ -192,6 +210,9 @@ public class TroubleshootController(
|
||||
troubleshootingInfo),
|
||||
cancellationToken);
|
||||
|
||||
// StartTroubleshootingPlayback is now responsible for releasing the lock in its finally
|
||||
startEnqueued = true;
|
||||
|
||||
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
|
||||
while (!fileSystem.File.Exists(playlistFile))
|
||||
{
|
||||
@@ -242,6 +263,15 @@ public class TroubleshootController(
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
finally
|
||||
{
|
||||
// the handler acquired the lock but we never handed off to the worker
|
||||
// (cancellation/exception in the window before enqueue) -> release it so it doesn't leak
|
||||
if (acquiredLock && !startEnqueued)
|
||||
{
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
@@ -13483,8 +13483,25 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -13580,8 +13597,25 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user