fix(320): break troubleshoot segment-wait loop on ffmpeg failure #423

Merged
timothy merged 3 commits from fix/320-troubleshoot-segment-wait-isfailed into main 2026-07-17 20:43:08 +02:00
2 changed files with 99 additions and 0 deletions
@@ -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<ErsatzTV.Core.Interfaces.Streaming.GraphicsEngineContext>.None,
Option<int>.Some(1));
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, PlayoutItemResult>.Right(playoutItemResult));
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
_mediator.Send(Arg.Any<GetTroubleshootingInfo>(), Arg.Any<CancellationToken>())
.Returns(new TroubleshootingInfo(
"1.2.3",
new Dictionary<string, string>(),
[],
[],
[],
new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(),
[],
[],
[],
false,
false,
null,
null,
null,
null));
var fileSystem = Substitute.For<IFileSystem>();
// playlist "exists" so the first wait loop exits immediately and we reach the segment poll
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(2));
var notifier = Substitute.For<ITroubleshootingNotifier>();
notifier.IsFailed(Arg.Any<Guid>()).Returns(true);
var controller = new TroubleshootController(
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().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<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().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<Guid>());
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
{
@@ -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(