From 88d25037b7b5a4265583b9bdc1678158e9a54664 Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Mon, 28 Sep 2026 11:12:27 -0700 Subject: [PATCH 1/2] Prepare Blazor workloads before dispatching to Helix Install wasm-tools once into the SDK correlation payload and make all Blazor scenario workload helpers read-only. Keep publish and measurement on Helix, avoiding partial workload uninstalls that corrupt subsequent work items. Preserve non-AOT localized publishing and pass the localized AOT argument directly instead of treating item metadata as a property. Add real MSBuild command-evaluation and Python helper regression coverage and document the lifecycle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/sdk-scenarios.md | 22 ++++ eng/performance/blazor_scenarios.proj | 18 ++- scripts/tests/test_blazor_workload.py | 181 ++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 scripts/tests/test_blazor_workload.py diff --git a/docs/sdk-scenarios.md b/docs/sdk-scenarios.md index c09f165f198..c72027e1323 100644 --- a/docs/sdk-scenarios.md +++ b/docs/sdk-scenarios.md @@ -47,6 +47,28 @@ does not download arbitrary Helix uploads into pipeline artifacts. Asynchronous skip the submitter's `artifacts/helix-results` downloads, which were not published by this pipeline. Local `--send-to-helix` runs still wait and download performance reports. +## Blazor workload preparation + +SDK Blazor jobs install `wasm-tools` once on the build agent through +`PreparePayloadWorkItems`, using the SDK in the correlation payload, its bundled +manifests (`--skip-manifest-update`), and the payload's `NuGet.config`. An installation +failure stops payload preparation before the job is sent to Helix. + +Helix work items use `pre.py publish --has-workload --readonly-dotnet` and +`post.py --readonly-dotnet`. These flags prevent the scenario helpers from changing +the shared workload installation; they do not set filesystem permissions. Application +restore, publish, size measurements, and cleanup of work-item-local outputs still run +on Helix. Standalone scenario scripts retain their existing workload-management +defaults. + +Workload availability does not select the benchmark's compilation mode. The Minimum +Template explicitly enables native relinking, while the non-AOT New Template, Pizza, +and Localized scenarios disable it with `WasmNativeWorkload=false`. AOT scenarios +enable `RunAOTCompilation=true`. The Localized AOT command passes this property directly +instead of referencing item metadata as an MSBuild property, which previously dropped +the AOT argument. Its reported sizes may therefore change when it first runs with AOT +actually enabled. + ## SDK Build Throughput Scenario **SDK Build Throughput** is a scenario test that measures the throughput of SDK build process. To be more specific, our test *implicitly calls* diff --git a/eng/performance/blazor_scenarios.proj b/eng/performance/blazor_scenarios.proj index 4cba378c7fa..8d0e868dc27 100644 --- a/eng/performance/blazor_scenarios.proj +++ b/eng/performance/blazor_scenarios.proj @@ -7,9 +7,13 @@ %PERFLAB_TARGET_FRAMEWORKS% %24{PERFLAB_TARGET_FRAMEWORKS} <_MSBuildArgs>/p:_TrimmerDumpDependencies=true;/warnaserror:NU1602,NU1604 - --msbuild "$(_MSBuildArgs)" --msbuild-static AdditionalMonoLinkerOptions=%27"%24(AdditionalMonoLinkerOptions) --dump-dependencies"%27 --binlog $(LogDirectory)blazor_publish.binlog + --has-workload --readonly-dotnet --msbuild "$(_MSBuildArgs)" --msbuild-static AdditionalMonoLinkerOptions=%27"%24(AdditionalMonoLinkerOptions) --dump-dependencies"%27 --binlog $(LogDirectory)blazor_publish.binlog $(Python) pre.py publish $(PublishArgs) + <_BlazorDotNet>$([MSBuild]::NormalizePath('$(CorrelationPayloadDirectory)', 'dotnet', 'dotnet')) + <_BlazorDotNet Condition="'$(TargetsWindows)' == 'true'">$(_BlazorDotNet).exe + <_BlazorNuGetConfig>$([MSBuild]::NormalizePath('$(CorrelationPayloadDirectory)', 'performance', 'NuGet.config')) + pub\wwwroot pub/wwwroot @@ -27,6 +31,11 @@ + + "$(_BlazorDotNet)" workload install wasm-tools --skip-manifest-update --configfile "$(_BlazorNuGetConfig)" + $(CorrelationPayloadDirectory)performance + + %(Identity) @@ -35,7 +44,7 @@ - $(Python) post.py + $(Python) post.py --readonly-dotnet 4:00 @@ -81,14 +90,13 @@ $(ScenariosDir)blazorlocalized - $(PublishCommand) -f $(PerfLabTargetFrameworksEnvVar) && $(Python) test.py sod --scenario-name "%(Identity)" --dirs $(PizzaAppPubLocation) $(ScenarioArgs) + $(PublishCommand) --msbuild "$(_MSBuildArgs);/p:WasmNativeWorkload=false" -f $(PerfLabTargetFrameworksEnvVar) && $(Python) test.py sod --scenario-name "%(Identity)" --dirs $(PizzaAppPubLocation) $(ScenarioArgs) - <_PublishArgsWithAOT>--msbuild "$(_MSBuildArgs);/p:RunAOTCompilation=true" $(ScenariosDir)blazorlocalized - $(PublishCommand) $(_PublishArgsWithAOT) -f $(PerfLabTargetFrameworksEnvVar) && $(Python) test.py sod --scenario-name "%(Identity)" --dirs $(PizzaAppPubLocation) $(ScenarioArgs) + $(PublishCommand) --msbuild "$(_MSBuildArgs);/p:RunAOTCompilation=true" -f $(PerfLabTargetFrameworksEnvVar) && $(Python) test.py sod --scenario-name "%(Identity)" --dirs $(PizzaAppPubLocation) $(ScenarioArgs) diff --git a/scripts/tests/test_blazor_workload.py b/scripts/tests/test_blazor_workload.py new file mode 100644 index 00000000000..5fab9879d76 --- /dev/null +++ b/scripts/tests/test_blazor_workload.py @@ -0,0 +1,181 @@ +import importlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +from unittest.mock import Mock +import xml.etree.ElementTree as ET + +import pytest + + +repo_root = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def scenario_commands(monkeypatch): + monkeypatch.syspath_prepend(str(repo_root / "scripts")) + monkeypatch.syspath_prepend(str(repo_root / "src" / "scenarios")) + return ( + importlib.import_module("shared.precommands").PreCommands, + importlib.import_module("shared.postcommands").PostCommands, + ) + + +@pytest.fixture(params=["true", "false"], ids=["windows", "unix"]) +def blazor_items(request, tmp_path): + dotnet = shutil.which("dotnet") + if dotnet is None: + pytest.skip("MSBuild evaluation requires a .NET SDK") + + project = ET.parse(repo_root / "eng" / "performance" / "blazor_scenarios.proj") + # Evaluate the real work items without downloading the Helix SDK. + project.getroot().attrib.pop("Sdk") + project.find("Import").set( + "Project", str(repo_root / "eng" / "performance" / "PreparePayloadWorkItems.targets") + ) + project_path = tmp_path / "blazor.proj" + project.write(project_path, encoding="utf-8") + payload = tmp_path / "correlation payload" + result = subprocess.run( + [ + dotnet, "msbuild", str(project_path), "-nologo", + "-getItem:PreparePayloadWorkItem,HelixWorkItem", + f"-p:CorrelationPayloadDirectory={payload}{os.sep}", + f"-p:TargetsWindows={request.param}", + "-p:Python=python", + ], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout)["Items"], payload, request.param == "true" + + +def test_workload_is_prepared_once_in_payload(blazor_items): + items, payload, windows = blazor_items + preparation, = items["PreparePayloadWorkItem"] + executable = payload / "dotnet" / ("dotnet.exe" if windows else "dotnet") + config = payload / "performance" / "NuGet.config" + + assert preparation["Command"] == ( + f'"{executable}" workload install wasm-tools --skip-manifest-update ' + f'--configfile "{config}"' + ) + assert Path(preparation["WorkingDirectory"]) == payload / "performance" + + +@pytest.mark.parametrize("exit_code", [0, 23]) +def test_preparation_propagates_workload_exit_code(blazor_items, tmp_path, exit_code): + _, payload, windows = blazor_items + working_directory = payload / "performance" + working_directory.mkdir(parents=True) + # Substitute only the executable; run the real preparation target and arguments. + (working_directory / "workload").write_text( + "import sys\n" + "assert sys.argv[1:4] == ['install', 'wasm-tools', '--skip-manifest-update']\n" + "print('Workload preparation executed')\n" + f"sys.exit({exit_code})\n", + encoding="utf-8", + ) + result = subprocess.run( + [ + shutil.which("dotnet"), "msbuild", str(tmp_path / "blazor.proj"), "-nologo", + "-t:PreparePayloadWorkItems", + f"-p:CorrelationPayloadDirectory={payload}{os.sep}", + f"-p:TargetsWindows={str(windows).lower()}", + f"-p:_BlazorDotNet={sys.executable}", + ], + cwd=tmp_path, + capture_output=True, + text=True, + ) + + assert "Workload preparation executed" in result.stdout + if exit_code: + assert result.returncode != 0 + assert "MSB3073" in result.stdout + else: + assert result.returncode == 0, result.stdout + result.stderr + + +def test_all_work_items_leave_shared_workload_untouched( + blazor_items, scenario_commands, monkeypatch +): + items, _, _ = blazor_items + PreCommands, PostCommands = scenario_commands + run = Mock(side_effect=AssertionError("A work item must not modify the workload")) + monkeypatch.setattr(subprocess, "run", run) + + assert len(items["HelixWorkItem"]) == 8 + for item in items["HelixWorkItem"]: + command = item["Command"].split(" && ", 1)[0] + monkeypatch.setattr(sys, "argv", shlex.split(command)[1:]) + pre = PreCommands() + assert pre.has_workload + assert pre.readonly_dotnet + pre.install_workload("wasm-tools") + pre.uninstall_workload("wasm-tools") + + monkeypatch.setattr(sys, "argv", shlex.split(item["PostCommands"])[1:]) + post = PostCommands() + assert post.readonly_dotnet + post.uninstall_workload("wasm-tools") + + run.assert_not_called() + + +@pytest.mark.parametrize( + ("name", "property_argument"), + [ + ("SOD - Localized App - Publish", "/p:WasmNativeWorkload=false"), + ("SOD - Localized App - Publish - AOT", "/p:RunAOTCompilation=true"), + ], +) +def test_localized_publish_mode_is_forwarded( + blazor_items, scenario_commands, monkeypatch, name, property_argument +): + items, _, _ = blazor_items + PreCommands, _ = scenario_commands + item = next(item for item in items["HelixWorkItem"] if item["Identity"] == name) + command = item["Command"].split(" && ", 1)[0] + monkeypatch.setattr(sys, "argv", shlex.split(command)[1:]) + pre = PreCommands() + pre.project = Mock() + pre.execute() + + assert pre.project.publish.call_args.args[6] == [ + "/p:_TrimmerDumpDependencies=true", + "/warnaserror:NU1602,NU1604", + property_argument, + ] + + +def test_standalone_workload_install_is_unchanged(scenario_commands, monkeypatch): + PreCommands, _ = scenario_commands + monkeypatch.setattr(sys, "argv", ["pre.py", "publish"]) + run = Mock() + monkeypatch.setattr(subprocess, "run", run) + + PreCommands().install_workload("wasm-tools") + + run.assert_called_once_with( + ["dotnet", "workload", "install", "wasm-tools", "--skip-manifest-update"], + check=True, + ) + + +def test_readonly_sdk_requires_preinstalled_workload(scenario_commands, monkeypatch): + PreCommands, _ = scenario_commands + monkeypatch.setattr(sys, "argv", ["pre.py", "publish", "--readonly-dotnet"]) + run = Mock() + monkeypatch.setattr(subprocess, "run", run) + + with pytest.raises(Exception, match="has_workload=false"): + PreCommands().install_workload("wasm-tools") + + run.assert_not_called() From a2f922748755c375fa75ab49e25b512d06deeb09 Mon Sep 17 00:00:00 2001 From: Parker Bibus Date: Mon, 28 Sep 2026 13:34:58 -0700 Subject: [PATCH 2/2] Explain Localized Blazor compilation-mode overrides Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/performance/blazor_scenarios.proj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eng/performance/blazor_scenarios.proj b/eng/performance/blazor_scenarios.proj index 8d0e868dc27..03c2b5a06ee 100644 --- a/eng/performance/blazor_scenarios.proj +++ b/eng/performance/blazor_scenarios.proj @@ -87,6 +87,9 @@ $(PublishCommand) -f $(PerfLabTargetFrameworksEnvVar) && $(Python) test.py sod --scenario-name "%(Identity)" --dirs $(PizzaAppPubLocation) $(ScenarioArgs) + $(ScenariosDir)blazorlocalized