fix(pump): slice 7 — take the loop off the UI thread (the 'wait 10ms after render 22ms' contradiction resolved)
Take 10 (59a02a5b, slice 6) finally produced a self-contradicting stat: render 22.4ms + submit 2.5 against a 16.7ms deadline, yet avg wait 10ms — a rebasing pacer CANNOT sleep after a blown deadline. The wait was queue time: StartAsync fires from a UI command handler, and async continuations re-capture the current SynchronizationContext — the 'WPF-free, hermetic' frame pump had been rendering ON THE DISPATCHER behind the live preview the entire starvation saga. OBS keeps obs_graphics_thread/video_thread off-UI for exactly this reason (dedicated threads; see docs.obsproject.com/backend-design 'Libobs Threads'). - FramePump: _pumpTask = Task.Run(() => PumpAsync(...)) — null context inside, every continuation stays on the pool. - Audited, not ignored, what that exposes: StaticPixelCache.Get now locks (pool miss-decodes raced UI callers); ChatOverlayLayer.RenderFrame checks its cache off-thread but marshals the rare raster MISS to the dispatcher (DrawingVisual + RenderTargetBitmap are UI-thread objects) and re-validates there; pump events already marshal in the VM. - GCLatencyMode.SustainedLowLatency for the pump's life (restored in finally). - Stats gained 'worst render Xms' — bimodal averages hid per-tick spikes. - Webcam routes through the paste cache (the IsOpaque bypass re-sampled ~156k px every tick even between identical device frames). ONE integration test: Pump_Produces_OffTheStartingContext — an inline-pumping SynchronizationContext makes the old construction run the resolver on the starting thread by capture; the loop must never. 70/70 per-class green, clean build 0 warnings. Docs same commit (ai.md slice 7, TASKS take-11 gate, MyMistakes #6, HANDOFF). take 11: ~300/300 + honest wait -> saga closed, Unit B (two-line top bar spec, fully captured) starts.
This commit is contained in:
@@ -154,7 +154,15 @@ public sealed class FramePump : IDisposable
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
// The loop runs on the thread pool ON PURPOSE (take-8 finding, 2026-09-04):
|
||||
// Task.Run installs no SynchronizationContext, so every await continuation
|
||||
// stays off the UI dispatcher. Before this, the pump inherited the UI
|
||||
// thread's sync context (StartAsync is fired from a command handler), so
|
||||
// "render 22ms, wait 10ms" was the producer sitting in the dispatcher
|
||||
// queue behind the live preview it is meant to be independent of — the
|
||||
// stats quantum fix made the wait VISIBLE; this removes its cause.
|
||||
// OBS's video threads are dedicated for exactly this reason.
|
||||
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -286,7 +294,7 @@ public sealed class FramePump : IDisposable
|
||||
// black box — the stats line now reports resolver time separately so a take
|
||||
// names the stage (get-frame vs blit) instead of feeding another guess.
|
||||
var resolveSw = new System.Diagnostics.Stopwatch();
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0;
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0, worstRender = 0;
|
||||
int statFrames = 0;
|
||||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
void ReportStats()
|
||||
@@ -299,7 +307,9 @@ public sealed class FramePump : IDisposable
|
||||
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
|
||||
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
|
||||
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms");
|
||||
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms");
|
||||
worstRender = 0;
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
statFrames = 0;
|
||||
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
@@ -308,6 +318,8 @@ public sealed class FramePump : IDisposable
|
||||
// One wrapper shared by every render of the run — resolve time accumulates
|
||||
// inside the render measurement, and the stats line reports the split.
|
||||
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
|
||||
var previousGcMode = System.Runtime.GCSettings.LatencyMode;
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
VideoFrame? TimedResolver(SceneElement element)
|
||||
{
|
||||
resolveSw.Restart();
|
||||
@@ -359,6 +371,7 @@ public sealed class FramePump : IDisposable
|
||||
lastTick.Restart();
|
||||
renderSw.Stop();
|
||||
renderTicks += renderSw.ElapsedTicks;
|
||||
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
|
||||
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
@@ -422,6 +435,7 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Runtime.GCSettings.LatencyMode = previousGcMode;
|
||||
timeEndPeriod(1);
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user