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

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:
2026-07-11 13:44:47 +02:00
co-authored by Claude Opus 4.8
parent eba22ce679
commit 084d4c4ca1
10 changed files with 529 additions and 15 deletions
@@ -62,9 +62,20 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken) 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 try
{ {
await _entityLocker.LockPlayout(request.PlayoutId);
if (request.Mode is not PlayoutBuildMode.Reset) if (request.Mode is not PlayoutBuildMode.Reset)
{ {
// this needs to happen before we load the playout in this handler because it modifies items, etc // 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, string ffmpegPath,
CancellationToken cancellationToken) 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 try
{ {
bool useEmbeddedSubtitles = await _configElementRepository bool useEmbeddedSubtitles = await _configElementRepository
@@ -124,7 +127,10 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
foreach (int playoutId in playoutIdsToCheck) 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); _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); _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) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{ {
// do nothing // do nothing
} }
finally
{
foreach (int playoutId in lockedPlayoutIds)
{
await _entityLocker.UnlockPlayout(playoutId);
}
}
return Option<BaseError>.None; return Option<BaseError>.None;
} }
@@ -64,12 +64,13 @@ public class PrepareTroubleshootingPlaybackHandler(
return BaseError.New("Channel start is required"); 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"); return BaseError.New("Troubleshooting playback is locked");
} }
entityLocker.LockTroubleshootingPlayback();
statusStore.Reset(); statusStore.Reset();
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder); localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
@@ -163,12 +164,11 @@ public class PrepareTroubleshootingPlaybackHandler(
FFmpegProfile ffmpegProfile, FFmpegProfile ffmpegProfile,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (entityLocker.IsTroubleshootingPlaybackLocked()) if (!entityLocker.LockTroubleshootingPlayback())
{ {
return BaseError.New("Troubleshooting playback is locked"); return BaseError.New("Troubleshooting playback is locked");
} }
entityLocker.LockTroubleshootingPlayback();
statusStore.Reset(); statusStore.Reset();
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder); localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
@@ -181,6 +181,9 @@ public class PrepareTroubleshootingPlaybackHandler(
string mediaPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken); string mediaPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken);
if (string.IsNullOrEmpty(mediaPath)) 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); logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id);
return BaseError.New("Media item does not exist on disk"); 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);
}
}
@@ -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", "404")]
[TestCase("/api/trakt/lists/{id}", "put", "422")] [TestCase("/api/trakt/lists/{id}", "put", "422")]
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")] [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-show", "post", "404")]
[TestCase("/api/libraries/{id}/scan", "post", "404")] [TestCase("/api/libraries/{id}/scan", "post", "404")]
[TestCase("/api/libraries/{id}/scan", "post", "409")] [TestCase("/api/libraries/{id}/scan", "post", "409")]
@@ -248,6 +248,32 @@ public class TroubleshootControllerTests
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus"); 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] [Test]
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked() public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
{ {
@@ -114,6 +114,7 @@ public class TroubleshootController(
[Tags("Troubleshooting")] [Tags("Troubleshooting")]
[EndpointSummary("Start a troubleshooting playback session")] [EndpointSummary("Start a troubleshooting playback session")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> TroubleshootPlayback( public async Task<IActionResult> TroubleshootPlayback(
[FromQuery] [FromQuery]
int mediaItem, int mediaItem,
@@ -140,8 +141,22 @@ public class TroubleshootController(
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, sessionId); 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 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; Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
Either<BaseError, PlayoutItemResult> result = await mediator.Send( Either<BaseError, PlayoutItemResult> result = await mediator.Send(
@@ -164,6 +179,9 @@ public class TroubleshootController(
return NotFound(); return NotFound();
} }
// Prepare returned a process, so the handler holds the troubleshooting lock now
acquiredLock = true;
foreach (PlayoutItemResult playoutItemResult in result.RightToSeq()) foreach (PlayoutItemResult playoutItemResult in result.RightToSeq())
{ {
Either<BaseError, MediaItemInfo> maybeMediaInfo = Either<BaseError, MediaItemInfo> maybeMediaInfo =
@@ -192,6 +210,9 @@ public class TroubleshootController(
troubleshootingInfo), troubleshootingInfo),
cancellationToken); cancellationToken);
// StartTroubleshootingPlayback is now responsible for releasing the lock in its finally
startEnqueued = true;
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8"); string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
while (!fileSystem.File.Exists(playlistFile)) while (!fileSystem.File.Exists(playlistFile))
{ {
@@ -242,6 +263,15 @@ public class TroubleshootController(
{ {
// do nothing // 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(); return NotFound();
} }
+38 -4
View File
@@ -13483,8 +13483,25 @@
} }
], ],
"responses": { "responses": {
"200": { "409": {
"description": "OK" "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": { "responses": {
"200": { "409": {
"description": "OK" "description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
} }
} }
} }