From eb4c379b917f518ececab8a8e94288d2e162b116 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 4 Sep 2026 12:16:20 -0700 Subject: [PATCH] =?UTF-8?q?fix(pump):=20slice=207=20=E2=80=94=20take=20the?= =?UTF-8?q?=20loop=20off=20the=20UI=20thread=20(the=20'wait=2010ms=20after?= =?UTF-8?q?=20render=2022ms'=20contradiction=20resolved)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- HANDOFF.md | 4 +-- MyMistakes.md | 14 +++++++- Services/ChatOverlayLayer.cs | 26 +++++++++++++- Services/Compositor/StaticPixelCache.cs | 17 ++++++---- Services/Encoder/FramePump.cs | 20 +++++++++-- TASKS.md | 2 +- ai.md | 17 ++++++++++ ytLive.Tests/FramePumpTests.cs | 45 +++++++++++++++++++++++++ 8 files changed, 131 insertions(+), 14 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 20608f4..e27aa57 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -52,7 +52,7 @@ RealApp boot-smoke. Scope-check passed. 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 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 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 @@ -60,7 +60,7 @@ RealApp boot-smoke. Scope-check passed. `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 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 ~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). diff --git a/MyMistakes.md b/MyMistakes.md index 77dedf2..d6c605e 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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 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 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 diff --git a/Services/ChatOverlayLayer.cs b/Services/ChatOverlayLayer.cs index 3600d8c..6656aeb 100644 --- a/Services/ChatOverlayLayer.cs +++ b/Services/ChatOverlayLayer.cs @@ -117,8 +117,32 @@ public sealed class ChatOverlayLayer if (_frameKey == key && _frameVersion == _contentVersion) 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; - _frameVersion = _contentVersion; + _frameVersion = version; if (Messages.Count == 0) return _cachedFrame = null; return _cachedFrame = _renderer.Render( diff --git a/Services/Compositor/StaticPixelCache.cs b/Services/Compositor/StaticPixelCache.cs index e98f623..d9f69cd 100644 --- a/Services/Compositor/StaticPixelCache.cs +++ b/Services/Compositor/StaticPixelCache.cs @@ -16,14 +16,19 @@ public static class StaticPixelCache public static VideoFrame? Get(string assetId) { if (string.IsNullOrWhiteSpace(assetId)) return null; - if (Cache.TryGetValue(assetId, out var frame)) return frame; + // 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; - var bytes = LayoutStore.Instance?.GetAssetBytes(assetId); - if (bytes == null || bytes.Length == 0) return null; + var bytes = LayoutStore.Instance?.GetAssetBytes(assetId); + if (bytes == null || bytes.Length == 0) return null; - var decoded = Decode(bytes); - if (decoded != null) Cache[assetId] = decoded; - return decoded; + var decoded = Decode(bytes); + if (decoded != null) Cache[assetId] = decoded; + return decoded; + } } public static VideoFrame? Decode(byte[] bytes) diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index 79cfd22..a4580d7 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -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; } diff --git a/TASKS.md b/TASKS.md index 287ca40..6ba3c8d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. -### 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) 2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags` diff --git a/ai.md b/ai.md index 397b564..1944323 100644 --- a/ai.md +++ b/ai.md @@ -777,6 +777,23 @@ seam:** `Func`, `Func` resolver, `Func 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) **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 diff --git a/ytLive.Tests/FramePumpTests.cs b/ytLive.Tests/FramePumpTests.cs index 94d22e5..c20e3b5 100644 --- a/ytLive.Tests/FramePumpTests.cs +++ b/ytLive.Tests/FramePumpTests.cs @@ -424,4 +424,49 @@ public class FramePumpTests Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count, "every frame rode a fresh buffer — the scratch pool is inert"); } + + /// 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. + [Fact] + public async Task Pump_Produces_OffTheStartingContext() + { + var startThread = Environment.CurrentManagedThreadId; + var resolverThreads = new List(); + 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; + } }