From cc287ffc6e581d5e7b66e472bc32bbf23b7dd762 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:40:26 -0600 Subject: [PATCH] fix hls direct streams remaining open (#2660) --- CHANGELOG.md | 1 + .../Controllers/StreamingControllerBase.cs | 87 ++++++++++--------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d05ad1b..f68ad98e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Playout builds now use JsonSchema.Net library which has no validation limit - Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand - Fix editing scripted and sequential playouts when using MySql +- Fix HLS Direct streams remaining open after client disconnect ### Changed - Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them diff --git a/ErsatzTV/Controllers/StreamingControllerBase.cs b/ErsatzTV/Controllers/StreamingControllerBase.cs index dc8a4c04c..7d08044f2 100644 --- a/ErsatzTV/Controllers/StreamingControllerBase.cs +++ b/ErsatzTV/Controllers/StreamingControllerBase.cs @@ -30,57 +30,62 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL foreach (PlayoutItemProcessModel processModel in result.RightToSeq()) { - // for process counter - var ffmpegProcess = new FFmpegProcess(); + return StartPlayout(processModel); + } - Command process = processModel.Process; + // this will never happen + return new NotFoundResult(); + } - logger.LogDebug("ffmpeg arguments {FFmpegArguments}", process.Arguments); + private FileStreamResult StartPlayout(PlayoutItemProcessModel processModel) + { + // for process counter + var ffmpegProcess = new FFmpegProcess(); + Command process = processModel.Process; - var cts = new CancellationTokenSource(); - HttpContext.Response.OnCompleted(async () => - { - ffmpegProcess.Dispose(); - await cts.CancelAsync(); - cts.Dispose(); - }); + logger.LogDebug("ffmpeg arguments {FFmpegArguments}", process.Arguments); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cts.Token, - HttpContext.RequestAborted); + var cts = new CancellationTokenSource(); - var pipe = new Pipe(); - var stdErrBuffer = new StringBuilder(); + // do not use 'using' here; the token needs to live longer than this method scope + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, + HttpContext.RequestAborted); - Command processWithPipe = process; - foreach (GraphicsEngineContext graphicsEngineContext in processModel.GraphicsEngineContext) - { - var gePipe = new Pipe(); - processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(gePipe.Reader.AsStream())); + var pipe = new Pipe(); + var stdErrBuffer = new StringBuilder(); - // fire and forget graphics engine task - _ = graphicsEngine.Run( - graphicsEngineContext, - gePipe.Writer, - linkedCts.Token); - } + Command processWithPipe = process; + foreach (GraphicsEngineContext graphicsEngineContext in processModel.GraphicsEngineContext) + { + var gePipe = new Pipe(); + processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(gePipe.Reader.AsStream())); - CommandTask task = processWithPipe - .WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream())) - .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) - .WithValidation(CommandResultValidation.None) - .ExecuteAsync(linkedCts.Token); + // fire and forget graphics engine task + _ = graphicsEngine.Run( + graphicsEngineContext, + gePipe.Writer, + linkedCts.Token); + } - // ensure pipe writer is completed when ffmpeg exits - _ = task.Task.ContinueWith( - (_, state) => ((PipeWriter)state!).Complete(), - pipe.Writer, - TaskScheduler.Default); + CommandTask task = processWithPipe + .WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream())) + .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(linkedCts.Token); - return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t"); - } + // ensure cleanup happens when ffmpeg exits (either naturally or via cancellation) + _ = task.Task.ContinueWith( + (t, _) => + { + pipe.Writer.Complete(t.Exception); + ffmpegProcess.Dispose(); + linkedCts.Dispose(); + cts.Dispose(); + }, + null, + TaskScheduler.Default); - // this will never happen - return new NotFoundResult(); + return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t"); } }