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:
2026-09-10 09:32:55 -07:00
parent bd396e488c
commit c45cbc93b7
8 changed files with 414 additions and 79 deletions
+80 -4
View File
@@ -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);
}
}