Files
LlamaCasty/ytLive.Tests/EncoderRestartTests.cs
T
gramps 24077e58cf fix(stepdown): restart the audio pipe around encoder changes
A quality step-down now closes the named audio pipe before stopping/flushing the
pump, then opens a fresh server before restarting the pump. This prevents ffmpeg
from waiting on the old audio input and avoids reconnecting to a broken pipe.

EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start
order. 616 tests green, clean build with 0 warnings.
2026-11-11 09:52:27 -08:00

119 lines
4.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 33 slice 3 — the step-down's one dangerous act: relaunching the encoder mid-broadcast.
///
/// <para>The restart is a full pump stop/start rather than an encoder swap, and this test is why: a swap
/// leaves an in-flight submit pointing at a disposed process, which faults the loop and ends the
/// broadcast. Here the pump must come back and produce a frame with a <b>new</b> encoder instance and
/// the tier the policy landed on.</para>
/// </summary>
public sealed class EncoderRestartTests
{
[Fact]
public async Task ChangeEncoder_RelauchesWithANewEncoderAtTheNewTier()
{
var tier = (Width: 1920, Height: 1080, Fps: 60, Bitrate: 8.0);
var started = new List<EncoderOptions>();
var frames = new List<CapturingEncoder>();
var order = new List<string>();
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
var black = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ => black,
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0,
SourceRectWidth = 1920, SourceRectHeight = 1080,
OutputWidth = 1920, OutputHeight = 1080,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = tier.Width, Height = tier.Height, Fps = tier.Fps,
BitrateKbps = (int)(tier.Bitrate * 1000),
},
encoderFactory: () =>
{
var encoder = new CapturingEncoder();
encoder.Started += options =>
{
started.Add(options);
order.Add($"encoder-start-{options.Fps}");
};
encoder.Stopped = () => order.Add("encoder-stop");
frames.Add(encoder);
return encoder;
},
log: _ => { },
pacingDelay: (_, _) => Task.Delay(5));
await pump.StartAsync();
Assert.True(await frames[0].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "first session never produced a frame");
// The policy lands on 1080p30 @ 6.4 Mbps; the VM has already written the override, so the
// options func now reports the new tier — which the restart must re-read.
tier = (1920, 1080, 30, 6.4);
Assert.True(await pump.ChangeEncoderAsync(
stopAudio: () => order.Add("audio-stop"),
startAudio: () => order.Add("audio-start")));
Assert.True(await frames[1].FirstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5)), "restarted session never produced a frame");
Assert.Equal(2, started.Count);
Assert.Equal(60, started[0].Fps);
Assert.Equal(30, started[1].Fps);
Assert.Equal(6400, started[1].BitrateKbps);
Assert.NotSame(frames[0], frames[1]);
Assert.Equal(new[] { "encoder-start-60", "audio-stop", "encoder-stop", "audio-start", "encoder-start-30" }, order);
await pump.StopAsync();
}
private sealed class CapturingEncoder : IFfmpegEncoder
{
public readonly TaskCompletionSource<bool> FirstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously);
public event Action<EncoderOptions>? Started;
public Action? Stopped { get; set; }
public int DroppedFrames => 0;
public event EventHandler<StreamHealth>? HealthUpdated { add { } remove { } }
public event EventHandler<string>? ProcessFailed { add { } remove { } }
public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
{
Started?.Invoke(options);
return Task.CompletedTask;
}
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
{
FirstFrame.TrySetResult(true);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken = default)
{
Stopped?.Invoke();
return Task.CompletedTask;
}
public void Dispose() { }
}
}