Skip to content

Correctness fixes for shipped pooling/caching optimizations (if.84)#8

Merged
oysteinkrog merged 7 commits into
if/mainfrom
fix/correctness-batch
Jul 17, 2026
Merged

Correctness fixes for shipped pooling/caching optimizations (if.84)#8
oysteinkrog merged 7 commits into
if/mainfrom
fix/correctness-batch

Conversation

@oysteinkrog

Copy link
Copy Markdown
Member

What this does

Fixes six correctness defects introduced by the pooling and caching optimizations that shipped in if.84 (base ref bbc8bfe), and repairs the smoke/perf CI gate that let those defects ship without a real test ever running. Each source fix ships with a regression test written to fail on if.84 and pass after the fix.

Why

An adversarial correctness review of the fork's shipping optimizations found one process-killing bug and five behavioral hazards, all in the recently added object-pool/instance-cache paths. The common thread: every one was analyzed only on its synchronous happy path, and the release gate that should have caught them was hollow (21 of 23 smoke tests were Assert.That(true) stubs, and the build fabricated an empty test report the aggregate gate then passed).

What's in here

Source fixes (WindowsBase, PresentationCore, PresentationFramework):

  • DispatcherOperationEvent pool guard (blocker). The pooled cross-thread wait wrapper could run a captured completion callback after it had parked or recycled: a null dereference on a threadpool timer thread (process kill), or a stale signal that woke a waiter early. A wrapper-owned state lock plus a sender-identity check makes the callback safe in any state; the pooling is preserved.
  • Revert DispatcherSynchronizationContext instance caching. Sharing one DSC instance per dispatcher/priority made an await continuation completed from a sibling operation run inline inside the completer instead of being posted, reordering async continuations. Restores fresh-per-operation semantics at all five sites (byte-identical to the stock base).
  • Revert DispatcherOperation task-mapping skip. The mapping decision was baked into the task at construction, but dispatcher hooks attach later, so an unmapped task threw on DispatcherOperationWait. Marginal cold-path win, reverted.
  • InputHitTest pooled-params re-entrancy. A re-entrant hit test shared one pooled parameters object and clobbered the outer walk's hit point. The slot is now taken and nulled for the walk, restored in a finally.
  • HwndStyleManager stale-Dirty on pooled reuse. The activation reset ran on only one branch, so a reused manager could emit a spurious native style write. The reset is now unconditional.
  • StreamGeometry pooled-context double-dispose. A pooled context handed to a public Open() caller could be stale-disposed after a later Open() revived the same instance, corrupting the second geometry. Public Open() now returns fresh contexts; pooling is confined to the strictly-scoped internal parse path via OpenPooled().

Plus a comment correction (removed a false "~17x" claim on AdornerLayer; the early return it credits exists in the stock base) and the CI repair: real dotnet test against the packed nupkg with a non-empty-report floor and an anti-stub lint, and perf-on-pack wired to BenchmarkDotNet JSON gating hard on allocations.

Six new regression fixtures live under test/InitialForce.WpfSmoke/Smoke/.

How to review

Start with the DispatcherOperationEvent guard (WindowsBase/.../DispatcherOperation.cs, nested DispatcherOperationEvent) and the DSC revert; those carry the load-bearing reasoning. The other four source fixes are small and local. Then check the CI changes in .github/workflows/build.yml and release.yml do what the fixes assume.

Verification status

Not built or runtime-verified locally (no arcade toolchain in the working checkout). The fixes and tests were authored by source inspection; the regression tests are designed to be red on if.84 and green after, but that is a design claim until this PR's CI builds the assemblies and runs them. Draft until CI is green and a human has reviewed. Do not merge on CI alone.

@oysteinkrog
oysteinkrog force-pushed the fix/correctness-batch branch from 536d227 to 56dcec0 Compare July 17, 2026 11:08
Claude (Initial Force WPF Bot) and others added 7 commits July 17, 2026 13:41
The early-return the comment credited for a ~17x speedup exists in the stock
base, so the claim was unfounded. Comment only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pooled HwndStyleManager reset ran on only one activation branch, so a
reused manager could carry a stale Dirty flag and emit a spurious native style
write. Make the reset unconditional on activation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The if.84 optimization pooled the PointHitTestParameters in a [ThreadStatic]
slot, but that object is handed to every visual's overridable HitTestCore during
the walk. A re-entrant hit test clobbered the outer walk's hit point, and a user
override that retained the reference could observe a later call's SetHitPoint
mutation. Allocate the params fresh per call, as stock WPF does; keep pooling
only the internal InputHitTestResult and its callback pair, which never escape to
user code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pooled context handed to a public Open() caller could be stale-disposed after
a later Open() revived the same instance, corrupting the second geometry. Public
Open() now returns fresh contexts; pooling is confined to the strictly-scoped
internal parse path via OpenPooled().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three correctness defects in the pooled/cached dispatcher-operation paths that
shipped in if.84, all in WindowsBase's Dispatcher/DispatcherOperation:

- Guard the pooled cross-thread DispatcherOperationEvent against stale
  completion. The pooled wait wrapper could run a captured completion callback
  after it had parked or recycled: a null dereference on a threadpool timer
  thread (process kill), or a stale signal that woke a waiter early. A
  wrapper-owned state lock plus a sender-identity check makes the callback safe
  in any state; the pooling is preserved.
- Revert DispatcherSynchronizationContext instance caching. Sharing one DSC
  instance per dispatcher/priority made an await continuation completed from a
  sibling operation run inline inside the completer instead of being posted,
  reordering async continuations. Restores fresh-per-operation semantics at all
  five sites.
- Revert the DispatcherOperation task-mapping skip. The mapping decision was
  baked into the task at construction, but dispatcher hooks attach later, so an
  unmapped task threw on DispatcherOperationWait. The cold-path win is marginal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release gate that should have caught the if.84 pooling defects was hollow:
the smoke job fabricated an empty test report the aggregate gate then passed,
and the perf gate did not fail on allocation regressions. Run a real smoke
suite against the packed nupkg with a non-empty-results floor, gate perf hard
on BenchmarkDotNet allocation JSON, pin the Windows jobs to windows-2022 (the
newer image's MSVC toolset broke the pinned v143 PlatformToolset), and xfail
the verify-tag unsigned check pending GPG provisioning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the stubbed smoke suite (21 of 23 tests were Assert.That(true)) with
fixtures that exercise real WPF behavior against the patched assemblies, run as
a self-contained published exe via the NUnitLite runner. Six per-defect
regression fixtures cover the if.84 correctness fixes and are red on if.84,
green after. A shared SmokeBase hosts visual trees in an off-screen HwndSource
and forces a WARP render pass so hit-testing, animation clocks, and container
virtualization materialize headlessly. An identity guard diffs the patched
P/Invoke surface against the runner's stock WPF to prove no native calls were
added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@oysteinkrog
oysteinkrog force-pushed the fix/correctness-batch branch from 56dcec0 to d7cdd18 Compare July 17, 2026 11:42
@oysteinkrog
oysteinkrog marked this pull request as ready for review July 17, 2026 13:39
@oysteinkrog
oysteinkrog merged commit 3be457f into if/main Jul 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant