fix(rec): bounded encoder queue + drop policy + burned frame counter — the "1...23...4...56..." smeared-ticker take
slice 9 made the DURATION right but content still hiccuped; aggregates (301/300, uniform file PTS) could not see it. Measured root cause: FfmpegEncoder.SubmitFrameAsync BLOCKED on WriteAsync(8.3MB)+FlushAsync when ffmpeg lagged the pipe, and the burst while-loop re-wrote that same stale composite per crossed slot — frozen runs. OBS shape (derivative, wrapped pre-1.0): the encoder queue in libobs/obs-encoder.c — encoder thread never couples back into the video thread; overflow = dropped data, never a frozen producer. https://github.com/obsproject/obs-studio/blob/master/libobs/obs-encoder.c - FfmpegEncoder: SubmitFrameAsync is now an enqueue (ArrayPool copy) into a bounded Channel (cap 120) drained by its own task; drop-newest + count when full; StopAsync flushes the queue then EOF (TryComplete). IFfmpegEncoder.DroppedFrames. - FramePump: ONE fresh composite per iteration (burst loop deleted); worst-submit stat, stall logger (>2x interval names the stage), dropped/stalls in stats. - Burned-in 6-digit dot-matrix frame counter (white box, bottom-right) on every composite — the clock-independent judge replacing the WSL ticker: +1/frame, jumps = counted drops. - ONE new test Backpressure_QueueOverflow_DropsFrames_AndNeverBlocks (slow-sink fake: submit never blocks, drops counted, stop flushes exactly submitted-minus-dropped). - Full suite 290 tests, 289 pass — sole failure the pre-existing compositor pixel test. - Docs same-commit: ai.md slice 10 (+ encoder/stop-note corrections), MyMistakes point 8, HANDOFF. Audio untouched (queued follow-up); web overlay still frozen pending timing closure.
This commit is contained in:
@@ -39,7 +39,7 @@ public class FfmpegEncoderTests
|
||||
private sealed class FakeEncoderProcess : IEncoderProcess
|
||||
{
|
||||
public ProcessStartInfo? StartInfo { get; private set; }
|
||||
public MemoryStream Stdin { get; } = new();
|
||||
public Stream Stdin { get; }
|
||||
public Stream StandardInput => Stdin;
|
||||
public TextReader StandardOutput { get; }
|
||||
public bool HasExited { get; private set; }
|
||||
@@ -51,8 +51,11 @@ public class FfmpegEncoderTests
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly QueuedReader _error = new();
|
||||
|
||||
public FakeEncoderProcess(string probeOutput = "")
|
||||
=> StandardOutput = new StringReader(probeOutput);
|
||||
public FakeEncoderProcess(string probeOutput = "", Stream? stdin = null)
|
||||
{
|
||||
StandardOutput = new StringReader(probeOutput);
|
||||
Stdin = stdin ?? new MemoryStream();
|
||||
}
|
||||
|
||||
public TextReader StandardError => _error;
|
||||
public void EnqueueStderr(string line) => _error.Enqueue(line);
|
||||
@@ -152,7 +155,12 @@ public class FfmpegEncoderTests
|
||||
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 255, 0, 0)));
|
||||
await encoder.SubmitFrameAsync(new VideoFrame(1920, 1080, BgraFrame(1920, 1080, 0, 255, 0)));
|
||||
Assert.Equal(2 * 1920 * 1080 * 4, encoderProc.Stdin.Length);
|
||||
// The drain task writes async — poll for both frames instead of racing it.
|
||||
const long twoFrames = 2 * 1920L * 1080 * 4;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (encoderProc.Stdin.Length < twoFrames && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
Assert.Equal(twoFrames, encoderProc.Stdin.Length);
|
||||
|
||||
encoderProc.EnqueueStderr(
|
||||
"frame= 120 fps= 59.9 q=28.0 size= 1024KiB time=00:00:02.00 bitrate= 4000.1kbits/s speed=1.00x");
|
||||
@@ -341,4 +349,72 @@ public class FfmpegEncoderTests
|
||||
|
||||
Assert.Equal("libopenh264", FfmpegEncoderPicker.Pick("no encoders at all"));
|
||||
}
|
||||
|
||||
/// <summary>A stdin slow enough that the encoder can't keep up — the pump's old
|
||||
/// blocking-write failure mode made demonstrable.</summary>
|
||||
private sealed class SlowSink : Stream
|
||||
{
|
||||
public long TotalBytes;
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
await Task.Delay(40, ct).ConfigureAwait(false);
|
||||
Interlocked.Add(ref TotalBytes, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ONE integration test for slice 10 (the bounded-queue reshape): a
|
||||
/// lagging ffmpeg must NOT stall the frame producer. With 300 fast submits against
|
||||
/// a ~25fps-max sink, every queue slot fills and the NEWEST frames are dropped and
|
||||
/// counted; SubmitFrameAsync must return instantly (it never touched a pipe wait —
|
||||
/// the old code blocked in WriteAsync and the pump froze); StopAsync still flushes
|
||||
/// every accepted frame; and the sink ends with exactly (submitted − dropped) bytes
|
||||
/// — the drop counter and the recording agree (the burned-in-frame-counter premise).</summary>
|
||||
[Fact]
|
||||
public async Task Backpressure_QueueOverflow_DropsFrames_AndNeverBlocks()
|
||||
{
|
||||
var sink = new SlowSink();
|
||||
var encoderProc = new FakeEncoderProcess(stdin: sink);
|
||||
var factory = new FakeEncoderProcessFactory();
|
||||
factory.Return(new FakeEncoderProcess());
|
||||
factory.Return(encoderProc);
|
||||
|
||||
using var encoder = new FfmpegEncoder(new StubLocator(), factory.Create);
|
||||
await encoder.StartAsync(new EncoderOptions
|
||||
{
|
||||
RtmpUrl = "rtmp://x/y",
|
||||
Width = 64,
|
||||
Height = 48,
|
||||
Fps = 60,
|
||||
});
|
||||
|
||||
var frame = new VideoFrame(64, 48, BgraFrame(64, 48, 255, 0, 0));
|
||||
const int submits = 300;
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
for (var i = 0; i < submits; i++)
|
||||
await encoder.SubmitFrameAsync(frame);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"SubmitFrameAsync blocked on a saturated queue: {sw.Elapsed}");
|
||||
var dropped = encoder.DroppedFrames;
|
||||
Assert.True(dropped > 0, "the bounded queue must overflow-drop when the encoder lags");
|
||||
Assert.True(dropped < submits, $"the whole lot must not vanish ({dropped}/{submits})");
|
||||
|
||||
encoderProc.SignalExit();
|
||||
await encoder.StopAsync();
|
||||
|
||||
Assert.Equal((submits - dropped) * 64 * 48 * 4L, sink.TotalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ public class FramePumpTests
|
||||
public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);
|
||||
|
||||
public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
public int DroppedFrames { get; set; }
|
||||
}
|
||||
|
||||
private static Scene BackgroundScene()
|
||||
|
||||
Reference in New Issue
Block a user