diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b4e1953e809..53ddef3bb45 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -142,13 +142,14 @@ jobs:
# ---------------------------------------------------------------------------
build-wpf:
name: Build WPF (${{ matrix.arch }})
- runs-on: windows-latest
+ runs-on: windows-2022
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
- # arm64 cross-compile runs on windows-latest x64 runner; runs-on already pins to Windows.
+ # arm64 cross-compile runs on the windows-2022 x64 runner (VS2022 / v143
+ # platform toolset, which the native WpfGfx vcxproj files require).
steps:
- uses: actions/checkout@v4
@@ -169,7 +170,7 @@ jobs:
global-json-file: global.json
- name: Install Strawberry Perl (idempotent)
- # windows-latest pre-installs strawberryperl (currently v5.42+).
+ # windows-2022 pre-installs strawberryperl (currently v5.42+).
# Skip choco install if perl is already on PATH; otherwise install.
run: |
if (Get-Command perl -ErrorAction SilentlyContinue) {
@@ -370,23 +371,27 @@ jobs:
retention-days: 7
# ---------------------------------------------------------------------------
- # smoke: 22-scenario smoke harness (needs build-wpf)
+ # smoke: real smoke harness run against the packed nupkg.
+ #
+ # x64 only: the pack steps in build-wpf run under `if: matrix.arch == 'x64'`
+ # (only build-x64 carries a nupkg), and the smoke project is pinned to the
+ # win-x64 RID, so there is no arm64 package to consume.
# ---------------------------------------------------------------------------
smoke:
name: Smoke (${{ matrix.arch }})
- runs-on: windows-latest
+ runs-on: windows-2022
needs: build-wpf
strategy:
fail-fast: false
matrix:
- arch: [x64, arm64]
+ arch: [x64]
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- - name: Download build artifacts
+ - name: Download build artifacts (packed nupkg)
uses: actions/download-artifact@v4
with:
name: build-${{ matrix.arch }}
@@ -397,30 +402,180 @@ jobs:
with:
global-json-file: global.json
- # Placeholder: run 22-scenario smoke harness.
- # TODO (wired by smoke-harness beads): replace with real test invocation.
- - name: Run smoke harness (placeholder)
+ - name: Resolve packed InitialForce.WPF version
+ id: pkgver
run: |
- echo "Smoke harness placeholder — test/InitialForce.WpfSmoke/ wired by other beads."
- echo "Expected command:"
- echo " dotnet test test/InitialForce.WpfSmoke/ -c Release --logger trx --results-directory smoke-results/"
- mkdir -p smoke-results
- echo '' > smoke-results/smoke-placeholder.xml
- shell: bash
+ $pkg = Get-ChildItem artifacts/packages -Recurse -Filter 'InitialForce.WPF.*.nupkg' |
+ Where-Object { $_.Name -match '^InitialForce\.WPF\.\d' } |
+ Select-Object -First 1
+ if (-not $pkg) {
+ Write-Error "No InitialForce.WPF..nupkg under artifacts/packages — build-wpf did not pack."
+ Get-ChildItem artifacts/packages -Recurse | Format-Table FullName
+ exit 1
+ }
+ $ver = $pkg.Name -replace '^InitialForce\.WPF\.', '' -replace '\.nupkg$', ''
+ "version=$ver" | Out-File -Append $env:GITHUB_OUTPUT
+ Write-Host "Resolved InitialForce.WPF version: $ver (from $($pkg.Name))"
+ shell: pwsh
+
+ - name: Run smoke harness against packed nupkg
+ run: |
+ $ver = "${{ steps.pkgver.outputs.version }}"
+ $pkg = Get-ChildItem artifacts/packages -Recurse -Filter 'InitialForce.WPF.*.nupkg' |
+ Where-Object { $_.Name -match '^InitialForce\.WPF\.\d' } |
+ Select-Object -First 1
+ $feed = $pkg.DirectoryName
+ Write-Host "Local packed feed: $feed"
+ # Add the local packed feed and nuget.org onto the repo NuGet.config, which
+ # already carries the pinned dnceng feeds the net10 private/servicing
+ # transitive deps need. Using `dotnet nuget add source` (not stacked
+ # `--source` flags) keeps the feed URL from being path-normalized into a
+ # bogus local path (NU1301).
+ dotnet nuget add source $feed --name local-packed --configfile NuGet.config
+ dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile NuGet.config
+ dotnet restore test/InitialForce.WpfSmoke/InitialForce.WpfSmoke.csproj `
+ -p:InitialForceWpfVersion=$ver
+ if ($LASTEXITCODE -ne 0) { throw "restore failed" }
+ # Publish self-contained win-x64 (the csproj pins the RID + SelfContained),
+ # which lays down a full runtime AND the patched WPF DLLs while stripping the
+ # stock ones — the exact production consumption path, so the patched WPF loads
+ # unambiguously. `dotnet test` cannot run a self-contained testhost from build
+ # output (hostpolicy.dll is not laid down there), so run the NUnitLite console
+ # entry point directly; it emits NUnit3 result XML.
+ dotnet publish test/InitialForce.WpfSmoke/InitialForce.WpfSmoke.csproj `
+ -c Release --no-restore `
+ -p:InitialForceWpfVersion=$ver `
+ -o smoke-publish/
+ if ($LASTEXITCODE -ne 0) { throw "publish failed" }
+ # The WPF Arcade SDK strips NuGet package assemblies from test-project output,
+ # so the NUnit assemblies are absent from the self-contained publish. Copy them
+ # from the restored package cache into the app directory; the self-contained host
+ # probes the application base directory, so they load without a deps.json entry.
+ $gp = ((dotnet nuget locals global-packages --list) -replace '^[^:]*:\s*','').Trim()
+ Write-Host "NuGet global packages: $gp"
+ foreach ($pkgId in 'nunit','nunitlite') {
+ $pkgDir = Join-Path $gp $pkgId
+ if (-not (Test-Path $pkgDir)) { throw "package cache missing '$pkgId' under $gp" }
+ $verDir = Get-ChildItem $pkgDir -Directory | Sort-Object Name -Descending | Select-Object -First 1
+ $libRoot = Join-Path $verDir.FullName 'lib'
+ $tfmDir = @('netstandard2.0','net6.0','net462') |
+ ForEach-Object { Join-Path $libRoot $_ } |
+ Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $tfmDir) { throw "no compatible lib TFM for '$pkgId' under $libRoot" }
+ Get-ChildItem $tfmDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName smoke-publish/ -Force
+ Write-Host " copied $($_.Name) from $pkgId ($($verDir.Name))"
+ }
+ }
+ Write-Host "Publish output nunit assemblies:"
+ Get-ChildItem smoke-publish -Filter 'nunit*' -ErrorAction SilentlyContinue |
+ ForEach-Object { Write-Host " $($_.Name)" }
+ # A self-contained app resolves assemblies strictly from its deps.json TPA and
+ # does not probe the app base directory for files absent from it. The WPF Arcade
+ # SDK strips the NUnit package assemblies, and the InitialForce.WPF targets strip
+ # the patched WPF DLLs, from the generated deps.json (the WPF DLLs are still copied
+ # into the app directory by the package targets, just not registered). Register
+ # every assembly physically present in the publish directory that deps.json does
+ # not already list, so the host resolves them from the app base directory.
+ $depsPath = "smoke-publish/InitialForce.WpfSmoke.deps.json"
+ $deps = Get-Content $depsPath -Raw | ConvertFrom-Json
+ $rt = $deps.runtimeTarget.name
+ $existing = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($lib in $deps.targets.$rt.PSObject.Properties) {
+ $runtime = $lib.Value.runtime
+ if ($null -ne $runtime) {
+ foreach ($r in $runtime.PSObject.Properties) { [void]$existing.Add([System.IO.Path]::GetFileName($r.Name)) }
+ }
+ }
+ $appProp = $deps.targets.$rt.PSObject.Properties |
+ Where-Object { $_.Name -like 'InitialForce.WpfSmoke/*' } | Select-Object -First 1
+ $runtimeObj = $deps.targets.$rt.($appProp.Name).runtime
+ $added = 0
+ foreach ($f in Get-ChildItem smoke-publish -Filter '*.dll') {
+ if (-not $existing.Contains($f.Name)) {
+ $runtimeObj | Add-Member -NotePropertyName $f.Name -NotePropertyValue ([pscustomobject]@{}) -Force
+ [void]$existing.Add($f.Name); $added++
+ Write-Host " deps.json += $($f.Name)"
+ }
+ }
+ Write-Host "Registered $added assemblies in deps.json"
+ ($deps | ConvertTo-Json -Depth 100) | Set-Content $depsPath -Encoding utf8
+ New-Item -ItemType Directory -Force -Path smoke-results | Out-Null
+ & "smoke-publish/InitialForce.WpfSmoke.exe" --result:"smoke-results/smoke.xml"
+ $runExit = $LASTEXITCODE
+ Write-Host "NUnitLite exit code: $runExit"
+ if ($runExit -ne 0) { throw "smoke run reported failures or errors (exit $runExit)" }
+ shell: pwsh
+
+ - name: Enforce non-empty smoke run (NUnit3 floor gate)
+ if: always()
+ run: |
+ $floorFile = "test/InitialForce.WpfSmoke/expected-min-tests.txt"
+ $floor = [int]((Get-Content $floorFile) |
+ Where-Object { $_ -match '^\s*\d+' } | Select-Object -First 1)
+ $xml = Get-ChildItem smoke-results -Recurse -Filter '*.xml' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $xml) {
+ Write-Error "No NUnit3 result XML produced — the smoke harness did not run (the fabricated-empty-results failure mode). Failing hard."
+ exit 1
+ }
+ [xml]$doc = Get-Content $xml.FullName
+ $run = $doc.'test-run'
+ $total = [int]$run.total
+ Write-Host "Smoke results: total=$total passed=$($run.passed) failed=$($run.failed) (floor=$floor)"
+ if ($total -lt $floor) {
+ Write-Error "Smoke run reported $total tests, below the floor of $floor. Refusing to pass a hollow smoke run."
+ exit 1
+ }
+ if ([int]$run.failed -gt 0) {
+ Write-Error "Smoke run reported $($run.failed) failed tests."
+ exit 1
+ }
+ shell: pwsh
- name: Upload smoke results
+ if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-${{ matrix.arch }}.xml
path: smoke-results/
retention-days: 14
+ # ---------------------------------------------------------------------------
+ # anti-stub-lint: ratchet-DOWN gate on placeholder Assert.That(true) stubs.
+ # Source-level grep; runs independently of the Windows build.
+ # ---------------------------------------------------------------------------
+ anti-stub-lint:
+ name: Anti-stub lint (smoke bodies)
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Fail if placeholder stubs exceed the allowlist
+ run: |
+ set -euo pipefail
+ # RATCHET-DOWN ceiling: the count of `Assert.That(true...` placeholder
+ # stubs in the smoke bodies must never exceed MAX_STUBS. Lower it as
+ # scenarios are un-stubbed; never raise it. Remaining stubs today are
+ # FrugalList (2 — internal FrugalList needs a test-friend adapter)
+ # and PixelDiff (4 — needs golden PNGs committed on Windows).
+ MAX_STUBS=6
+ count=$(grep -rn --include='*.cs' 'Assert.That(true' test/InitialForce.WpfSmoke/Smoke/ | wc -l | tr -d ' ')
+ echo "Placeholder stubs found: $count (ceiling: $MAX_STUBS)"
+ grep -rn --include='*.cs' 'Assert.That(true' test/InitialForce.WpfSmoke/Smoke/ || true
+ if [ "$count" -gt "$MAX_STUBS" ]; then
+ echo "::error::$count Assert.That(true) stubs exceed the ceiling of $MAX_STUBS. Un-stub the scenario (move the body out of the /* Full implementation */ block) or lower MAX_STUBS deliberately."
+ exit 1
+ fi
+ shell: bash
+
# ---------------------------------------------------------------------------
# perf: BenchmarkDotNet perf gate (needs build-wpf)
# ---------------------------------------------------------------------------
perf:
name: Perf (${{ matrix.arch }})
- runs-on: windows-latest
+ runs-on: windows-2022
needs: build-wpf
strategy:
fail-fast: false
@@ -467,7 +622,7 @@ jobs:
aggregate:
name: Aggregate results
runs-on: ubuntu-latest
- needs: [lint-tools, lint-yaml, smoke, perf]
+ needs: [lint-tools, lint-yaml, anti-stub-lint, smoke, perf]
if: always()
steps:
@@ -520,16 +675,18 @@ jobs:
run: |
LINT_TOOLS="${{ needs.lint-tools.result }}"
LINT_YAML="${{ needs.lint-yaml.result }}"
+ ANTI_STUB="${{ needs.anti-stub-lint.result }}"
SMOKE="${{ needs.smoke.result }}"
PERF="${{ needs.perf.result }}"
- echo "lint-tools: $LINT_TOOLS"
- echo "lint-yaml: $LINT_YAML"
- echo "smoke: $SMOKE"
- echo "perf: $PERF"
+ echo "lint-tools: $LINT_TOOLS"
+ echo "lint-yaml: $LINT_YAML"
+ echo "anti-stub-lint: $ANTI_STUB"
+ echo "smoke: $SMOKE"
+ echo "perf: $PERF"
FAILED=0
- for result in "$LINT_TOOLS" "$LINT_YAML" "$SMOKE" "$PERF"; do
+ for result in "$LINT_TOOLS" "$LINT_YAML" "$ANTI_STUB" "$SMOKE" "$PERF"; do
if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
FAILED=1
fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f178b02441c..fb5a2d0f914 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,6 +11,11 @@ on:
description: "Tag to release (e.g. if-10.0.4-perf.20260427)"
required: true
type: string
+ regenerate_perf_baseline:
+ description: "Also regenerate perf/baseline.json from this build's package (uploaded as an artifact to commit manually)"
+ required: false
+ default: false
+ type: boolean
concurrency:
group: release-${{ github.ref }}
@@ -102,7 +107,7 @@ jobs:
name: Build and pack NuGet packages
needs: verify-tag
if: needs.verify-tag.result == 'success'
- runs-on: windows-latest
+ runs-on: windows-2022
outputs:
nuget_version: ${{ steps.version.outputs.nuget_version }}
@@ -175,7 +180,7 @@ jobs:
name: Smoke harness against packed NuGet
needs: build
if: needs.build.result == 'success'
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- uses: actions/checkout@v4
@@ -194,21 +199,112 @@ jobs:
name: release-packages-${{ needs.build.outputs.nuget_version }}
path: /tmp/packages/
- - name: Install packages into temp feed and run smoke harness
+ - name: Run smoke harness against packed nupkg
run: |
- # Set up a local NuGet feed pointing at the downloaded packages
+ # Local NuGet feed pointing at the downloaded packages.
$feedDir = "/tmp/local-feed"
New-Item -ItemType Directory -Force -Path $feedDir | Out-Null
Copy-Item /tmp/packages/*.nupkg $feedDir/
- # Run the smoke harness against the consumer experience
- $env:NUGET_VERSION = "${{ needs.build.outputs.nuget_version }}"
- $env:LOCAL_FEED_PATH = $feedDir
- dotnet test test/InitialForce.WpfSmoke/ `
- -c Release `
- --logger trx `
- --results-directory /tmp/smoke-results/ `
- -- NUnit.DefaultTestNamePattern="{m}"
+ $ver = "${{ needs.build.outputs.nuget_version }}"
+ if (-not $ver) { throw "nuget_version output is empty — cannot resolve InitialForceWpfVersion." }
+ Write-Host "Consuming InitialForce.WPF $ver from $feedDir"
+
+ dotnet nuget add source $feedDir --name local-packed --configfile NuGet.config
+ dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile NuGet.config
+ dotnet restore test/InitialForce.WpfSmoke/InitialForce.WpfSmoke.csproj `
+ -p:InitialForceWpfVersion=$ver
+ if ($LASTEXITCODE -ne 0) { throw "restore failed" }
+
+ # Publish self-contained win-x64 (the csproj pins the RID + SelfContained),
+ # which lays down a full runtime AND the patched WPF DLLs while stripping the
+ # stock ones — the exact production consumption path, so the patched WPF loads
+ # unambiguously. `dotnet test` cannot run a self-contained testhost from build
+ # output (hostpolicy.dll is not laid down there), so run the NUnitLite console
+ # entry point directly; it emits NUnit3 result XML.
+ dotnet publish test/InitialForce.WpfSmoke/InitialForce.WpfSmoke.csproj `
+ -c Release --no-restore `
+ -p:InitialForceWpfVersion=$ver `
+ -o /tmp/smoke-publish/
+ if ($LASTEXITCODE -ne 0) { throw "publish failed" }
+ # The WPF Arcade SDK strips NuGet package assemblies from test-project output,
+ # so the NUnit assemblies are absent from the self-contained publish. Copy them
+ # from the restored package cache into the app directory; the self-contained host
+ # probes the application base directory, so they load without a deps.json entry.
+ $gp = ((dotnet nuget locals global-packages --list) -replace '^[^:]*:\s*','').Trim()
+ Write-Host "NuGet global packages: $gp"
+ foreach ($pkgId in 'nunit','nunitlite') {
+ $pkgDir = Join-Path $gp $pkgId
+ if (-not (Test-Path $pkgDir)) { throw "package cache missing '$pkgId' under $gp" }
+ $verDir = Get-ChildItem $pkgDir -Directory | Sort-Object Name -Descending | Select-Object -First 1
+ $libRoot = Join-Path $verDir.FullName 'lib'
+ $tfmDir = @('netstandard2.0','net6.0','net462') |
+ ForEach-Object { Join-Path $libRoot $_ } |
+ Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $tfmDir) { throw "no compatible lib TFM for '$pkgId' under $libRoot" }
+ Get-ChildItem $tfmDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName /tmp/smoke-publish/ -Force
+ Write-Host " copied $($_.Name) from $pkgId ($($verDir.Name))"
+ }
+ }
+ # A self-contained app resolves assemblies strictly from its deps.json TPA and
+ # does not probe the app base directory for files absent from it. The WPF Arcade
+ # SDK strips the NUnit package assemblies, and the InitialForce.WPF targets strip
+ # the patched WPF DLLs, from the generated deps.json (the WPF DLLs are still copied
+ # into the app directory by the package targets, just not registered). Register
+ # every assembly physically present in the publish directory that deps.json does
+ # not already list, so the host resolves them from the app base directory.
+ $depsPath = "/tmp/smoke-publish/InitialForce.WpfSmoke.deps.json"
+ $deps = Get-Content $depsPath -Raw | ConvertFrom-Json
+ $rt = $deps.runtimeTarget.name
+ $existing = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($lib in $deps.targets.$rt.PSObject.Properties) {
+ $runtime = $lib.Value.runtime
+ if ($null -ne $runtime) {
+ foreach ($r in $runtime.PSObject.Properties) { [void]$existing.Add([System.IO.Path]::GetFileName($r.Name)) }
+ }
+ }
+ $appProp = $deps.targets.$rt.PSObject.Properties |
+ Where-Object { $_.Name -like 'InitialForce.WpfSmoke/*' } | Select-Object -First 1
+ $runtimeObj = $deps.targets.$rt.($appProp.Name).runtime
+ $added = 0
+ foreach ($f in Get-ChildItem /tmp/smoke-publish -Filter '*.dll') {
+ if (-not $existing.Contains($f.Name)) {
+ $runtimeObj | Add-Member -NotePropertyName $f.Name -NotePropertyValue ([pscustomobject]@{}) -Force
+ [void]$existing.Add($f.Name); $added++
+ Write-Host " deps.json += $($f.Name)"
+ }
+ }
+ Write-Host "Registered $added assemblies in deps.json"
+ ($deps | ConvertTo-Json -Depth 100) | Set-Content $depsPath -Encoding utf8
+ New-Item -ItemType Directory -Force -Path /tmp/smoke-results | Out-Null
+ & "/tmp/smoke-publish/InitialForce.WpfSmoke.exe" --result:"/tmp/smoke-results/smoke.xml"
+ $runExit = $LASTEXITCODE
+ Write-Host "NUnitLite exit code: $runExit"
+ if ($runExit -ne 0) { throw "smoke run reported failures or errors (exit $runExit)" }
+ shell: pwsh
+
+ - name: Enforce non-empty smoke run (NUnit3 floor gate)
+ if: always()
+ run: |
+ $floorFile = "test/InitialForce.WpfSmoke/expected-min-tests.txt"
+ $floor = [int]((Get-Content $floorFile) |
+ Where-Object { $_ -match '^\s*\d+' } | Select-Object -First 1)
+ $xml = Get-ChildItem /tmp/smoke-results -Recurse -Filter '*.xml' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $xml) { Write-Error "No NUnit3 result XML produced — smoke harness did not run."; exit 1 }
+ [xml]$doc = Get-Content $xml.FullName
+ $run = $doc.'test-run'
+ $total = [int]$run.total
+ Write-Host "Smoke results total=$total passed=$($run.passed) failed=$($run.failed) (floor=$floor)"
+ if ($total -lt $floor) {
+ Write-Error "Smoke run reported $total tests, below the floor of $floor."
+ exit 1
+ }
+ if ([int]$run.failed -gt 0) {
+ Write-Error "Smoke run reported $($run.failed) failed tests."
+ exit 1
+ }
shell: pwsh
- name: Upload smoke results
@@ -226,7 +322,7 @@ jobs:
name: Perf regression check against packed NuGet
needs: build
if: needs.build.result == 'success'
- runs-on: windows-latest
+ runs-on: windows-2022
steps:
- uses: actions/checkout@v4
@@ -250,16 +346,41 @@ jobs:
name: release-packages-${{ needs.build.outputs.nuget_version }}
path: /tmp/packages/
- - name: Run perf harness
+ - name: Run BenchmarkDotNet perf harness against packed nupkg
run: |
- $env:NUGET_VERSION = "${{ needs.build.outputs.nuget_version }}"
- dotnet test test/InitialForce.WpfSmoke/ `
- -c Release `
- --filter "Category=Perf" `
- -- NUnit.DefaultTestNamePattern="{m}"
+ $feedDir = "/tmp/local-feed"
+ New-Item -ItemType Directory -Force -Path $feedDir | Out-Null
+ Copy-Item /tmp/packages/*.nupkg $feedDir/
+
+ $ver = "${{ needs.build.outputs.nuget_version }}"
+ if (-not $ver) { throw "nuget_version output is empty — cannot resolve InitialForceWpfVersion." }
+ $proj = "test/InitialForce.WpfSmoke/Perf/InitialForce.WpfPerf.csproj"
+
+ dotnet nuget add source $feedDir --name local-packed --configfile NuGet.config
+ dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile NuGet.config
+ dotnet restore $proj `
+ -p:InitialForceWpfVersion=$ver
+ if ($LASTEXITCODE -ne 0) { throw "restore failed" }
+
+ # Standalone BenchmarkDotNet console harness (NOT the NUnit stubs).
+ dotnet run -c Release --no-restore --project $proj `
+ -p:InitialForceWpfVersion=$ver `
+ -- --filter '*' --exporters json --artifacts /tmp/bdn-artifacts
+ if ($LASTEXITCODE -ne 0) { throw "BenchmarkDotNet run failed" }
+
+ $json = Get-ChildItem /tmp/bdn-artifacts -Recurse -Filter '*-report-full-compressed.json' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $json) {
+ $json = Get-ChildItem /tmp/bdn-artifacts -Recurse -Filter '*.json' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ }
+ if (-not $json) { throw "No BenchmarkDotNet JSON export produced under /tmp/bdn-artifacts." }
+ New-Item -ItemType Directory -Force -Path perf | Out-Null
+ Copy-Item $json.FullName perf/current.json -Force
+ Write-Host "Wrote perf/current.json from $($json.FullName)"
shell: pwsh
- - name: Check perf regression
+ - name: Check perf regression (hard on Allocated, warn-only on time)
run: |
python tools/check-regression.py `
--current perf/current.json `
@@ -267,7 +388,8 @@ jobs:
--current-sha ${{ github.sha }} `
--output /tmp/perf-result.json `
--threshold-warn 5 `
- --threshold-fail 15
+ --threshold-fail 15 `
+ --warn-only-metrics Mean
shell: pwsh
- name: Upload perf results
@@ -278,6 +400,81 @@ jobs:
path: /tmp/perf-result.json
retention-days: 14
+ # ---------------------------------------------------------------------------
+ # Job 5b: regenerate-perf-baseline — operator-driven refresh of the checked-in
+ # perf/baseline.json from this build's package. Runs only when dispatched with
+ # regenerate_perf_baseline=true. The refreshed baseline is uploaded as an
+ # artifact (perf-baseline-) for a human to review and commit; CI does
+ # not push to the repo. The seeded perf/baseline.json is a hand-authored
+ # placeholder until this job replaces it with real measurements.
+ # ---------------------------------------------------------------------------
+ regenerate-perf-baseline:
+ name: Regenerate perf baseline (manual)
+ needs: build
+ if: >-
+ github.event_name == 'workflow_dispatch' &&
+ inputs.regenerate_perf_baseline &&
+ needs.build.result == 'success'
+ runs-on: windows-2022
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Download packed NuGet artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: release-packages-${{ needs.build.outputs.nuget_version }}
+ path: /tmp/packages/
+
+ - name: Run BenchmarkDotNet and capture baseline
+ run: |
+ $feedDir = "/tmp/local-feed"
+ New-Item -ItemType Directory -Force -Path $feedDir | Out-Null
+ Copy-Item /tmp/packages/*.nupkg $feedDir/
+
+ $ver = "${{ needs.build.outputs.nuget_version }}"
+ if (-not $ver) { throw "nuget_version output is empty." }
+ $proj = "test/InitialForce.WpfSmoke/Perf/InitialForce.WpfPerf.csproj"
+
+ dotnet nuget add source $feedDir --name local-packed --configfile NuGet.config
+ dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile NuGet.config
+ dotnet restore $proj `
+ -p:InitialForceWpfVersion=$ver
+ if ($LASTEXITCODE -ne 0) { throw "restore failed" }
+
+ dotnet run -c Release --no-restore --project $proj `
+ -p:InitialForceWpfVersion=$ver `
+ -- --filter '*' --exporters json --artifacts /tmp/bdn-artifacts
+ if ($LASTEXITCODE -ne 0) { throw "BenchmarkDotNet run failed" }
+
+ $json = Get-ChildItem /tmp/bdn-artifacts -Recurse -Filter '*-report-full-compressed.json' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if (-not $json) {
+ $json = Get-ChildItem /tmp/bdn-artifacts -Recurse -Filter '*.json' -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ }
+ if (-not $json) { throw "No BenchmarkDotNet JSON export produced." }
+ New-Item -ItemType Directory -Force -Path perf | Out-Null
+ Copy-Item $json.FullName perf/baseline.json -Force
+ Write-Host "Regenerated perf/baseline.json from $($json.FullName)"
+ Write-Host "Review and commit this file to update the checked-in baseline."
+ shell: pwsh
+
+ - name: Upload regenerated baseline
+ uses: actions/upload-artifact@v4
+ with:
+ name: perf-baseline-${{ needs.build.outputs.nuget_version }}
+ path: perf/baseline.json
+ retention-days: 30
+
# ---------------------------------------------------------------------------
# Job 6: release-notes — run Claude to draft GitHub Release notes from ledger
# ---------------------------------------------------------------------------
diff --git a/perf/baseline.json b/perf/baseline.json
new file mode 100644
index 00000000000..7d93efc0b85
--- /dev/null
+++ b/perf/baseline.json
@@ -0,0 +1,55 @@
+{
+ "_todo": "SEED BASELINE — NOT MEASURED ON REAL HARDWARE. These numbers are hand-authored placeholders so tools/check-regression.py has a valid schema to gate against. Regenerate on windows-latest from the last-good package via the release.yml 'regenerate-perf-baseline' job (workflow_dispatch with regenerate_perf_baseline=true), then commit the produced perf/baseline.json. The Allocated (BytesAllocatedPerOperation) numbers are the hard gate; Mean is warn-only.",
+ "Title": "BenchmarkRun_InitialForce_WPF_Baseline_Seed",
+ "HostEnvironmentInfo": {
+ "BenchmarkDotNetVersion": "0.14.0",
+ "OsVersion": "Windows",
+ "ProcessorName": "seed-placeholder",
+ "RuntimeVersion": ".NET 10.0",
+ "Architecture": "X64"
+ },
+ "Benchmarks": [
+ {
+ "FullName": "InitialForce.WpfSmoke.GeometryParserBench.ReadNumberBench",
+ "Namespace": "InitialForce.WpfSmoke",
+ "Type": "GeometryParserBench",
+ "Method": "ReadNumberBench",
+ "Parameters": "",
+ "Statistics": {
+ "N": 10,
+ "Mean": 45500.0,
+ "Median": 45500.0,
+ "StandardError": 115.0,
+ "StandardDeviation": 363.8
+ },
+ "Memory": {
+ "Gen0Collections": 0,
+ "Gen1Collections": 0,
+ "Gen2Collections": 0,
+ "TotalOperations": 10000,
+ "BytesAllocatedPerOperation": 0
+ }
+ },
+ {
+ "FullName": "InitialForce.WpfSmoke.ImageLoadingBench.JpegDecode100",
+ "Namespace": "InitialForce.WpfSmoke",
+ "Type": "ImageLoadingBench",
+ "Method": "JpegDecode100",
+ "Parameters": "",
+ "Statistics": {
+ "N": 10,
+ "Mean": 4250000.0,
+ "Median": 4250000.0,
+ "StandardError": 14000.0,
+ "StandardDeviation": 44272.0
+ },
+ "Memory": {
+ "Gen0Collections": 2,
+ "Gen1Collections": 0,
+ "Gen2Collections": 0,
+ "TotalOperations": 100,
+ "BytesAllocatedPerOperation": 8192
+ }
+ }
+ ]
+}
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ByteStreamGeometryContext.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ByteStreamGeometryContext.cs
index 5ceb1140bf8..e2573a8562f 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ByteStreamGeometryContext.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ByteStreamGeometryContext.cs
@@ -913,6 +913,9 @@ private static void ReturnChunkToPool(byte[] chunk)
#region Fields
+ /// Whether this context has been closed or disposed.
+ protected bool IsDisposed => _disposed;
+
private bool _disposed;
private int _currChunkOffset;
private FrugalStructList _chunkList;
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ParsersCommon.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ParsersCommon.cs
index 507e7d70f31..09df1f4c25a 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ParsersCommon.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/ParsersCommon.cs
@@ -39,9 +39,9 @@ internal static object DeserializeStreamGeometry( BinaryReader reader )
{
StreamGeometry geometry = new StreamGeometry();
- using (StreamGeometryContext context = geometry.Open())
+ using (StreamGeometryContext context = geometry.OpenPooled())
{
- ParserStreamGeometryContext.Deserialize( reader, context, geometry );
+ ParserStreamGeometryContext.Deserialize( reader, context, geometry );
}
geometry.Freeze();
@@ -77,7 +77,7 @@ internal static Geometry ParseGeometry(
{
FillRule fillRule = FillRule.EvenOdd ;
StreamGeometry geometry = new StreamGeometry();
- StreamGeometryContext context = geometry.Open();
+ StreamGeometryContext context = geometry.OpenPooled();
ParseStringToStreamGeometryContext( context, pathString, formatProvider , ref fillRule ) ;
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/StreamGeometry.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/StreamGeometry.cs
index 9333943ce96..c3679c940ae 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/StreamGeometry.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/StreamGeometry.cs
@@ -35,6 +35,24 @@ public StreamGeometryContext Open()
{
WritePreamble();
+ // Public callers may retain the returned context and Dispose it
+ // again later; IDisposable requires that to be a no-op. A fresh,
+ // never-pooled instance guarantees a stale reference only ever sees
+ // its own dead context, never a pooled instance a later Open() has
+ // revived.
+ return new StreamGeometryCallbackContext(this);
+ }
+
+ ///
+ /// Opens the StreamGeometry for population using the [ThreadStatic]
+ /// pooled callback context. Reserved for internal call sites whose
+ /// context lifetime is strictly scoped (using/finally in the same frame,
+ /// reference never retained) — e.g. the Geometry.Parse path.
+ ///
+ internal StreamGeometryContext OpenPooled()
+ {
+ WritePreamble();
+
return StreamGeometryCallbackContext.Acquire(this);
}
@@ -559,7 +577,7 @@ internal static StreamGeometryCallbackContext Acquire(StreamGeometry owner)
StreamGeometryCallbackContext ctx = _pooled;
if (ctx is null)
{
- return new StreamGeometryCallbackContext(owner);
+ return new StreamGeometryCallbackContext(owner, poolable: true);
}
_pooled = null;
@@ -572,8 +590,14 @@ internal static StreamGeometryCallbackContext Acquire(StreamGeometry owner)
/// Creates a geometry stream context which is associated with a given owner
///
internal StreamGeometryCallbackContext(StreamGeometry owner)
+ : this(owner, poolable: false)
+ {
+ }
+
+ private StreamGeometryCallbackContext(StreamGeometry owner, bool poolable)
{
_owner = owner;
+ _poolable = poolable;
}
///
@@ -587,8 +611,28 @@ protected override void CloseCore(byte[] data)
internal override void DisposeCore()
{
+ // Repeated Dispose on an already-closed context is a no-op, as it
+ // is for a fresh (unpooled) instance where only the _disposed-guarded
+ // base body could run. This guard covers only the parked window: a
+ // later Acquire runs ResetForReuse which clears _disposed, so it does
+ // NOT protect a revived instance. Safety across the revived window
+ // rests on confining OpenPooled to strictly-scoped internal call sites
+ // that never retain the context reference past their using scope.
+ if (IsDisposed)
+ {
+ return;
+ }
+
base.DisposeCore();
+ // Only pool-eligible instances (created via Acquire for the internal
+ // strictly-scoped call sites) return to the pool; instances handed to
+ // public Open() callers die with their owner scope.
+ if (!_poolable)
+ {
+ return;
+ }
+
// After base.DisposeCore, _chunkList[0] points at the FINAL byte[]
// now owned by the StreamGeometry. Drop that reference and the
// owner ref before returning the instance to the [ThreadStatic]
@@ -607,6 +651,7 @@ internal override void DisposeCore()
}
private StreamGeometry _owner;
+ private readonly bool _poolable;
}
#endregion StreamGeometryCallbackContext
}
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/UIElement.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/UIElement.cs
index ed65bf21306..ec90f6d9784 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/UIElement.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/UIElement.cs
@@ -2023,36 +2023,33 @@ internal void InputHitTest(Point pt, out IInputElement enabledHit, out IInputEle
///
internal void InputHitTest(Point pt, out IInputElement enabledHit, out IInputElement rawHit, out HitTestResult rawHitResult)
{
- // Acquire pooled hit-test infrastructure ([ThreadStatic] single-slot
- // pool keyed by the UI thread). The result instance and its bound
- // HitTestResultCallback are paired in the pool — the callback's
- // delegate target IS the result instance, so reuse keeps them
- // consistent. The filter callback is stateless (no `this` capture
- // in its body) and is cached in a static delegate field. The
- // PointHitTestParameters wrapper is mutated via SetHitPoint on
- // each acquire. Combined, this eliminates 4 heap allocations per
- // InputHitTest call (PointHitTestParameters, InputHitTestResult,
- // and two delegates).
- PointHitTestParameters hitTestParameters = _pooledHitTestParameters;
- if (hitTestParameters is null)
- {
- hitTestParameters = new PointHitTestParameters(pt);
- _pooledHitTestParameters = hitTestParameters;
- }
- else
- {
- hitTestParameters.SetHitPoint(pt);
- }
+ // Allocate the PointHitTestParameters fresh per call. It is handed to
+ // every visual's overridable HitTestCore during the walk, so pooling it
+ // would let a user override that retains the reference observe a later
+ // call's SetHitPoint mutation, and would let a re-entrant walk clobber
+ // the outer walk's hit point. Stock WPF allocates fresh here. Only the
+ // InputHitTestResult and its callback pair — internal machinery that
+ // never escapes to user code — stay pooled ([ThreadStatic] single slot,
+ // emptied during use so a re-entrant InputHitTest allocates its own).
+ PointHitTestParameters hitTestParameters = new PointHitTestParameters(pt);
InputHitTestResult result = InputHitTestResult.Acquire(out HitTestResultCallback resultCallback);
- VisualTreeHelper.HitTest(this,
- s_inputHitTestFilterCallback,
- resultCallback,
- hitTestParameters);
+ DependencyObject candidate;
+ HitTestResult capturedHitTestResult;
+ try
+ {
+ VisualTreeHelper.HitTest(this,
+ s_inputHitTestFilterCallback,
+ resultCallback,
+ hitTestParameters);
- DependencyObject candidate = result.Result;
- HitTestResult capturedHitTestResult = result.HitTestResult;
- result.Release(resultCallback);
+ candidate = result.Result;
+ capturedHitTestResult = result.HitTestResult;
+ }
+ finally
+ {
+ result.Release(resultCallback);
+ }
rawHit = candidate as IInputElement;
rawHitResult = capturedHitTestResult;
@@ -2132,14 +2129,6 @@ internal void InputHitTest(Point pt, out IInputElement enabledHit, out IInputEle
private static readonly HitTestFilterCallback s_inputHitTestFilterCallback
= new HitTestFilterCallback(InputHitTestFilterCallback);
- // Per-thread reusable PointHitTestParameters wrapper. SetHitPoint
- // mutates the inner Point before each VisualTreeHelper.HitTest call,
- // letting all InputHitTest invocations on this thread share one
- // wrapper object. The UI thread does ~all hit-testing, so a
- // [ThreadStatic] single-slot pool is sufficient.
- [ThreadStatic]
- private static PointHitTestParameters _pooledHitTestParameters;
-
private static HitTestFilterBehavior InputHitTestFilterCallback(DependencyObject currentNode)
{
HitTestFilterBehavior behavior = HitTestFilterBehavior.Continue;
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Documents/AdornerLayer.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Documents/AdornerLayer.cs
index ffe5a0e9d31..a24f56f8e87 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Documents/AdornerLayer.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Documents/AdornerLayer.cs
@@ -631,13 +631,10 @@ internal static void InvalidateAdorner(AdornerInfo adornerInfo)
///
internal void OnLayoutUpdated(object sender, EventArgs args)
{
- // Empty AdornerLayer fast path: skip the per-pass walk entirely when
- // no user adorners are attached. Without this, the default AdornerLayer
- // on every WPF window subscribes to LayoutUpdated unconditionally and
- // calls UpdateAdorner→TransformToAncestor→InvalidateMeasure on every
- // pass, which schedules a new render via NeedsRecalc→PostRender,
- // amplifying any forever-animation by ~17× (e.g. a perpetual busy
- // spinner produces ~570 renders/sec instead of ~32).
+ // Skip the per-pass walk entirely when no adorners are attached; the
+ // walk would be a no-op with an empty ElementMap. The measured
+ // per-pass cost reduction lives in UpdateElementAdorners via
+ // TryTransformToAncestorAsMatrix, not here.
if (ElementMap.Count == 0)
{
return;
diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Window.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Window.cs
index 1a32fd7fcd1..61383768161 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Window.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Window.cs
@@ -7806,8 +7806,12 @@ internal static HwndStyleManager StartManaging(Window w, int Style, int StyleEx
{
w._Style = Style;
w._StyleEx = StyleEx;
- m.Dirty = false;
}
+ // Activation always begins clean on both branches. A parked
+ // instance may carry Dirty from a prior cycle whose final
+ // Flush was skipped (Handle was IntPtr.Zero), so the reset
+ // cannot be conditional on the source-window branch.
+ m.Dirty = false;
m._refCount = 1;
return m;
}
diff --git a/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/Dispatcher.cs b/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/Dispatcher.cs
index f5affa5efca..e540052246c 100644
--- a/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/Dispatcher.cs
+++ b/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/Dispatcher.cs
@@ -580,29 +580,21 @@ public void Invoke(Action callback, DispatcherPriority priority, CancellationTok
try
{
- // priority is statically Send inside this guard. Use the per-Dispatcher cached
- // SyncCtx + cached compat bools (captured at ctor time) to skip the per-call
- // BaseCompatibilityPreferences Get*() static method calls AND the per-call
- // DispatcherSynchronizationContext allocation under the .NET Core defaults
- // (reuseInstance=false, flowPriority=true). Mirrors the LegacyInvokeImpl
- // pattern at the same call site (Send + same-thread + cached compat bools).
DispatcherSynchronizationContext newSynchronizationContext;
- if(_reuseDispatcherSyncCtxInstance)
+ if(BaseCompatibilityPreferences.GetReuseDispatcherSynchronizationContextInstance())
{
newSynchronizationContext = _defaultDispatcherSynchronizationContext;
}
- else if(_flowDispatcherSyncCtxPriority)
- {
- // .NET Core default: flow Send priority. Reuse the cached Send-priority
- // instance instead of allocating a fresh one per call.
- newSynchronizationContext = _sendDispatcherSynchronizationContext;
- }
else
{
- // Rare opt-out: reuseInstance=false && flow=false. Preserve the original
- // per-call Normal-priority alloc so callers that key off reference identity
- // in this config continue to see a unique instance.
- newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ if(BaseCompatibilityPreferences.GetFlowDispatcherSynchronizationContextPriority())
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, priority);
+ }
+ else
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ }
}
SynchronizationContext.SetSynchronizationContext(newSynchronizationContext);
@@ -616,15 +608,7 @@ public void Invoke(Action callback, DispatcherPriority priority, CancellationTok
}
// Slow-Path: go through the queue.
- // internalSyncInvoke:true — the op is constructed locally here, waited
- // on synchronously, and goes out of scope when this method returns
- // (Invoke returns void; neither the op nor its Task is exposed to user
- // code). This lets the op's TaskSource skip the per-op
- // `new DispatcherOperationTaskMapping(this)` heap allocation that the
- // default Initialize path would otherwise attach as Task.AsyncState
- // (~24 B/op). See DispatcherOperation's internal-sync ctor for the
- // safety argument.
- DispatcherOperation operation = new DispatcherOperation(this, priority, callback, internalSyncInvoke: true);
+ DispatcherOperation operation = new DispatcherOperation(this, priority, callback);
InvokeImpl(operation, cancellationToken, timeout);
}
@@ -738,22 +722,21 @@ public TResult Invoke(Func callback, DispatcherPriority priori
try
{
- // priority is statically Send inside this guard. Mirror the Action-overload's
- // cached-SyncCtx + cached-compat-bools pattern to skip the per-call DSC alloc
- // under the .NET Core defaults (reuseInstance=false, flowPriority=true).
DispatcherSynchronizationContext newSynchronizationContext;
- if(_reuseDispatcherSyncCtxInstance)
+ if(BaseCompatibilityPreferences.GetReuseDispatcherSynchronizationContextInstance())
{
newSynchronizationContext = _defaultDispatcherSynchronizationContext;
}
- else if(_flowDispatcherSyncCtxPriority)
- {
- newSynchronizationContext = _sendDispatcherSynchronizationContext;
- }
else
{
- // Rare opt-out: preserve the per-call Normal-priority alloc semantics.
- newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ if(BaseCompatibilityPreferences.GetFlowDispatcherSynchronizationContextPriority())
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, priority);
+ }
+ else
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ }
}
SynchronizationContext.SetSynchronizationContext(newSynchronizationContext);
@@ -1303,31 +1286,21 @@ internal object LegacyInvokeImpl(DispatcherPriority priority, TimeSpan timeout,
try
{
- // priority is statically Send inside this guard. Use the per-Dispatcher cached
- // SyncCtx + cached compat bools (captured at ctor time) to skip the per-call
- // BaseCompatibilityPreferences Get*() calls AND the per-call
- // DispatcherSynchronizationContext allocation under the .NET Core defaults
- // (reuseInstance=false, flowPriority=true). This is the call site that
- // HwndSubclass.SubclassWndProc -> dispatcher.Invoke(Send, callback, param) hits
- // on every Win32 message dispatch on the UI thread.
DispatcherSynchronizationContext newSynchronizationContext;
- if(_reuseDispatcherSyncCtxInstance)
+ if(BaseCompatibilityPreferences.GetReuseDispatcherSynchronizationContextInstance())
{
newSynchronizationContext = _defaultDispatcherSynchronizationContext;
}
- else if(_flowDispatcherSyncCtxPriority)
- {
- // .NET Core default: flow Send priority. Reuse the cached Send-priority
- // instance instead of allocating a fresh one per call. The cache is per-
- // Dispatcher so cross-Dispatcher (cross-thread) instances stay distinct.
- newSynchronizationContext = _sendDispatcherSynchronizationContext;
- }
else
{
- // Rare opt-out: reuseInstance=false && flow=false. Preserve the original
- // per-call Normal-priority alloc so callers that key off reference identity
- // in this config continue to see a unique instance.
- newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ if(BaseCompatibilityPreferences.GetFlowDispatcherSynchronizationContextPriority())
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, priority);
+ }
+ else
+ {
+ newSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Normal);
+ }
}
SynchronizationContext.SetSynchronizationContext(newSynchronizationContext);
@@ -1767,32 +1740,6 @@ private Dispatcher()
_defaultDispatcherSynchronizationContext = new DispatcherSynchronizationContext(this);
- // Per-Dispatcher cache for the Send-priority same-thread fast path in LegacyInvokeImpl.
- // BaseCompatibilityPreferences seals these values on first read; capturing them at
- // ctor time means LegacyInvokeImpl's Send fast path can avoid two static method calls
- // (each Get*() does Seal+volatile-read) AND the per-call DispatcherSynchronizationContext
- // allocation under the .NET Core defaults (reuseInstance=false, flowPriority=true).
- // The cache is per-Dispatcher (per-thread), so cross-thread instances remain distinct,
- // preserving the per-thread reference-inequality semantics that motivated the original
- // per-call alloc.
- _reuseDispatcherSyncCtxInstance = BaseCompatibilityPreferences.GetReuseDispatcherSynchronizationContextInstance();
- _flowDispatcherSyncCtxPriority = BaseCompatibilityPreferences.GetFlowDispatcherSynchronizationContextPriority();
- _sendDispatcherSynchronizationContext = new DispatcherSynchronizationContext(this, DispatcherPriority.Send);
-
- // Per-priority DSC cache for the variable-priority InvokeImpl path (DispatcherOperation.InvokeImpl
- // gets _priority from the queued op, so unlike Invoke's Send fast path it can't reuse the Send
- // singleton). Sized for the DispatcherPriority enum's valid range [Inactive=0 .. Send=10]; slots
- // are filled lazily on first use by GetOrCreatePrioritySyncContext. Pre-populate the Normal and
- // Send slots with the already-constructed cached instances so the two most common priorities
- // (Normal = queued ops, Send = same-thread synchronous Invoke) skip even the lazy-fill branch.
- // The cache is per-Dispatcher (per-thread), so cross-thread DSC instances remain distinct,
- // preserving the per-thread reference-inequality semantics that motivated the .NET 4.5 switch
- // away from the WPF 4.0 shared-singleton design (cross-thread EC flow still uses CreateCopy(),
- // which is unchanged and continues to allocate fresh DSCs at the EC.Capture / EC.SetEC handoff).
- _priorityDispatcherSyncContexts = new DispatcherSynchronizationContext[11];
- _priorityDispatcherSyncContexts[(int)DispatcherPriority.Normal] = _defaultDispatcherSynchronizationContext;
- _priorityDispatcherSyncContexts[(int)DispatcherPriority.Send] = _sendDispatcherSynchronizationContext;
-
// Create the message-only window we use to receive messages
// that tell us to process the queue.
_window = new MessageOnlyHwndWrapper();
@@ -2132,27 +2079,8 @@ private void PushFrameImpl(DispatcherFrame frame)
try
{
// Change the CLR SynchronizationContext to be compatable with our Dispatcher.
- // Reuse the per-Dispatcher cached default-priority DispatcherSynchronizationContext
- // (created once at ctor, line 1743) instead of allocating a fresh
- // `new DispatcherSynchronizationContext(this)` every frame push. The two are
- // semantically identical — both wrap `this` with DispatcherPriority.Normal and
- // are DSC instances whose state (`_dispatcher`, `_priority`) is set in the ctor
- // and never mutated afterwards. SetSynchronizationContext + the finally restore
- // are unaffected: the cached DSC is used identically to a fresh one for the
- // duration of the frame, then the old SyncCtx is restored on exit. Nested
- // PushFrame calls already worked correctly when both outer and inner allocated
- // fresh DSCs, and they continue to work when both share the cached instance
- // (the inner frame's `oldSyncContext` captures the cached DSC the outer set,
- // and on inner-frame exit SetSynchronizationContext is called with the same
- // cached DSC — a no-op write, balanced by the outer-frame finally restoring
- // the pre-pump SyncCtx). Eliminates one DSC heap allocation (~32 B) per
- // Dispatcher.PushFrame call — the modal pump path inside Window.ShowDialog
- // is the dominant per-iter target on the WindowLifecycle WindowShowDialog
- // benchmark, and every Application.Run / Dispatcher.Run startup also benefits
- // (one-time saving at thread/dispatcher start, but the structural cleanup
- // applies to every PushFrame caller).
oldSyncContext = SynchronizationContext.Current;
- newSyncContext = _defaultDispatcherSynchronizationContext;
+ newSyncContext = new DispatcherSynchronizationContext(this);
SynchronizationContext.SetSynchronizationContext(newSyncContext);
try
@@ -2873,47 +2801,6 @@ internal object WrappedInvoke(Delegate callback, object args, int numArgs, Deleg
return _exceptionWrapper.TryCatchWhen(this, callback, args, numArgs, catchHandler);
}
- // Per-priority DSC cache lookup used by DispatcherOperation.InvokeImpl (variable priority comes
- // from the queued op's _priority field). Hot path: array load + slot read + null-check on the
- // already-populated slot — three memory references that the JIT folds into the caller. The
- // first-touch fill of an unused priority goes through the outlined slow path so it doesn't
- // bloat InvokeImpl's epilogue. The array is sized 11 for DispatcherPriority [Inactive=0..Send=10]
- // and was allocated + Normal/Send pre-filled in the ctor; ValidatePriority gates the public APIs
- // so the (uint)idx bounds check is defensive only.
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- internal DispatcherSynchronizationContext GetOrCreatePrioritySyncContext(DispatcherPriority priority)
- {
- DispatcherSynchronizationContext[] arr = _priorityDispatcherSyncContexts;
- int idx = (int)priority;
- if ((uint)idx < (uint)arr.Length)
- {
- DispatcherSynchronizationContext dsc = arr[idx];
- if (dsc != null)
- {
- return dsc;
- }
- }
- return GetOrCreatePrioritySyncContextSlow(priority);
- }
-
- [MethodImpl(MethodImplOptions.NoInlining)]
- private DispatcherSynchronizationContext GetOrCreatePrioritySyncContextSlow(DispatcherPriority priority)
- {
- int idx = (int)priority;
- DispatcherSynchronizationContext[] arr = _priorityDispatcherSyncContexts;
- // Defensive: ValidatePriority should have rejected out-of-range priorities upstream, but
- // if a caller somehow bypasses validation (or the enum is extended), fall back to a fresh
- // per-call DSC instead of crashing. This is the same allocation behavior we replaced, so
- // the fallback is strictly no worse than the pre-cache code.
- if ((uint)idx >= (uint)arr.Length)
- {
- return new DispatcherSynchronizationContext(this, priority);
- }
- DispatcherSynchronizationContext dsc = new DispatcherSynchronizationContext(this, priority);
- arr[idx] = dsc;
- return dsc;
- }
-
private object[] CombineParameters(object arg, object[] args)
{
object[] parameters = new object[1 + (args == null ? 1 : args.Length)];
@@ -2988,30 +2875,6 @@ private object[] CombineParameters(object arg, object[] args)
internal DispatcherSynchronizationContext _defaultDispatcherSynchronizationContext;
- // Per-Dispatcher cached Send-priority SyncCtx, reused by LegacyInvokeImpl's same-thread
- // Send-priority fast path under the .NET Core defaults (reuseInstance=false, flowPriority=true).
- // Constructed once in the ctor with (this, DispatcherPriority.Send) so the
- // HwndSubclass.SubclassWndProc -> dispatcher.Invoke(Send, callback, param) hot path
- // does not allocate a fresh DispatcherSynchronizationContext per Win32 message dispatch.
- private DispatcherSynchronizationContext _sendDispatcherSynchronizationContext;
-
- // Per-priority DSC cache for DispatcherOperation.InvokeImpl. Indexed by (int)DispatcherPriority
- // in the [Inactive=0..Send=10] range. Allocated in the ctor at size 11; Normal and Send slots
- // pre-populated with the already-cached singletons; other slots lazy-filled by
- // GetOrCreatePrioritySyncContext on first use. The cache eliminates the per-op
- // `new DispatcherSynchronizationContext(_dispatcher, _priority)` allocation that
- // InvokeImpl was paying on every queued op under the .NET Core defaults
- // (reuseInstance=false, flowPriority=true) — that's a ~32 B heap alloc on every
- // dispatcher pump iteration. Same per-thread safety story as the other cached fields:
- // cross-thread DSC instances stay distinct because EC flow goes through CreateCopy().
- private DispatcherSynchronizationContext[] _priorityDispatcherSyncContexts;
-
- // Cached compat-pref values, captured once in the ctor (BaseCompatibilityPreferences seals
- // these on first read anyway). Lets the LegacyInvokeImpl fast path skip per-call
- // BaseCompatibilityPreferences.Get*() static method-call frames + their volatile reads.
- private bool _reuseDispatcherSyncCtxInstance;
- private bool _flowDispatcherSyncCtxPriority;
-
internal object _instanceLock = new object(); // Also used by DispatcherOperation
private PriorityQueue _queue;
private List _timers = new List();
diff --git a/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/DispatcherOperation.cs b/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/DispatcherOperation.cs
index a03aeab7e7d..7e9f8d7cbe3 100644
--- a/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/DispatcherOperation.cs
+++ b/src/Microsoft.DotNet.Wpf/src/WindowsBase/System/Windows/Threading/DispatcherOperation.cs
@@ -28,45 +28,6 @@ internal DispatcherOperation(
int numArgs,
DispatcherOperationTaskSource taskSource,
bool useAsyncSemantics)
- : this(dispatcher, method, priority, args, numArgs, taskSource, useAsyncSemantics, skipTaskAsyncStateMapping: false)
- {
- }
-
- // Inner ctor — the `skipTaskAsyncStateMapping` switch lets the synchronous
- // Dispatcher.Invoke(Action,...) slow path opt out of the per-op
- // `new DispatcherOperationTaskMapping(this)` allocation (~24 B/op) that
- // every DispatcherOperation otherwise pays inside `_taskSource.Initialize(this)`.
- //
- // The Mapping object exists solely as the Task.AsyncState discriminator for
- // the public TaskExtensions API (`IsDispatcherOperationTask` / `DispatcherOperationWait`)
- // — see DispatcherOperationTaskMapping.cs and System.Windows.Presentation/TaskExtensions.cs.
- // On the sync void-Invoke slow path the caller is `Dispatcher.Invoke(Action,...)`,
- // which returns `void`: the DispatcherOperation is constructed locally inside
- // Invoke, waited on via op.Wait (which routes through the per-op Task /
- // DispatcherOperationEvent), and goes out of scope when Invoke returns. The
- // op + its Task are never exposed to user code, so Task.AsyncState is
- // unobservable on that path — meaning the Mapping is pure waste.
- //
- // When skipTaskAsyncStateMapping is true the TaskSource creates a default
- // `new TaskCompletionSource()` with null state, so Task.AsyncState
- // is null. Internal callers (DispatcherOperation.Wait's
- // `Task.GetAwaiter().GetResult()`, InvokeCompletions' SetResult/SetException/
- // SetCanceled) don't read AsyncState, so they are unaffected.
- //
- // Default false preserves the existing allocation behavior for every
- // DispatcherOperation construction that exposes the op (BeginInvoke /
- // InvokeAsync / LegacyBeginInvokeImpl / params-object[] BeginInvoke /
- // the typed DispatcherOperation ctor used by both Invoke
- // and InvokeAsync).
- internal DispatcherOperation(
- Dispatcher dispatcher,
- Delegate method,
- DispatcherPriority priority,
- object args,
- int numArgs,
- DispatcherOperationTaskSource taskSource,
- bool useAsyncSemantics,
- bool skipTaskAsyncStateMapping)
{
_dispatcher = dispatcher;
_method = method;
@@ -77,10 +38,7 @@ internal DispatcherOperation(
_executionContext = CulturePreservingExecutionContext.Capture();
_taskSource = taskSource;
- if (skipTaskAsyncStateMapping)
- _taskSource.InitializeWithoutMapping(this);
- else
- _taskSource.Initialize(this);
+ _taskSource.Initialize(this);
_useAsyncSemantics = useAsyncSemantics;
}
@@ -115,29 +73,6 @@ internal DispatcherOperation(
{
}
- // Internal-sync ctor used by Dispatcher.Invoke(Action,...) slow path.
- // The op is constructed locally inside Invoke, waited on, and goes out of
- // scope when Invoke returns — it is never exposed to user code. Skipping
- // the per-op DispatcherOperationTaskMapping allocation that the Initialize
- // path would otherwise create saves ~24 B/op on every cross-thread or
- // non-Send-priority synchronous Dispatcher.Invoke(Action,...) call.
- // See the inner ctor's comment for the safety argument.
- internal DispatcherOperation(
- Dispatcher dispatcher,
- DispatcherPriority priority,
- Action action,
- bool internalSyncInvoke) : this(
- dispatcher,
- action,
- priority,
- null,
- 0,
- new DispatcherOperationTaskSource