fix(320): break troubleshoot segment-wait loop on ffmpeg failure #423
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user