fix(dispatcher): count low and idle work in the render budget - #24040
fix(dispatcher): count low and idle work in the render budget#24040MartinZikmund wants to merge 2 commits into
Conversation
Render actions are consulted before the priority queues, and the only anti-starvation budget was seeded from the Normal queue alone. With the Normal queue empty a render loop preempts Low and Idle indefinitely, so anything awaiting CoreDispatcher.RunIdleAsync never resumes. Hosts without a frame pacer hit this constantly: macOS draws on the dispatcher thread, so a render action is pending on nearly every turn. Seed the budget from Normal plus a capped share of Low and Idle, and decrement it on every non-High dispatch. High is excluded because the render pipeline posts CompositionTarget.RaiseRendering there itself, so counting it would let rendering consume the budget meant to yield to the other queues. Normal stays uncapped, leaving today's Normal-vs-render pacing unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K297hJpRGEufnFFqS7pZh9
Both tests model an unpaced host: the next frame is requested as soon as the previous one is drawn, and each frame posts RaiseRendering at High priority. The unit test owns the dispatcher pump so ordering is deterministic, and asserts both directions — idle work runs, and a deep idle backlog does not starve rendering. The runtime test covers the same contract on a real windowing host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K297hJpRGEufnFFqS7pZh9
|
Claude finished @MartinZikmund's task in 12m 28s —— View job Review complete
Overall verdict: ✅ Ship (with nits)The fix is correct. Root cause is properly identified (budget seeded only from Normal, decremented only on Normal dispatch → Low/Idle starved whenever Normal is empty), and the repair targets the mutation point as required: seed from 3 findings — all minor/nit, none blocking:
Cross-cutting notes:
|
| #if __ANDROID__ || __WASM__ || __SKIA__ || __APPLE_UIKIT__ || IS_UNIT_TESTS | ||
| // Bounds how long a deep Low/Idle backlog can hold rendering back. Normal items stay uncapped, which | ||
| // keeps the existing Normal-vs-render pacing unchanged. | ||
| private const int MaxLowPriorityItemsBeforeRender = 2; |
There was a problem hiding this comment.
Minor naming nit: MaxLowPriorityItemsBeforeRender is applied to Low.Count + Idle.Count combined (line 256–257), so the name implies it only guards against Low when it actually caps both. Consider MaxLowOrIdleItemsBeforeRender or MaxLowAndIdleItemsBeforeRender to reduce future confusion for readers of the constant alone.
| private const int TimeoutMs = 5000; | ||
|
|
||
| [TestMethod] | ||
| [PlatformCondition(Include, SkiaWin32 | SkiaX11 | SkiaMacOS)] |
There was a problem hiding this comment.
SkiaFrameBuffer is an unpaced host (the PR description explicitly names it alongside macOS as an affected target), yet it is absent from this [PlatformCondition]. Since the test drives the dispatcher directly via EnqueueRender (not through a real vsync), it is equally valid on FrameBuffer. Consider:
| [PlatformCondition(Include, SkiaWin32 | SkiaX11 | SkiaMacOS)] | |
| [PlatformCondition(Include, SkiaWin32 | SkiaX11 | SkiaMacOS | SkiaFrameBuffer)] |
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
| using Uno.UI.Dispatching; | ||
|
|
||
| namespace Uno.UI.Tests.Uno_UI_Dispatching |
There was a problem hiding this comment.
New file — per the repo code-style rule, new files should use file-scoped namespaces (namespace Foo;). The braced form here is inconsistent with the paired runtime-test file (which does use file-scoped). This doesn't affect behaviour but CI style-enforcement will flag it.
| namespace Uno.UI.Tests.Uno_UI_Dispatching | |
| namespace Uno.UI.Tests.Uno_UI_Dispatching; |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-24040/wasm-skia-net9/index.html |
|
|
GitHub Issue: closes #24032
PR Type:
🐞 Bugfix
What changed? 🚀
Current behavior.
NativeDispatcher.DispatchItemsconsultsTryGetRenderAction()before it scans the four priority queues, so a pending render action outranks every queued item. The only anti-starvation guard,normalItemsToProcessBeforeNextRenderAction, was seeded from the Normal queue alone (:216) and decremented only when a Normal item was dispatched (:156). With the Normal queue empty — the ordinary state on a settled visual tree — the budget was re-seeded to0on every render, so the next render action was immediately eligible again and Low and Idle never got a turn. Anything awaitingCoreDispatcher.RunIdleAsynctherefore never resumed.Hosts that pace their frames rarely notice: Win32 (
Win32RenderPacer.cs) and X11 (FramePacer) produce frames on a separate paced thread, so a render action is pending only briefly. macOS has neither —MacOSWindowHost.InvalidateRender()is a bareuno_window_invalidate→needsDisplay = YESand AppKit draws on the dispatcher's own thread — so a render action is pending on nearly every turn and the starvation is total.Change. Repair the accounting at the mutation point rather than guarding downstream:
Normal.Count + min(Low.Count + Idle.Count, 2)(GetItemsToProcessBeforeNextRenderAction)normalItemsToProcessBeforeNextRenderAction→itemsToProcessBeforeNextRenderAction, since it is no longer Normal-onlyHigh is excluded deliberately: the render pipeline posts
CompositionTarget.RaiseRenderingat High priority on every frame, so counting it would let rendering consume the budget that exists to yield to the other queues. Normal stays uncapped, leaving today's Normal-vs-render pacing bit-for-bit unchanged.Why not the simpler options. Widening the budget to all queues while leaving the decrement in the
_currentPriority == Normalbranch deadlocks rendering — once Normal drains, the budget never returns to0. An earlier "let one queued item through after K consecutive renders" floor was also drafted and rejected:RaiseRenderingregenerates a High item each frame and consumes exactly the slot the floor releases, so Idle still starves. TheWhen_Render_Loop_Is_Active_Then_Idle_Work_Runstest below fails against that design.Invariants.
budget ≤ Normal.Count + Low.Count + Idle.Countholds at every mutation, sobudget > 0implies some non-High queue is non-empty; the scan that follows always dequeues, and the still-pending render keeps_globalCount > 0soEnqueueNativealways re-posts — no lost wakeup. Renders are deferred by at mostNormal.Count + 2items plus the one outstandingRaiseRendering, and no new render work is produced while renders are deferred — no render starvation in the other direction.WinUI parity
WinUI does not schedule rendering through
CoreDispatcher's priority queues at all — the compositor rasterizes off-thread andCompositionTarget.Renderingis a vsync-paced UI-thread callback, so a running animation or a scrollingScrollViewercan never prevent an idle callback from firing. Total idle starvation has no WinUI analogue and is plainly wrong againstRunIdleAsync's contract, so this moves Uno toward parity. Routing renders through the dispatcher is Uno-specific (CompositionTarget.RenderScheduling.skia.cs:172), so this is a repair of an Uno invariant rather than a port of a WinUI mechanism — the genuinely WinUI-faithful fix is macOS frame pacing, tracked separately.One bounded divergence is introduced knowingly: a frame can now be deferred behind up to 2 Low/Idle items, which WinUI would not do. That is why the share is capped rather than counting the whole backlog; a 2-item frame delay is far closer to WinUI than indefinite idle starvation. Once macOS frame pacing lands the budget rarely binds at all.
Blast radius
The changed region is inside
#if __ANDROID__ || __WASM__ || __SKIA__ || __APPLE_UIKIT__ || IS_UNIT_TESTS, so it compiles for the maintenance-only native targets too.EnqueueRenderhas exactly one production caller (CompositionTarget.RenderScheduling.skia.cs:172, a.skia.csfile); on native Android, native iOS and native WASM_compositionTargetsis never populated,TryGetRenderActionreturnsnullimmediately and the budget can never leave0, so the change is inert there. Observable pacing change is concentrated on the unpaced hosts: macOS, FrameBuffer, Headless.Validation
SamplesApp.Skia.Generic, local):Given_NativeDispatcher.When_Render_Requested_Continuously_Then_Idle_Work_Runsfails onmaster, passes with the fix.Given_SystemFocusVisual(one of the classes that stalls in CI) passes 5/5.master— "Idle work never ran (100 render actions over 100 dispatcher turns)" and "Idle work was starved by rendering (0 idle items, 30 renders)" — and pass with the fix.Uno.UI.DispatchingSkia, Wasm and Reference flavors clean.netcoremobilewas not built locally; relying on CI.libicudataon this machine, unrelated to this change. Relying on CI for full-suite coverage; please check the macOS leg before merging.PR Checklist ✅
Screenshots Compare Test Runresults.