Skip to content

fix(dispatcher): count low and idle work in the render budget - #24040

Draft
MartinZikmund wants to merge 2 commits into
masterfrom
dev/mazi/macoshangs
Draft

fix(dispatcher): count low and idle work in the render budget#24040
MartinZikmund wants to merge 2 commits into
masterfrom
dev/mazi/macoshangs

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

GitHub Issue: closes #24032

PR Type:

🐞 Bugfix

What changed? 🚀

Current behavior. NativeDispatcher.DispatchItems consults TryGetRenderAction() 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 to 0 on every render, so the next render action was immediately eligible again and Low and Idle never got a turn. Anything awaiting CoreDispatcher.RunIdleAsync therefore 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 bare uno_window_invalidateneedsDisplay = YES and 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:

  • seed the budget from Normal.Count + min(Low.Count + Idle.Count, 2) (GetItemsToProcessBeforeNextRenderAction)
  • decrement it on every non-High dispatch
  • rename normalItemsToProcessBeforeNextRenderActionitemsToProcessBeforeNextRenderAction, since it is no longer Normal-only

High is excluded deliberately: the render pipeline posts CompositionTarget.RaiseRendering at 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 == Normal branch deadlocks rendering — once Normal drains, the budget never returns to 0. An earlier "let one queued item through after K consecutive renders" floor was also drafted and rejected: RaiseRendering regenerates a High item each frame and consumes exactly the slot the floor releases, so Idle still starves. The When_Render_Loop_Is_Active_Then_Idle_Work_Runs test below fails against that design.

Invariants. budget ≤ Normal.Count + Low.Count + Idle.Count holds at every mutation, so budget > 0 implies some non-High queue is non-empty; the scan that follows always dequeues, and the still-pending render keeps _globalCount > 0 so EnqueueNative always re-posts — no lost wakeup. Renders are deferred by at most Normal.Count + 2 items plus the one outstanding RaiseRendering, 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 and CompositionTarget.Rendering is a vsync-paced UI-thread callback, so a running animation or a scrolling ScrollViewer can never prevent an idle callback from firing. Total idle starvation has no WinUI analogue and is plainly wrong against RunIdleAsync'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. EnqueueRender has exactly one production caller (CompositionTarget.RenderScheduling.skia.cs:172, a .skia.cs file); on native Android, native iOS and native WASM _compositionTargets is never populated, TryGetRenderAction returns null immediately and the budget can never leave 0, so the change is inert there. Observable pacing change is concentrated on the unpaced hosts: macOS, FrameBuffer, Headless.

Validation

  • Runtime (macOS Skia, SamplesApp.Skia.Generic, local): Given_NativeDispatcher.When_Render_Requested_Continuously_Then_Idle_Work_Runs fails on master, passes with the fix. Given_SystemFocusVisual (one of the classes that stalls in CI) passes 5/5.
  • Runtime (unit tests): both new tests fail on 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.
  • Compile: Uno.UI.Dispatching Skia, Wasm and Reference flavors clean. netcoremobile was not built locally; relying on CI.
  • A full local macOS runtime-suite pass was not obtained — the run aborts in the HotReload tests because the spawned secondary app fails to load libicudata on this machine, unrelated to this change. Relying on CI for full-suite coverage; please check the macOS leg before merging.

PR Checklist ✅

MartinZikmund and others added 2 commits August 11, 2026 10:12
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
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 12m 28s —— View job


Review complete

  • Gather context and read changed files
  • Review NativeDispatcher.cs changes
  • Review unit tests (Given_NativeDispatcher.cs)
  • Review runtime tests (Given_NativeDispatcher.cs)
  • Post inline comments and summary

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 Normal.Count + min(Low.Count + Idle.Count, 2) in GetItemsToProcessBeforeNextRenderAction, decrement on every non-High dispatch. The invariant proof in the PR description is sound.

3 findings — all minor/nit, none blocking:

# Severity File Finding
1 Nit NativeDispatcher.cs:130 MaxLowPriorityItemsBeforeRender constant name implies Low-only; it caps Low + Idle combined
2 Minor RuntimeTests/Given_NativeDispatcher.cs:26 SkiaFrameBuffer (unpaced, named in PR description as affected) absent from [PlatformCondition]
3 Nit UnitTests/Given_NativeDispatcher.cs:8 New file uses braced namespace; style guide requires file-scoped (namespace Foo;) for new files

Cross-cutting notes:

  • The ordering fix (decrement budget before EnqueueNative) is important for the IS_UNIT_TESTS re-entrant dispatch path and is correctly reasoned in the comment.
  • Both test files exercise a genuinely adversarial scenario (unpaced render loop + idle/render interaction) — the unit test is deterministic and the runtime test exercises the real host path. Red/green coverage is solid.
  • The <remarks> XML doc on GetItemsToProcessBeforeNextRenderAction is longer than typical but justified by the subtle invariant; no concern.
  • Pre-existing: Dictionary value-mutation via indexer inside foreach in DispatchItems would throw InvalidOperationException if there are ≥2 composition targets and the inner if branch fires. This isn't introduced by the PR and is left as-is here.

@github-actions github-actions Bot added the area/automation Categorizes an issue or PR as relevant to project automation label Aug 11, 2026
#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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
[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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
namespace Uno.UI.Tests.Uno_UI_Dispatching
namespace Uno.UI.Tests.Uno_UI_Dispatching;

@unodevops

Copy link
Copy Markdown
Contributor

🤖 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

@unodevops

Copy link
Copy Markdown
Contributor

⚠️⚠️ The build 226938 has failed on Uno.UI - CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/automation Categorizes an issue or PR as relevant to project automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skia: rendering starves Idle/Low dispatcher work, hanging RunIdleAsync (macOS runtime tests time out)

2 participants