diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 341aace1398..b4e1953e809 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -252,6 +252,51 @@ jobs: } shell: powershell + # ----- crossgen2 (ReadyToRun) compilation ----- + # Stock dotnet/wpf DLLs in Microsoft.WindowsDesktop.App ship with R2R native + # code baked in by dotnet/runtime's runtime-pack assembly step. Our fork + # builds the libraries via build.cmd but does not run that step, so the DLLs + # in artifacts/bin are JIT-only. JIT'd frames are slightly fatter than R2R'd + # frames, and WPF code paths that are already deep on the stack + # (dispatcher unhandled-exception handler -> MessageDialog.xaml -> BAML -> + # WPFLocalizeExtension's culture iteration) overflow the 1 MB thread stack + # in consumers. R2R-compile the staged DLLs in place to match upstream behavior. + - name: R2R-compile staged DLLs (crossgen2) + run: | + $rid = if ("${{ matrix.arch }}" -eq "x64") { "win-x64" } else { "win-arm64" } + # Locate runtime packs from the SDK installed by setup-dotnet. + $sdkInfo = (& dotnet --info) -join "`n" + $dotnetRoot = $env:DOTNET_ROOT + if (-not $dotnetRoot -or -not (Test-Path $dotnetRoot)) { + $dotnetRoot = Split-Path -Parent (Get-Command dotnet).Source + } + $netCorePack = Get-ChildItem (Join-Path $dotnetRoot 'shared\Microsoft.NETCore.App') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + $desktopPack = Get-ChildItem (Join-Path $dotnetRoot 'shared\Microsoft.WindowsDesktop.App') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 + if (-not $netCorePack) { throw "Microsoft.NETCore.App runtime pack not found under $dotnetRoot" } + if (-not $desktopPack) { throw "Microsoft.WindowsDesktop.App runtime pack not found under $dotnetRoot" } + Write-Host "Using NetCorePack = $($netCorePack.FullName)" + Write-Host "Using DesktopPack = $($desktopPack.FullName)" + + $stagingDirs = @( + "packaging\InitialForce.WPF\runtimes\$rid\lib\net10.0", + "packaging\InitialForce.WPF.RuntimeOverride\runtimes\$rid\lib\net10.0" + ) + foreach ($dir in $stagingDirs) { + Write-Host "" + Write-Host "===================================================================" + Write-Host "Crossgen2: $dir" + Write-Host "===================================================================" + & .\tools\crossgen-staged.ps1 ` + -StagingDir $dir ` + -NetCorePack $netCorePack.FullName ` + -DesktopPack $desktopPack.FullName ` + -TargetArch ${{ matrix.arch }} + if ($LASTEXITCODE -ne 0) { throw "crossgen-staged.ps1 failed for $dir" } + } + shell: powershell + - name: Compute package version id: version run: | diff --git a/.gitignore b/.gitignore index 202762ac83b..8409bc1beed 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ project.fragment.lock.json artifacts/ **/Properties/launchSettings.json +# Tooling cache (downloaded crossgen2 nupkg, etc.) +.tools-cache/ + # StyleCop StyleCopReport.xml diff --git a/.if-fork/config.yaml b/.if-fork/config.yaml index 06c42acfacf..6564c91a12a 100644 --- a/.if-fork/config.yaml +++ b/.if-fork/config.yaml @@ -30,6 +30,31 @@ author_allowlist: - akshatsinha0 - dotnet-maestro # automated dep updates only +# Upstream commits/PRs that must NEVER be cherry-picked back into the fork. +# Each entry refuses both the listed upstream commit SHA and any further commit +# whose message contains "(cherry picked from commit )" pointing at it. +# Cherry-pick automation must consult this list before applying any patch. +# +# Format: list of objects with: +# sha: upstream commit SHA (40 hex chars; matched as prefix on first 12+ chars) +# pr: (optional) upstream PR number for cross-reference +# reason: short human-readable rationale +# added: ISO date the entry was added +commit_denylist: + - sha: 0e965e795ee813ea17bf1bfaf3b8db9a0e0f9a8a + pr: 10715 + reason: > + Substitutes typeof(CanExecuteChangedEventManager) for + typeof(RequerySuggestedEventManager) as the WeakEventManager dictionary key + in CommandManager.RequerySuggestedEventManager.CurrentManager. The two managers + then race for the same key; whichever registers first wins, and the cast on + the loser's lookup throws InvalidCastException at runtime under XAML parsing + paths that bind ICommand. Reproducible: any Button with a Command in a template + crashes once the cast goes the wrong way. Author claims "Risk: Low / Regression: No" + but it is a hard regression. Do not re-apply without an upstream fix that uses + each manager's own type as its key. + added: "2026-04-30" + # Files that auto-fail conflict resolution (always escalate to human). file_denylist: # supply chain diff --git a/.if-fork/prompts/cherry-pick.md b/.if-fork/prompts/cherry-pick.md index bf41405326a..a5bf65106ad 100644 --- a/.if-fork/prompts/cherry-pick.md +++ b/.if-fork/prompts/cherry-pick.md @@ -61,7 +61,7 @@ Exit 0 for `graduated_upstream` (not an error). Exit 0 on success. Exit 1 on all ## Procedure -**Step 1 — Load config.** Note `config.file_denylist` and `config.review_hard_fail_patterns`. +**Step 1 — Load config.** Note `config.file_denylist`, `config.commit_denylist`, and `config.review_hard_fail_patterns`. **Step 2 — Verify SHA:** ```bash @@ -75,6 +75,23 @@ if [ "$CURRENT" != "$HEAD_SHA" ]; then fi ``` +**Step 2.5 — Commit denylist check.** Refuse the pick (exit 1) if the upstream +SHA, or any SHA reached by walking `(cherry picked from commit ...)` trailers, +is on `config.commit_denylist`. This catches regressions that were already +identified and reverted in this fork. +```bash +python tools/check-commit-denylist.py "$HEAD_SHA" --config "$CONFIG_PATH" +RC=$? +if [ $RC -eq 2 ]; then + python tools/ledger-event.py --event refused --pr-number $PR_NUMBER \ + --head-sha "$HEAD_SHA" --actor cherry-pick \ + --details-json '{"reason":"commit_denylist"}' + exit 1 +elif [ $RC -ne 0 ]; then + exit 1 +fi +``` + **Step 3 — Pre-flight graduation check:** ```bash git fetch upstream refs/pull/$PR_NUMBER/head:refs/remotes/pr/$PR_NUMBER diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Input/Command/CommandManager.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Input/Command/CommandManager.cs index 94e0d09c2d0..0ad544e79db 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Input/Command/CommandManager.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Input/Command/CommandManager.cs @@ -901,13 +901,13 @@ private static RequerySuggestedEventManager CurrentManager { get { - RequerySuggestedEventManager manager = (RequerySuggestedEventManager)GetCurrentManager(typeof(CanExecuteChangedEventManager)); + RequerySuggestedEventManager manager = (RequerySuggestedEventManager)GetCurrentManager(typeof(RequerySuggestedEventManager)); // at first use, create and register a new manager if (manager == null) { manager = new RequerySuggestedEventManager(); - SetCurrentManager(typeof(CanExecuteChangedEventManager), manager); + SetCurrentManager(typeof(RequerySuggestedEventManager), manager); } return manager; diff --git a/tools/check-commit-denylist.py b/tools/check-commit-denylist.py new file mode 100644 index 00000000000..e04ff082e64 --- /dev/null +++ b/tools/check-commit-denylist.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""check-commit-denylist.py — Refuse cherry-pick when the upstream commit (or any +chained "(cherry picked from commit ...)" ancestor) appears in commit_denylist. + +Used by cherry-pick automation as a hard gate BEFORE applying any patch. Catches: + 1. A direct cherry-pick whose --from SHA is on the list. + 2. A re-pick of a fork-side commit whose body contains + "(cherry picked from commit )" pointing at a denied SHA. + 3. A re-pick after the upstream commit was rebased/squashed: any of the parent + SHAs threaded through the cherry-pick trailer chain. + +Exit codes: + 0 — not on denylist (safe to proceed) + 1 — error (bad config, missing file, subprocess failure) + 2 — denied (one or more SHAs in the chain match the denylist) + +Output (stdout): JSON with either: + { "verdict": "ok", "checked_shas": [...] } + { "verdict": "denied", + "matched": {"sha": "...", "pr": ..., "reason": "..."}, + "checked_shas": [...] } +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + +CHERRY_PICK_TRAILER = re.compile( + r"\(cherry picked from commit ([0-9a-f]{7,40})\)", + re.IGNORECASE, +) + + +def _die(msg: str, code: int = 1) -> None: + print(json.dumps({"verdict": "error", "error": msg})) + sys.exit(code) + + +def load_denylist(config_path: Path) -> list[dict[str, Any]]: + if not config_path.is_file(): + _die(f"config file not found: {config_path}") + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + _die(f"failed to parse {config_path}: {exc}") + raw = data.get("commit_denylist") if isinstance(data, dict) else None + if raw is None: + return [] + if not isinstance(raw, list): + _die("commit_denylist must be a list") + out: list[dict[str, Any]] = [] + for entry in raw: + if not isinstance(entry, dict): + _die(f"commit_denylist entry not a mapping: {entry!r}") + sha = entry.get("sha") + if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{12,40}", sha): + _die(f"commit_denylist entry has invalid sha: {entry!r}") + out.append(entry) + return out + + +def collect_chain(sha: str, repo: Path) -> list[str]: + """Return [sha, parents...] including any (cherry picked from commit X) trailers + found by walking the local commit message and any reachable upstream copies.""" + seen: list[str] = [] + pending = [sha.lower()] + while pending: + cur = pending.pop() + if cur in seen: + continue + seen.append(cur) + # Try to read commit message; if SHA is unknown locally, skip — we still keep + # it in seen so caller can match against denylist by prefix. + try: + msg = subprocess.check_output( + ["git", "log", "-1", "--format=%B", cur], + cwd=repo, + text=True, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + continue + for m in CHERRY_PICK_TRAILER.finditer(msg): + pending.append(m.group(1).lower()) + return seen + + +def is_denied(sha: str, denylist: list[dict[str, Any]]) -> dict[str, Any] | None: + sha = sha.lower() + for entry in denylist: + denied = entry["sha"].lower() + if sha.startswith(denied[:12]) or denied.startswith(sha[:12]): + return entry + return None + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Check whether a candidate commit (and its cherry-pick ancestry) is on " + "the commit_denylist in .if-fork/config.yaml. Hard gate for cherry-pick " + "automation." + ) + ) + parser.add_argument( + "sha", + help="Candidate commit SHA (the upstream commit you would cherry-pick, or a " + "local commit that re-picks one). Walks (cherry picked from commit ...) " + "trailers up the chain.", + ) + parser.add_argument( + "--config", + type=Path, + default=Path(".if-fork/config.yaml"), + help="Path to config.yaml (default: .if-fork/config.yaml)", + ) + parser.add_argument( + "--repo", + type=Path, + default=Path("."), + help="Path to git repo root (default: current dir)", + ) + args = parser.parse_args() + + denylist = load_denylist(args.config) + if not denylist: + print(json.dumps({"verdict": "ok", "checked_shas": [args.sha], "note": "denylist empty"})) + return 0 + + chain = collect_chain(args.sha, args.repo) + for candidate in chain: + match = is_denied(candidate, denylist) + if match is not None: + print(json.dumps({ + "verdict": "denied", + "matched": { + "sha": match["sha"], + "pr": match.get("pr"), + "reason": match["reason"].strip(), + }, + "matched_via": candidate, + "checked_shas": chain, + })) + return 2 + + print(json.dumps({"verdict": "ok", "checked_shas": chain})) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/config-schema.json b/tools/config-schema.json index 2be6676f2ab..21b0f11219f 100644 --- a/tools/config-schema.json +++ b/tools/config-schema.json @@ -88,6 +88,36 @@ "items": {"type": "string"}, "description": "Glob patterns for files that auto-fail conflict resolution" }, + "commit_denylist": { + "type": "array", + "description": "Upstream commit SHAs that must never be cherry-picked (regression list)", + "items": { + "type": "object", + "required": ["sha", "reason", "added"], + "additionalProperties": false, + "properties": { + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{12,40}$", + "description": "Upstream commit SHA (matched as prefix on first 12+ chars)" + }, + "pr": { + "type": "integer", + "description": "Upstream PR number for cross-reference", + "minimum": 1 + }, + "reason": { + "type": "string", + "description": "Human-readable rationale for the block" + }, + "added": { + "type": "string", + "format": "date", + "description": "ISO date the entry was added" + } + } + } + }, "tier_predicates": { "type": "object", "required": ["s", "a", "b"], diff --git a/tools/crossgen-staged.ps1 b/tools/crossgen-staged.ps1 new file mode 100644 index 00000000000..c93ad9c5d1c --- /dev/null +++ b/tools/crossgen-staged.ps1 @@ -0,0 +1,148 @@ +#requires -Version 5 +<# +.SYNOPSIS + Runs crossgen2 (ReadyToRun ahead-of-time compilation) on the staged + InitialForce.WPF DLLs so the published nupkg contains native code, + matching stock dotnet/wpf behavior. + +.DESCRIPTION + Stock WPF DLLs in Microsoft.WindowsDesktop.App ship with R2R native code + baked in by the dotnet/runtime build infra during runtime-pack assembly. + Our fork builds the libraries via build.cmd but does not run the + runtime-pack step, so the DLLs in artifacts/bin are JIT-only. + + JIT'd frames are slightly fatter than R2R'd frames. WPF code paths that + are already deep on the stack (notably the dispatcher unhandled-exception + handler loading MessageDialog.xaml -> BAML -> WPFLocalizeExtension's + 800-culture iteration) overflow the 1 MB thread stack when frames are + fatter than upstream consumers expect. + + This script crossgen2-compiles the 4 staged DLLs in place. Verified with + upstream crossgen2 10.0.7. + +.PARAMETER StagingDir + Directory containing the 4 patched DLLs (PresentationCore, + PresentationFramework, WindowsBase, System.Xaml). The DLLs are replaced + in place with their R2R-compiled equivalents. + +.PARAMETER NetCorePack + Path to the Microsoft.NETCore.App shared runtime pack (full of *.dll). + +.PARAMETER DesktopPack + Path to the Microsoft.WindowsDesktop.App shared runtime pack. + +.PARAMETER Crossgen2Version + Version of Microsoft.NETCore.App.Crossgen2.win-x64 to fetch from nuget.org. + Should match the runtime pack's major.minor. + +.PARAMETER ToolsCache + Directory used to cache the downloaded crossgen2 NuGet package. + Defaults to /.tools-cache. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $StagingDir, + [Parameter(Mandatory)] [string] $NetCorePack, + [Parameter(Mandatory)] [string] $DesktopPack, + [ValidateSet('x64', 'arm64')] [string] $TargetArch = 'x64', + [string] $Crossgen2Version = '10.0.7', + [string] $ToolsCache = (Join-Path $PSScriptRoot '..\.tools-cache') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Get-Crossgen2Exe { + param([string] $Version, [string] $Cache) + + $pkgDir = Join-Path $Cache "crossgen2-$Version" + $exe = Join-Path $pkgDir 'tools\crossgen2.exe' + if (Test-Path $exe) { + Write-Host "==> crossgen2 cached at $exe" + return $exe + } + + New-Item -ItemType Directory -Path $pkgDir -Force | Out-Null + $nupkg = Join-Path $pkgDir "crossgen2.$Version.nupkg" + $url = "https://api.nuget.org/v3-flatcontainer/microsoft.netcore.app.crossgen2.win-x64/$Version/microsoft.netcore.app.crossgen2.win-x64.$Version.nupkg" + + Write-Host "==> Downloading crossgen2 $Version from $url" + Invoke-WebRequest -Uri $url -OutFile $nupkg -UseBasicParsing + + Write-Host "==> Extracting to $pkgDir" + # .nupkg is a zip; Expand-Archive insists on a .zip extension, so copy then expand. + $zip = Join-Path $pkgDir "crossgen2.$Version.zip" + Copy-Item -Path $nupkg -Destination $zip -Force + Expand-Archive -Path $zip -DestinationPath $pkgDir -Force + Remove-Item -Path $zip -Force + + if (-not (Test-Path $exe)) { + throw "crossgen2.exe not found after extraction: $exe" + } + return $exe +} + +if (-not (Test-Path $StagingDir)) { throw "StagingDir not found: $StagingDir" } +if (-not (Test-Path $NetCorePack)) { throw "NetCorePack not found: $NetCorePack" } +if (-not (Test-Path $DesktopPack)) { throw "DesktopPack not found: $DesktopPack" } + +$crossgen = Get-Crossgen2Exe -Version $Crossgen2Version -Cache $ToolsCache +& $crossgen --version +if ($LASTEXITCODE -ne 0) { throw "crossgen2 --version failed" } + +$dlls = @('PresentationCore.dll', 'PresentationFramework.dll', 'WindowsBase.dll', 'System.Xaml.dll') +$tmpOut = Join-Path $StagingDir '.r2r-out' +New-Item -ItemType Directory -Path $tmpOut -Force | Out-Null + +foreach ($name in $dlls) { + $input = Join-Path $StagingDir $name + if (-not (Test-Path $input)) { throw "Missing staged DLL: $input" } + $output = Join-Path $tmpOut $name + + Write-Host "" + Write-Host "==> R2R-compiling $name" + $inSize = (Get-Item $input).Length + + # crossgen2.exe is the win-x64 binary (only one shipped); use --targetarch + # to cross-compile to arm64. Reference assemblies are read for managed + # metadata only, so the host arch's runtime pack works as references for + # arm64 cross-compilation too. + & $crossgen ` + --targetos=windows ` + --targetarch=$TargetArch ` + -r "$NetCorePack\*.dll" ` + -r "$DesktopPack\*.dll" ` + -r "$StagingDir\*.dll" ` + -o $output ` + $input + + if ($LASTEXITCODE -ne 0) { throw "crossgen2 failed for $name (exit $LASTEXITCODE)" } + + # Sanity check: output must contain the R2R magic 'RTR\0' near the start. + $bytes = [System.IO.File]::ReadAllBytes($output) + $found = $false + for ($i = 0; $i -lt [Math]::Min($bytes.Length - 4, 65536); $i++) { + if ($bytes[$i] -eq 0x52 -and $bytes[$i+1] -eq 0x54 -and $bytes[$i+2] -eq 0x52 -and $bytes[$i+3] -eq 0x00) { + $found = $true + break + } + } + if (-not $found) { throw "R2R magic 'RTR\0' not found in $name within first 64 KB; crossgen2 produced a non-R2R image" } + + $outSize = $bytes.Length + $growth = if ($inSize -gt 0) { ($outSize / $inSize - 1) * 100 } else { 0 } + Write-Host (" {0,-30} in={1} out={2} growth={3:N1}%" -f $name, $inSize, $outSize, $growth) +} + +# Move R2R outputs back over staged inputs. +foreach ($name in $dlls) { + $src = Join-Path $tmpOut $name + $dst = Join-Path $StagingDir $name + Move-Item -Path $src -Destination $dst -Force +} +Remove-Item -Path $tmpOut -Recurse -Force + +Write-Host "" +Write-Host "==> Done. $StagingDir now contains R2R-compiled DLLs." +Get-ChildItem $StagingDir -Filter '*.dll' | Format-Table Name, Length