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:
2026-09-04 12:16:20 -07:00
parent 09a866e6a1
commit eb4c379b91
8 changed files with 131 additions and 14 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ RealApp boot-smoke. Scope-check passed.
not cache keys. Tests: `PasteCache_...` + 85/85 across compositor/pump/chat/capture/session not cache keys. Tests: `PasteCache_...` + 85/85 across compositor/pump/chat/capture/session
classes; clean build 0 warnings. (Cam bypasses the cache via IsOpaque; revisit if take 9 is classes; clean build 0 warnings. (Cam bypasses the cache via IsOpaque; revisit if take 9 is
borderline. Follow-ups unchanged: vertical-tier alloc, debounced chat re-render on bursts.) borderline. Follow-ups unchanged: vertical-tier alloc, debounced chat re-render on bursts.)
3. **Take 9 ran + slice 6 (2026-09-04, THE FINDING):** paste cache moved render 26.5→22.4ms yet 3. **Take 9/10 + slices 6-7 (2026-09-04):** slice 6 broke the 15.6ms Task.Delay sleep quantum (timeBeginPeriod + bulk-sleep + 2ms spin tail + wait stat); take 10's `wait 10ms after render 22ms` then exposed the FINAL structural bug: the pump loop's await-continuations inherited the UI SynchronizationContext — the "WPF-free" producer had been rendering ON THE DISPATCHER, queued behind the live preview, the whole time (explains every 'zero change' complaint). Slice 7: Task.Run the loop (OBS pattern) + StaticPixelCache lock + chat raster-miss marshalled to dispatcher + SustainedLowLatency GC + `worst render` stat + webcam through paste cache. Test `Pump_Produces_OffTheStartingContext`. 70/70 green, clean build. paste cache moved render 26.5→22.4ms yet
period stayed ~37ms — the gap is Task.Delay's ~15.6ms Windows sleep quantum padding every sub-tick period stayed ~37ms — the gap is Task.Delay's ~15.6ms Windows sleep quantum padding every sub-tick
wait. **This is why takes 7→9 read as "zero change" despite real wins: the sleep floor dominated.** wait. **This is why takes 7→9 read as "zero change" despite real wins: the sleep floor dominated.**
Fixed (media-app canon, cited in code + MyMistakes #3): timeBeginPeriod(1) for the pump's life Fixed (media-app canon, cited in code + MyMistakes #3): timeBeginPeriod(1) for the pump's life
@@ -60,7 +60,7 @@ RealApp boot-smoke. Scope-check passed.
`avg wait` so render+submit+wait ≈ period (accounting closed — nothing can hide). Webcam now `avg wait` so render+submit+wait ≈ period (accounting closed — nothing can hide). Webcam now
routes through the paste cache too (bypass re-sampled 156k px even between identical device routes through the paste cache too (bypass re-sampled 156k px even between identical device
frames). 52/52 per-class green, clean build 0 warnings, committed this slice. frames). 52/52 per-class green, clean build 0 warnings, committed this slice.
4. **Take 10 (user, ~30s record-only):** read the superscript; stats must show `≈300/300, avg render 4. **Take 11 (user, ~30s record-only):** read the superscript; expect `≈300/300 frames, wait ≈ the true remainder, worst render` now visible. If ~300: playback must be honest 1x — saga CLOSED, Unit B starts. If still ~250-280 with worst-render spikes: raster-miss spikes (web capture ~ every second) — next slice is pre-rasterizing on content change rather than on first-tick-after-change (cache the miss behind a swap-in). If wait is STILL large: the context theory was wrong and I have egg to eat — re-instrument, don't guess.
~22, wait ~0-3` — honest 60fps IF render+submit ≤ ~16.7. If wait is near zero and n/300 sits at ~22, wait ~0-3` — honest 60fps IF render+submit ≤ ~16.7. If wait is near zero and n/300 sits at
~200, the remaining gap is pure render 22ms → next slice = per-phase compositor timing (the stats ~200, the remaining gap is pure render 22ms → next slice = per-phase compositor timing (the stats
can split blit phases the same way they split resolve; that is the honest path, not a guess). can split blit phases the same way they split resolve; that is the honest path, not a guess).
+13 -1
View File
@@ -93,7 +93,19 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
(the chat log) silently arm the per-tick cost even in flows that never touch the (the chat log) silently arm the per-tick cost even in flows that never touch the
feature (signed-out record-only takes paid chat rendering!). feature (signed-out record-only takes paid chat rendering!).
6. **Prove the stage, then the fix — and re-prove after every slice (2026-09-04, takes 6-8).** 6. **An async loop started from a UI handler runs ON THE UI THREAD until you take it off.**
`await` continuations re-capture the current `SynchronizationContext` — the frame pump was started
from a WPF command handler, so the "WPF-free, hermetic" compositor rendered and read capture state
ON THE DISPATCHER, serialized behind the live preview itself, for the whole starvation saga. The
`wait` stat caught it only when the numbers became self-contradictory (render 22 + wait 10 > any
rebasing deadline — a blown deadline cannot sleep). OBS runs `obs_graphics_thread`/`video_thread`
as dedicated threads for exactly this reason. Pattern: `_task = Task.Run(() => Loop())` (null
context inside), then audit EVERY object the loop touches for UI affinity (RenderTargetBitmap /
DrawingVisual / WriteableBitmap: marshal the work or the rare miss; plain locked byte[] lookups:
fine) and pin it with a context test (`Pump_Produces_OffTheStartingContext`, inline-pumping
SynchronizationContext that the old code failed by construction). Cost: takes 3–10.
7. **Prove the stage, then the fix — and re-prove after every slice (2026-09-04, takes 6-8).**
The chat raster fix was REAL but the composer blamed it for the residual slowness it did not The chat raster fix was REAL but the composer blamed it for the residual slowness it did not
own; two takes burned before the render/resolve split showed `resolve ≈ 0` and pointed at the own; two takes burned before the render/resolve split showed `resolve ≈ 0` and pointed at the
compositor pasting static layers per tick (`BlitCachedLayer` finished the job OBS-style). Before compositor pasting static layers per tick (`BlitCachedLayer` finished the job OBS-style). Before
+25 -1
View File
@@ -117,8 +117,32 @@ public sealed class ChatOverlayLayer
if (_frameKey == key && _frameVersion == _contentVersion) if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame; // same content + same config — the cached raster stands return _cachedFrame; // same content + same config — the cached raster stands
// The raster is WPF (DrawingVisual + RenderTargetBitmap = UI-thread objects)
// and the frame pump now renders from its own thread (take-8 off-UI fix) —
// so only the cache CHECK runs off-thread; the rare miss marshals to the
// dispatcher and re-validates there (a second message landing mid-hop must
// not be answered by a snapshot taken before it).
var app = System.Windows.Application.Current;
if (app != null && !app.Dispatcher.CheckAccess())
{
var capturedKey = key;
var capturedVersion = _contentVersion;
var frame = app.Dispatcher.Invoke(() =>
{
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
return _cachedFrame; // someone already re-rasterized on the UI thread
return RenderCore(chatBox, width, height, capturedKey, capturedVersion);
});
return frame;
}
return RenderCore(chatBox, width, height, key, _contentVersion);
}
private VideoFrame? RenderCore(Source chatBox, int width, int height, string key, int version)
{
_frameKey = key; _frameKey = key;
_frameVersion = _contentVersion; _frameVersion = version;
if (Messages.Count == 0) return _cachedFrame = null; if (Messages.Count == 0) return _cachedFrame = null;
return _cachedFrame = _renderer.Render( return _cachedFrame = _renderer.Render(
+5
View File
@@ -16,6 +16,10 @@ public static class StaticPixelCache
public static VideoFrame? Get(string assetId) public static VideoFrame? Get(string assetId)
{ {
if (string.IsNullOrWhiteSpace(assetId)) return null; if (string.IsNullOrWhiteSpace(assetId)) return null;
// Locked: the frame pump decodes from its own thread now (take-8 off-UI fix),
// so the dictionary genuinely races with UI-path callers (snapshots, heals).
lock (Cache)
{
if (Cache.TryGetValue(assetId, out var frame)) return frame; if (Cache.TryGetValue(assetId, out var frame)) return frame;
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId); var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
@@ -25,6 +29,7 @@ public static class StaticPixelCache
if (decoded != null) Cache[assetId] = decoded; if (decoded != null) Cache[assetId] = decoded;
return decoded; return decoded;
} }
}
public static VideoFrame? Decode(byte[] bytes) public static VideoFrame? Decode(byte[] bytes)
{ {
+17 -3
View File
@@ -154,7 +154,15 @@ public sealed class FramePump : IDisposable
// synchronously on this thread before PumpAsync even returns. // synchronously on this thread before PumpAsync even returns.
IsRunning = true; IsRunning = true;
_cts = new CancellationTokenSource(); _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)"); _log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
} }
catch (Exception ex) 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 // 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. // names the stage (get-frame vs blit) instead of feeding another guess.
var resolveSw = new System.Diagnostics.Stopwatch(); 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; int statFrames = 0;
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5); var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
void ReportStats() void ReportStats()
@@ -299,7 +307,9 @@ public sealed class FramePump : IDisposable
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " + $"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " + $"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, " $"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; renderTicks = submitTicks = resolveTicks = waitTicks = 0;
statFrames = 0; statFrames = 0;
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5); 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 // One wrapper shared by every render of the run — resolve time accumulates
// inside the render measurement, and the stats line reports the split. // inside the render measurement, and the stats line reports the split.
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note 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) VideoFrame? TimedResolver(SceneElement element)
{ {
resolveSw.Restart(); resolveSw.Restart();
@@ -359,6 +371,7 @@ public sealed class FramePump : IDisposable
lastTick.Restart(); lastTick.Restart();
renderSw.Stop(); renderSw.Stop();
renderTicks += renderSw.ElapsedTicks; renderTicks += renderSw.ElapsedTicks;
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
IFfmpegEncoder? encoder; IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder; lock (_gate) encoder = _encoder;
@@ -422,6 +435,7 @@ public sealed class FramePump : IDisposable
} }
finally finally
{ {
System.Runtime.GCSettings.LatencyMode = previousGcMode;
timeEndPeriod(1); timeEndPeriod(1);
lock (_gate) IsRunning = false; lock (_gate) IsRunning = false;
} }
+1 -1
View File
@@ -973,7 +973,7 @@ click (volume sliders keep their manual `SetSliderValueFromClick`, harmless dupl
**Goal:** record the stream output to a local file, with or without simultaneously streaming. **Goal:** record the stream output to a local file, with or without simultaneously streaming.
### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10: stats must show `≈300/300 frames, render+submit+wait ≈ period`** → saga closes.** Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF ### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10 ran (59a02a5b): the new `wait` stat exposed the LAST structural bug — `render 22 + wait 10` against a 16.7ms deadline is impossible for a rebasing pacer: the wait was the producer QUEUED BEHIND THE LIVE PREVIEW — the pump's await-continuations inherit the UI SynchronizationContext (StartAsync fires from a command handler), so the loop had been rendering on the dispatcher all along. Slice 7: Task.Run the loop (OBS keeps media threads off-UI for this exact reason), StaticPixelCache locked + chat raster marshalled to the dispatcher on cache-miss (RTB/DrawingVisual are UI-thread objects), SustainedLowLatency GC, `worst render` stat, webcam routed through the paste cache; test `Pump_Produces_OffTheStartingContext`, 70/70 green. **take 11: `≈300/300 frames, wait ≈ the true remainder` → saga closes.**** Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF
1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags) 1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags)
2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags` 2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags`
+17
View File
@@ -777,6 +777,23 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
bypass was removed (it re-sampled ~156k px every tick even between identical device frames; the bypass was removed (it re-sampled ~156k px every tick even between identical device frames; the
cached paste beats the sampler on hits and costs the same on misses). Take 10 verdict: `≈300/300` cached paste beats the sampler on hits and costs the same on misses). Take 10 verdict: `≈300/300`
honest fps — if short, the wait/render split names the remaining term with no ambiguity left. honest fps — if short, the wait/render split names the remaining term with no ambiguity left.
- **Slice 7 — the loop was ON the UI thread the whole time (take-8 numbers, 2026-09-04):** with
the wait finally measured, take 8 (59a02a5b) showed the impossible pair — `render 22ms, wait 10ms`
against a 16.7ms deadline: a blown deadline rebases with NO wait. The wait was the producer
**queued behind the live preview on the dispatcher**: `PumpAsync` starts from a UI command handler
and its `await` continuations inherit the UI `SynchronizationContext`, so the "WPF-free" compositor
actually rendered on the UI thread and ran only when WPF let it. OBS runs its media loops on
dedicated threads for exactly this reason. Fix: `_pumpTask = Task.Run(() => PumpAsync(...))`
(no sync context inside → continuations stay on the pool). Side effects handled, not ignored:
`StaticPixelCache.Get` now locks (pool miss-decodes race 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 marshalled.
Also: `GCLatencyMode.SustainedLowLatency` for the pump's life, stats gained `worst render Xms`
(spike visibility — bimodal averages hid them), webcam now routes through the paste cache (bypass
re-sampled 156k px even between identical device frames). Tests: `Pump_Produces_OffTheStartingContext`
(the ONE — inline-pumping SyncContext proves continuations never return to the starting thread) +
70/70. Take 9 verdict: wait ≈ true remainder (period → 16.7, n → ~300); `worst render` names any
remaining raster-miss spikes; render+submit+wait still == period — the accounting holds.
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits) - **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the **before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
+45
View File
@@ -424,4 +424,49 @@ public class FramePumpTests
Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count, Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count,
"every frame rode a fresh buffer — the scratch pool is inert"); "every frame rode a fresh buffer — the scratch pool is inert");
} }
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
/// command handler; the old loop inherited it, so every frame competed with the
/// live preview for the dispatcher — the "wait 10ms after a 22ms render" that the
/// quantum stats exposed). An inline-pumping sync context makes the OLD code run
/// its resolver on the starting thread; the Task.Run'd loop must never do so.</summary>
[Fact]
public async Task Pump_Produces_OffTheStartingContext()
{
var startThread = Environment.CurrentManagedThreadId;
var resolverThreads = new List<int>();
var firstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
lock (resolverThreads) resolverThreads.Add(Environment.CurrentManagedThreadId);
firstFrame.TrySetResult();
return null;
});
var prev = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new InlineSyncContext());
try
{
await pump.StartAsync();
await firstFrame.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
finally
{
SynchronizationContext.SetSynchronizationContext(prev);
await pump.StopAsync();
}
Assert.NotEmpty(resolverThreads);
Assert.All(resolverThreads, id => Assert.True(id != startThread,
$"resolver ran on the starting thread ({startThread}) — the loop is back on the caller context"));
}
private sealed class InlineSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object? state) => d(state);
public override void Send(SendOrPostCallback d, object? state) => d(state);
public override SynchronizationContext CreateCopy() => this;
}
} }