diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs index e1314e7c6..0ceb3ce42 100644 --- a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -448,6 +448,94 @@ public class TroubleshootControllerTests } } + [Test] + public async Task TroubleshootPlayback_Should_Return_404_And_Not_Spin_When_Ffmpeg_Fails_Before_Segments() + { + // ersatztv#320: if ffmpeg dies after writing the playlist but before any segments appear, the + // segment-readiness poll must break on notifier.IsFailed instead of spinning until the client + // cancels (which would tie up the request thread + hold the troubleshooting lock). + // + // The segment scan uses the real Directory.GetFiles on the shared troubleshooting folder, so + // ensure the folder EXISTS — otherwise Directory.GetFiles throws DirectoryNotFoundException, + // which the pre-fix loop would surface as a fast 404 (a false pass that hides the regression). + // We deliberately do NOT delete stray *.ts here: this folder is machine-global and may hold a + // live troubleshooting session's segments (don't reap files this test didn't create). It isn't + // needed for determinism — NUnit runs serially and the sibling Should_Return_200 test cleans up + // its own seg-test-*.ts in a finally, so no test leaves >= initialSegmentCount(2) files behind; + // and the fixed code breaks on IsFailed before the scan runs regardless of folder contents. + Directory.CreateDirectory(FileSystemLayout.TranscodeTroubleshootingFolder); + + _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); + + var playoutItemResult = new PlayoutItemResult( + new CliWrap.Command("ffmpeg"), + Option.None, + Option.Some(1)); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Either.Right(playoutItemResult)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Either.Right(FakeMediaItemInfo())); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new TroubleshootingInfo( + "1.2.3", + new Dictionary(), + [], + [], + [], + new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(), + [], + [], + [], + false, + false, + null, + null, + null, + null)); + + var fileSystem = Substitute.For(); + // playlist "exists" so the first wait loop exits immediately and we reach the segment poll + fileSystem.File.Exists(Arg.Any()).Returns(true); + + var configElementRepository = Substitute.For(); + configElementRepository + .GetValue(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any()) + .Returns(Option.Some(2)); + + var notifier = Substitute.For(); + notifier.IsFailed(Arg.Any()).Returns(true); + + var controller = new TroubleshootController( + System.Threading.Channels.Channel.CreateUnbounded().Writer, + fileSystem, + configElementRepository, + notifier, + _entityLocker, + _statusStore, + _mediator) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { Request = { PathBase = "/etv" } } + } + }; + + // safety deadline so a regression (the loop spinning) fails the test in bounded time instead + // of hanging CI; the fixed code exits via IsFailed long before this fires + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + IActionResult result = await controller.TroubleshootPlayback(DefaultPlaybackRequest(), cts.Token); + + var notFound = result.ShouldBeOfType(); + notFound.Value.ShouldBeOfType().Status.ShouldBe(404); + + // the loop must have exited via IsFailed, NOT by hitting the cancellation deadline — this is + // what makes the test non-vacuous (it fails on the pre-fix spinning loop) + cts.IsCancellationRequested.ShouldBeFalse(); + notifier.Received().RemoveSession(Arg.Any()); + } + [Test] public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked() { diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 7fa49fb2c..cefeeee34 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -238,6 +238,17 @@ public class TroubleshootController( { await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + // if ffmpeg died before segments appeared, stop waiting instead of spinning + // until client-cancel; the !IsFailed guard below then falls through to the + // terminal NotFoundProblem, releasing the request thread + lock. (Client + // cancellation is already handled by the Task.Delay above throwing; checking + // it here too would let a cancel exit into the Ok gate and return a spurious + // 200 for a request with no segments.) + if (notifier.IsFailed(sessionId)) + { + break; + } + string[] segmentFiles = streamingMode switch { // StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(