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:
+2
-2
@@ -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).
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- **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)
|
||||
**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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user