diff --git a/docs/build_cache_requirements.md b/docs/build_cache_requirements.md
new file mode 100644
index 000000000..3efac3690
--- /dev/null
+++ b/docs/build_cache_requirements.md
@@ -0,0 +1,95 @@
+# Build Cache Service — Requirements for Crank Integration
+
+## Context
+
+Crank (the .NET benchmarking tool) has been updated to support a new `buildcache` channel that downloads pre-built runtime binaries from the Build Cache Service (BCS) instead of resolving versions from VMR/NuGet feeds. This gives per-commit granularity for performance testing and regression bisection.
+
+The crank-side changes are complete. This document describes what's needed on the BCS/dotnet-performance-infra side to make the integration work end-to-end.
+
+---
+
+## Requirement 1: Public Blob Access
+
+**Status:** Already in progress (per prior discussion).
+
+Crank's BCS client uses unauthenticated HTTP GET requests to download artifacts. The blobs in the `pvscmdupload` storage account's `$web` container need to be publicly readable.
+
+**URLs crank will hit:**
+
+```
+GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/latest/{branch}/latestBuilds.json
+GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/{artifactFile}
+```
+
+Where:
+- `repoName` = `runtime` (initially; `aspnetcore` in the future)
+- `branch` = e.g., `main`, `release/10.0`
+- `configKey` = e.g., `coreclr_x64_linux`, `coreclr_arm64_windows`
+- `artifactFile` = e.g., `BuildArtifacts_linux_x64_Release_coreclr.tar.gz`
+
+---
+
+## Requirement 2: Commit Index File (Not Required)
+
+~~Originally proposed as a per-branch `commitIndex.json` mapping commits to timestamps.~~
+
+**Decision:** Not needed. For the default case, `latestBuilds.json` provides the latest commit. For specific-commit runs (e.g., bisection), users will already know the SHAs — either from git history, GitHub, or a local tool that queries the GitHub API for the commit list. A separate index in BCS would be redundant.
+
+If automated bisection tooling is built in the future, it can query GitHub directly for ordered commit SHAs and then check BCS blob existence per-commit.
+
+---
+
+## Requirement 3: latestBuilds.json Compatibility
+
+**Resolved.** The actual `latestBuilds.json` uses PascalCase (`CommitSha`, `CommitTime`), not snake_case. Crank's parser has been updated to accept both casings for forward compatibility.
+
+---
+
+## Requirement 4: Artifact Layout Stability
+
+Crank extracts runtime artifacts using this path convention inside the archive:
+
+```
+microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/lib/net{X}.0/ → managed DLLs
+microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/native/ → native libs
+{rid}.Release/corehost/ → host binaries (dotnet, libhostfxr, libhostpolicy)
+```
+
+Where `{rid}` = `linux-x64`, `linux-arm64`, `win-x64`, etc.
+
+This layout was confirmed by inspecting `BuildArtifacts_linux_arm64_Release_coreclr.tar.gz`. **If this layout changes in future builds, the crank extraction will break.** Consider treating it as a stable contract or documenting it.
+
+---
+
+## Nice-to-Have: Artifact Manifest
+
+A `manifest.json` per commit+config that describes the archive contents would make extraction more robust:
+
+```
+builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/manifest.json
+```
+
+```json
+{
+ "runtimeVersion": "10.0.0-preview.4.26120.3",
+ "commitSha": "abc123...",
+ "rid": "linux-arm64",
+ "managedPath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/lib/net10.0",
+ "nativePath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/native",
+ "corehostPath": "linux-arm64.Release/corehost"
+}
+```
+
+This isn't blocking — crank currently discovers paths by convention — but it would decouple crank from the internal archive layout and make future changes safe.
+
+---
+
+## Summary
+
+| # | Requirement | Priority | Blocking? |
+|---|-------------|----------|-----------|
+| 1 | Public blob access | High | Yes — crank can't download without it |
+| 2 | ~~Commit index~~ | N/A | Dropped — users provide SHAs directly or use GitHub |
+| 3 | `latestBuilds.json` field names | N/A | Resolved — crank parser updated to handle PascalCase |
+| 4 | Artifact layout stability | Medium | Not now, but breaking changes would break crank |
+| 5 | Artifact manifest.json | Low | Nice-to-have for robustness |
diff --git a/docs/dotnet_versions.md b/docs/dotnet_versions.md
index 9c48d5305..70e21181c 100644
--- a/docs/dotnet_versions.md
+++ b/docs/dotnet_versions.md
@@ -55,9 +55,12 @@ When a TFM is configured, the agent will download the corresponding .NET SDK ver
- `current`: only latest public versions, this is the default
- `latest`: latest versions used by ASP.NET
- `edge`: latest nightly builds available
+- `buildcache`: runtime from the Build Cache Service (per-commit builds)
The difference between `latest` and `edge` is that `latest` will pick runtimes and SDKs that are deemed compatible together. For instance a very recent .NET core runtime might be compatible with a less recent ASP.NET runtime. The `edge` is used to pick the absolute latest build for the select TFM.
+The `buildcache` channel uses the Build Cache Service (BCS) from `dotnet-performance-infra` to resolve runtime versions by individual commit SHA rather than from VMR feeds. This provides much finer-grained control — every cached runtime commit is available, whereas VMR feeds may have multi-day gaps between ingested commits. SDK and ASP.NET Core versions are resolved from `latest` when using `buildcache`.
+
In order to benchmark and ASP.NET application using very recent runtimes of .NET 5, the `latest` channel is recommended:
```
@@ -115,4 +118,52 @@ The following command uses the `edge` channel but ASP.NET is fixed so it doesn't
```
> crank --config /crank/samples/hello/hello.benchmarks.yml --scenario hello --profile local --application.framework netcoreapp5.0 --application.channel edge --application.aspnetCoreVersion 5.0.0-preview.6.20279.12
-```
\ No newline at end of file
+```
+
+## Using the Build Cache channel
+
+The `buildcache` channel resolves the .NET runtime from the Build Cache Service (BCS), which caches pre-built runtime binaries for individual commits. This is useful for performance regression bisection where VMR feed gaps make it hard to pinpoint which commit caused a regression.
+
+### Basic usage (latest cached build on main)
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache
+```
+
+### Specific commit SHA
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheCommitSha a1b2c3d4e5f6...
+```
+
+If the commit is not found in the cache, crank will fail with an error rather than falling back.
+
+### Different branch
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheBranch release/10.0
+```
+
+### Mixed channels (BCS runtime + pinned ASP.NET)
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.aspNetCoreVersion 10.0.0-preview.3.26115.7
+```
+
+### Build Cache properties
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `buildCacheCommitSha` | (empty) | Specific runtime commit SHA. If empty, uses the latest cached build for the branch. |
+| `buildCacheBranch` | `main` | Branch to query for the latest build. |
+| `buildCacheConfig` | (auto-detected) | BCS configuration key (e.g., `coreclr_x64_linux`). Auto-detected from agent platform. |
+
+### Agent configuration
+
+The agent supports these command-line options for BCS:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--build-cache-base-url` | `https://pvscmdupload.z22.web.core.windows.net` | Base URL for BCS blob storage. |
+| `--build-cache-repo-name` | `runtime` | Repository name in BCS. |
+| `--build-cache-disabled` | (not set) | Disables BCS integration on this agent. |
diff --git a/src/Microsoft.Crank.Agent/BuildCacheClient.cs b/src/Microsoft.Crank.Agent/BuildCacheClient.cs
new file mode 100644
index 000000000..08d35f0ed
--- /dev/null
+++ b/src/Microsoft.Crank.Agent/BuildCacheClient.cs
@@ -0,0 +1,909 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Formats.Tar;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Crank.Agent
+{
+ ///
+ /// Lightweight client for the Build Caching Service (BCS) in dotnet-performance-infra.
+ /// Downloads pre-built runtime artifacts from public Azure Blob Storage and assembles a
+ /// per-job dotnet home (or overlays a self-contained published app) so the benchmark runs
+ /// against the BCS runtime instead of the feed-installed one.
+ ///
+ internal static class BuildCacheClient
+ {
+ private const int DownloadRetryCount = 3;
+ private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(10);
+ private static readonly TimeSpan _latestBuildsCacheDuration = TimeSpan.FromHours(1);
+
+ private static readonly HttpClient _httpClient = new HttpClient { Timeout = _httpTimeout };
+
+ // Cache latestBuilds.json responses to avoid repeated downloads (keyed by baseUrl|repo|branch).
+ private static readonly ConcurrentDictionary _latestBuildsCache = new();
+
+ // Per-(commit,config) async locks so concurrent jobs serialize their downloads/extracts.
+ private static readonly ConcurrentDictionary _extractLocks = new();
+
+ // Hex SHA-1 (full or short), 8-40 chars. BCS commits are runtime repo commits.
+ private static readonly Regex _shaRegex = new("^[0-9a-fA-F]{8,40}$", RegexOptions.Compiled);
+
+ ///
+ /// Maps the agent's platform (RID) to the BCS configuration key and artifact filename.
+ /// The reverse direction (config → RID) is used to decide which RID-shaped subtree to
+ /// look for inside the archive when the user supplies an explicit config override.
+ ///
+ internal static readonly IReadOnlyDictionary PlatformToBcsConfig =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["linux-x64"] = ("coreclr_x64_linux", "BuildArtifacts_linux_x64_Release_coreclr.tar.gz", "linux-x64"),
+ ["linux-arm64"] = ("coreclr_arm64_linux", "BuildArtifacts_linux_arm64_Release_coreclr.tar.gz", "linux-arm64"),
+ ["linux-musl-x64"] = ("coreclr_muslx64_linux", "BuildArtifacts_linux_musl_x64_Release_coreclr.tar.gz", "linux-musl-x64"),
+ ["win-x64"] = ("coreclr_x64_windows", "BuildArtifacts_windows_x64_Release_coreclr.zip", "win-x64"),
+ ["win-arm64"] = ("coreclr_arm64_windows", "BuildArtifacts_windows_arm64_Release_coreclr.zip", "win-arm64"),
+ ["win-x86"] = ("coreclr_x86_windows", "BuildArtifacts_windows_x86_Release_coreclr.zip", "win-x86"),
+ };
+
+ ///
+ /// Sentinel thrown for HTTP responses that are definitively not retryable (e.g. 404).
+ /// Distinguishes "the build doesn't exist" from "transient network blip".
+ ///
+ public class BuildCacheNotFoundException : InvalidOperationException
+ {
+ public BuildCacheNotFoundException(string message) : base(message) { }
+ }
+
+ ///
+ /// Validates a user-supplied commit SHA. Accepts 8-40 lowercase/uppercase hex chars.
+ ///
+ internal static void ValidateCommitSha(string commitSha)
+ {
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ return;
+ }
+
+ if (!_shaRegex.IsMatch(commitSha))
+ {
+ throw new ArgumentException(
+ $"'{commitSha}' is not a valid commit SHA. Expected 8-40 hex characters.",
+ nameof(commitSha));
+ }
+ }
+
+ ///
+ /// Resolves the commit SHA to use from BCS. If a specific commit is provided, returns it
+ /// (after platform-config inference). Otherwise queries latestBuilds.json for the latest
+ /// commit on the branch.
+ ///
+ public static async Task<(string commitSha, string buildCacheConfig)> ResolveCommitAsync(
+ string baseUrl,
+ string repoName,
+ string branch,
+ string commitSha,
+ string buildCacheConfig,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateCommitSha(commitSha);
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ var latestBuilds = await GetLatestBuildsAsync(baseUrl, repoName, branch, cancellationToken);
+
+ if (latestBuilds.Entries.TryGetValue(buildCacheConfig, out var configEntry) && !string.IsNullOrEmpty(configEntry.CommitSha))
+ {
+ commitSha = configEntry.CommitSha;
+ Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for config '{buildCacheConfig}' on branch '{branch}' (committed {configEntry.CommitTime})");
+ }
+ else if (latestBuilds.Entries.TryGetValue("all", out var allEntry) && !string.IsNullOrEmpty(allEntry.CommitSha))
+ {
+ commitSha = allEntry.CommitSha;
+ Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for all configs on branch '{branch}' (committed {allEntry.CommitTime})");
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ $"Build Cache: No latest build found for branch '{branch}' (config '{buildCacheConfig}'). Check that BCS has builds for this branch.");
+ }
+ }
+ else
+ {
+ Log.Info($"Build Cache: Using specified commit {ShortSha(commitSha)}");
+ }
+
+ return (commitSha, buildCacheConfig);
+ }
+
+ ///
+ /// Downloads and extracts BCS runtime artifacts to a per-call temp directory. The caller
+ /// owns the returned directory and is responsible for deleting it when done.
+ ///
+ public static async Task DownloadAndExtractAsync(
+ string baseUrl,
+ string repoName,
+ string commitSha,
+ string buildCacheConfig,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateCommitSha(commitSha);
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ throw new ArgumentException("commitSha must be provided.", nameof(commitSha));
+ }
+
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+ var artifactFile = GetArtifactFile(buildCacheConfig);
+ var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
+
+ var artifactUrl =
+ $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/buildArtifacts/" +
+ $"{Uri.EscapeDataString(commitSha)}/{Uri.EscapeDataString(buildCacheConfig)}/{Uri.EscapeDataString(artifactFile)}";
+
+ var rootCacheDir = Path.Combine(Path.GetTempPath(), "crank-buildcache");
+ Directory.CreateDirectory(rootCacheDir);
+
+ var safeConfig = SanitizeForPath(buildCacheConfig);
+
+ // Per-(commit,config) lock so two concurrent jobs don't race on the same archive download.
+ var lockKey = $"{commitSha}|{safeConfig}";
+ var gate = _extractLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken);
+ try
+ {
+ var commitDir = Path.Combine(rootCacheDir, commitSha);
+ Directory.CreateDirectory(commitDir);
+
+ var archivePath = Path.Combine(commitDir, $"{safeConfig}-{artifactFile}");
+
+ if (!File.Exists(archivePath))
+ {
+ Log.Info($"Build Cache: Downloading {artifactFile} from {artifactUrl}");
+ await DownloadWithRetryAsync(artifactUrl, archivePath, cancellationToken);
+ Log.Info($"Build Cache: Downloaded {new FileInfo(archivePath).Length / (1024 * 1024)} MB");
+ }
+ else
+ {
+ Log.Info($"Build Cache: Using cached archive at {archivePath}");
+ }
+
+ // Per-call unique extract dir so two concurrent jobs for the same (commit,config)
+ // never delete each other's working tree.
+ var extractDir = Path.Combine(commitDir, $"extracted-{safeConfig}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(extractDir);
+
+ Log.Info($"Build Cache: Extracting archive to {extractDir} ...");
+ await ExtractArchiveAsync(archivePath, extractDir, cancellationToken);
+
+ return extractDir;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Deletes a previously-extracted directory. Safe to call multiple times. Archives in the
+ /// parent commit dir are intentionally NOT deleted so subsequent jobs for the same commit
+ /// can reuse the download.
+ ///
+ public static void CleanupExtractDir(string extractDir)
+ {
+ if (string.IsNullOrEmpty(extractDir))
+ {
+ return;
+ }
+
+ try
+ {
+ if (Directory.Exists(extractDir))
+ {
+ Directory.Delete(extractDir, recursive: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Info($"Build Cache: Failed to clean up extracted dir '{extractDir}': {ex.Message}");
+ }
+ }
+
+ ///
+ /// Builds a per-job dotnet home that mirrors the relevant subtrees of the global dotnet
+ /// home (runtime, asp.net, host) and overlays BCS bits on top. The global dotnet home is
+ /// NOT modified, so concurrent jobs and subsequent non-buildcache jobs are unaffected.
+ ///
+ /// Absolute path to the per-job dotnet home root. Caller owns it.
+ public static string CreateBuildCacheDotnetHome(
+ string globalDotnetHome,
+ string extractDir,
+ string runtimeVersion,
+ string aspNetCoreVersion,
+ string commitSha,
+ string buildCacheConfig)
+ {
+ if (string.IsNullOrEmpty(runtimeVersion))
+ {
+ throw new ArgumentException("runtimeVersion must be provided.", nameof(runtimeVersion));
+ }
+
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+ var rid = GetRidForConfig(buildCacheConfig);
+
+ // Per-job, never reused across jobs to avoid pollution.
+ var bcsHomeRoot = Path.Combine(
+ Path.GetTempPath(),
+ "crank-buildcache",
+ $"home-{ShortSha(commitSha)}-{SanitizeForPath(buildCacheConfig)}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(bcsHomeRoot);
+
+ // 1. Copy the dotnet host binary.
+ var dotnetExeName = GetDotnetExecutableName();
+ var srcDotnet = Path.Combine(globalDotnetHome, dotnetExeName);
+ if (File.Exists(srcDotnet))
+ {
+ var dstDotnet = Path.Combine(bcsHomeRoot, dotnetExeName);
+ File.Copy(srcDotnet, dstDotnet, overwrite: true);
+ EnsureExecutable(dstDotnet);
+ }
+
+ // 2. Mirror host/fxr/{runtimeVersion} (small dir, ~1 file).
+ var srcHostFxr = Path.Combine(globalDotnetHome, "host", "fxr", runtimeVersion);
+ var dstHostFxr = Path.Combine(bcsHomeRoot, "host", "fxr", runtimeVersion);
+ if (Directory.Exists(srcHostFxr))
+ {
+ CopyDirectory(srcHostFxr, dstHostFxr);
+ }
+
+ // 3. Mirror shared/Microsoft.NETCore.App/{runtimeVersion}.
+ var srcNetCoreApp = Path.Combine(globalDotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ var dstNetCoreApp = Path.Combine(bcsHomeRoot, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ if (Directory.Exists(srcNetCoreApp))
+ {
+ CopyDirectory(srcNetCoreApp, dstNetCoreApp);
+ }
+
+ // 4. Mirror shared/Microsoft.AspNetCore.App/{aspNetCoreVersion}.
+ if (!string.IsNullOrEmpty(aspNetCoreVersion))
+ {
+ var srcAspNet = Path.Combine(globalDotnetHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ var dstAspNet = Path.Combine(bcsHomeRoot, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ if (Directory.Exists(srcAspNet))
+ {
+ CopyDirectory(srcAspNet, dstAspNet);
+ }
+ }
+
+ // 5. Overlay BCS managed + native into the per-job NETCore.App.
+ int filesOverlaid = 0;
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ if (nugetPackageDir != null)
+ {
+ var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid);
+ if (Directory.Exists(runtimesDir))
+ {
+ filesOverlaid += CopyManaged(runtimesDir, dstNetCoreApp);
+ filesOverlaid += CopyNative(runtimesDir, dstNetCoreApp);
+ }
+ }
+
+ // 6. Overlay BCS host binaries.
+ var corehostDir = FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstNetCoreApp, GetNativeLibName("hostpolicy"));
+
+ if (Directory.Exists(dstHostFxr))
+ {
+ filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstHostFxr, GetNativeLibName("hostfxr"));
+ }
+
+ var dstDotnetHost = Path.Combine(bcsHomeRoot, dotnetExeName);
+ var copied = CopyHostBinaryIfPresent(corehostDir, bcsHomeRoot, dotnetExeName);
+ if (copied > 0)
+ {
+ EnsureExecutable(dstDotnetHost);
+ }
+ filesOverlaid += copied;
+ }
+
+ if (filesOverlaid == 0)
+ {
+ // The per-job home would be just a copy of the feed runtime with no BCS bits.
+ // Tear it down and let the caller fail the job loudly.
+ try { Directory.Delete(bcsHomeRoot, recursive: true); } catch { }
+ throw new InvalidOperationException(
+ $"Build Cache: overlay copied 0 files for commit {ShortSha(commitSha)} (config '{buildCacheConfig}', rid '{rid}'). " +
+ "The archive layout may have changed or the platform is not supported.");
+ }
+
+ // 7. Rewrite .version so any consumer (the agent's own BenchmarksNetCoreAppVersion
+ // measurement, GetDependencies, etc.) reports the BCS commit.
+ File.WriteAllText(
+ Path.Combine(dstNetCoreApp, ".version"),
+ $"{commitSha}\n{runtimeVersion}\n");
+
+ Log.Info($"Build Cache: Per-job dotnet home built at {bcsHomeRoot} ({filesOverlaid} BCS files overlaid)");
+ return bcsHomeRoot;
+ }
+
+ ///
+ /// Overlays BCS runtime binaries (managed + native + apphost) into a self-contained
+ /// published output directory. For SCD the runtime ships next to the app, so this is the
+ /// only way to make the benchmark actually run BCS bits. The BCS apphost is renamed to
+ /// match the published app's executable name (the SDK renames apphost → AssemblyName).
+ ///
+ /// Number of files overlaid.
+ public static int OverlayPublishedOutput(
+ string extractDir,
+ string outputFolder,
+ string buildCacheConfig,
+ string assemblyName)
+ {
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+ var rid = GetRidForConfig(buildCacheConfig);
+ int filesCopied = 0;
+
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ if (nugetPackageDir != null)
+ {
+ var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid);
+ if (Directory.Exists(runtimesDir))
+ {
+ filesCopied += CopyManaged(runtimesDir, outputFolder);
+ filesCopied += CopyNative(runtimesDir, outputFolder);
+ }
+ }
+
+ var corehostDir = FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetNativeLibName("hostpolicy"));
+
+ // Intentionally NOT replacing the SDK-bound apphost. The BCS archive ships the raw,
+ // unbound apphost (the binary has a placeholder SHA-256 hash where the managed DLL
+ // path is encoded). The SDK's publish step normally invokes HostWriter.CreateAppHost
+ // to bake the managed entry-point path into that binary. Overlaying the raw BCS
+ // apphost on top of the SDK-bound one leaves the executable unable to locate its
+ // managed DLL and the app fails to start with:
+ //
+ // "This executable is not bound to a managed DLL to execute. The binding value
+ // is: ''"
+ //
+ // The perf-relevant runtime code (CoreCLR JIT, GC, managed BCL, hostfxr, hostpolicy)
+ // is still overlaid above. To overlay apphost as well, BCS would need to ship a
+ // pre-bound apphost per project, or the agent would need to invoke the apphost
+ // binder against the BCS apphost using the published app's binding metadata.
+ }
+
+ return filesCopied;
+ }
+
+ // --- HTTP / latestBuilds.json -------------------------------------------------
+
+ private static async Task GetLatestBuildsAsync(
+ string baseUrl, string repoName, string branch, CancellationToken cancellationToken)
+ {
+ var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
+ var cacheKey = $"{normalizedBaseUrl}|{repoName}|{branch}";
+
+ if (_latestBuildsCache.TryGetValue(cacheKey, out var cached) &&
+ DateTimeOffset.UtcNow - cached.fetchedAt < _latestBuildsCacheDuration)
+ {
+ return cached.data;
+ }
+
+ // Branch may contain slashes (e.g., "release/10.0"). Escape each segment but keep
+ // the slash semantics so the URL still resolves correctly on the server.
+ var escapedBranch = string.Join("/", branch.Split('/').Select(Uri.EscapeDataString));
+ var url = $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/latest/{escapedBranch}/latestBuilds.json";
+
+ Log.Info($"Build Cache: Fetching latest builds from {url}");
+
+ string json = null;
+
+ // 404s are not transient; pre-check before entering the retry loop.
+ await RetryTransientAsync(DownloadRetryCount, async () =>
+ {
+ using var response = await _httpClient.GetAsync(url, cancellationToken);
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ throw new BuildCacheNotFoundException(
+ $"Build Cache: No latest builds found for branch '{branch}' in repo '{repoName}'. URL: {url}");
+ }
+
+ response.EnsureSuccessStatusCode();
+ json = await response.Content.ReadAsStringAsync(cancellationToken);
+ });
+
+ var latestBuilds = ParseLatestBuilds(json);
+ _latestBuildsCache[cacheKey] = (DateTimeOffset.UtcNow, latestBuilds);
+ return latestBuilds;
+ }
+
+ private static async Task DownloadWithRetryAsync(string url, string destination, CancellationToken cancellationToken)
+ {
+ var partial = destination + ".partial";
+
+ await RetryTransientAsync(DownloadRetryCount, async () =>
+ {
+ if (File.Exists(partial))
+ {
+ File.Delete(partial);
+ }
+
+ using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
+ {
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Definitively not transient; do not retry.
+ throw new BuildCacheNotFoundException(
+ $"Build Cache: Artifact not found at {url}. The build may not exist in the cache.");
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ var expectedLength = response.Content.Headers.ContentLength;
+
+ using (var fileStream = File.Create(partial))
+ {
+ await response.Content.CopyToAsync(fileStream, cancellationToken);
+ }
+
+ if (expectedLength.HasValue)
+ {
+ var actual = new FileInfo(partial).Length;
+ if (actual != expectedLength.Value)
+ {
+ throw new InvalidOperationException(
+ $"Build Cache: Download size mismatch (expected {expectedLength.Value}, got {actual}). URL: {url}");
+ }
+ }
+ }
+
+ if (File.Exists(destination))
+ {
+ File.Delete(destination);
+ }
+
+ File.Move(partial, destination);
+ });
+ }
+
+ ///
+ /// Like
+ /// but rethrows immediately without retrying.
+ ///
+ private static async Task RetryTransientAsync(int retries, Func operation)
+ {
+ var attempts = 0;
+ while (true)
+ {
+ try
+ {
+ attempts++;
+ await operation();
+ return;
+ }
+ catch (BuildCacheNotFoundException)
+ {
+ // Non-retryable: fail fast.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ if (attempts > retries)
+ {
+ throw;
+ }
+
+ Log.Info($"Build Cache: Attempt {attempts} failed: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Parses the latestBuilds.json format from BCS. The JSON has dynamic keys for each
+ /// build configuration plus a "branch_name" / "BranchName" string property.
+ ///
+ internal static LatestBuildsResponse ParseLatestBuilds(string json)
+ {
+ var result = new LatestBuildsResponse();
+
+ using var doc = JsonDocument.Parse(json);
+
+ foreach (var property in doc.RootElement.EnumerateObject())
+ {
+ if (property.Name.Equals("branch_name", StringComparison.OrdinalIgnoreCase) ||
+ property.Name.Equals("BranchName", StringComparison.Ordinal))
+ {
+ if (property.Value.ValueKind == JsonValueKind.String)
+ {
+ result.BranchName = property.Value.GetString();
+ }
+ continue;
+ }
+
+ if (property.Value.ValueKind == JsonValueKind.Object)
+ {
+ var entry = new LatestBuildEntry
+ {
+ CommitSha = TryGetStringPropertyAnyCase(property.Value, "CommitSha", "commit_sha"),
+ CommitTime = TryGetStringPropertyAnyCase(property.Value, "CommitTime", "commit_time"),
+ };
+
+ result.Entries[property.Name] = entry;
+ }
+ }
+
+ return result;
+ }
+
+ private static string TryGetStringPropertyAnyCase(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String)
+ {
+ return value.GetString();
+ }
+ }
+
+ return null;
+ }
+
+ // --- Extraction ---------------------------------------------------------------
+
+ private static Task ExtractArchiveAsync(string archivePath, string outputDir, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(outputDir);
+
+ if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
+ {
+ return ExtractTarGzAsync(archivePath, outputDir, cancellationToken);
+ }
+
+ if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.Run(() => ZipFile.ExtractToDirectory(archivePath, outputDir, overwriteFiles: true), cancellationToken);
+ }
+
+ throw new InvalidOperationException($"Unsupported archive format: {archivePath}");
+ }
+
+ private static async Task ExtractTarGzAsync(string archivePath, string outputDir, CancellationToken cancellationToken)
+ {
+ await using var fs = File.OpenRead(archivePath);
+ await using var gz = new GZipStream(fs, CompressionMode.Decompress);
+ await TarFile.ExtractToDirectoryAsync(gz, outputDir, overwriteFiles: true, cancellationToken: cancellationToken);
+ }
+
+ // --- Overlay helpers ----------------------------------------------------------
+
+ ///
+ /// Selects the highest net{X}.0 directory under lib/ using a numeric comparison.
+ /// Lexicographic ordering puts "net9.0" above "net10.0", which would silently overlay
+ /// the wrong managed assemblies if BCS ever ships multiple TFMs.
+ ///
+ internal static string SelectHighestManagedDir(string libDir)
+ {
+ if (!Directory.Exists(libDir))
+ {
+ return null;
+ }
+
+ (int major, int minor, string path) Parse(string dir)
+ {
+ var name = Path.GetFileName(dir);
+ if (name.StartsWith("net", StringComparison.OrdinalIgnoreCase))
+ {
+ var rest = name.Substring(3);
+ var dot = rest.IndexOf('.');
+ if (dot > 0 &&
+ int.TryParse(rest.Substring(0, dot), out var maj) &&
+ int.TryParse(rest.Substring(dot + 1), out var min))
+ {
+ return (maj, min, dir);
+ }
+ }
+
+ return (-1, -1, dir);
+ }
+
+ return Directory.GetDirectories(libDir)
+ .Select(Parse)
+ .OrderByDescending(t => t.major)
+ .ThenByDescending(t => t.minor)
+ .Select(t => t.path)
+ .FirstOrDefault();
+ }
+
+ private static int CopyManaged(string runtimesDir, string destinationDir)
+ {
+ int copied = 0;
+ var libDir = Path.Combine(runtimesDir, "lib");
+ var managedDir = SelectHighestManagedDir(libDir);
+
+ if (managedDir == null)
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destinationDir);
+
+ foreach (var file in Directory.GetFiles(managedDir, "*.dll"))
+ {
+ var dest = Path.Combine(destinationDir, Path.GetFileName(file));
+ File.Copy(file, dest, overwrite: true);
+ copied++;
+ }
+
+ return copied;
+ }
+
+ private static int CopyNative(string runtimesDir, string destinationDir)
+ {
+ int copied = 0;
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ if (!Directory.Exists(nativeDir))
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destinationDir);
+
+ foreach (var file in Directory.GetFiles(nativeDir))
+ {
+ var fileName = Path.GetFileName(file);
+ if (fileName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) ||
+ fileName.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var dest = Path.Combine(destinationDir, fileName);
+ File.Copy(file, dest, overwrite: true);
+ EnsureExecutable(dest);
+ copied++;
+ }
+
+ return copied;
+ }
+
+ private static int CopyHostBinaryIfPresent(string sourceDir, string destDir, string fileName)
+ {
+ var sourcePath = Path.Combine(sourceDir, fileName);
+ if (!File.Exists(sourcePath))
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destDir);
+ var destPath = Path.Combine(destDir, fileName);
+ File.Copy(sourcePath, destPath, overwrite: true);
+ EnsureExecutable(destPath);
+ return 1;
+ }
+
+ ///
+ /// Recursively copies a directory tree. Used to materialize the per-job dotnet home
+ /// from the global feed-installed one. Native files and the dotnet host are chmod'd
+ /// executable on Unix-like systems.
+ ///
+ internal static void CopyDirectory(string sourceDir, string destDir)
+ {
+ Directory.CreateDirectory(destDir);
+
+ foreach (var file in Directory.EnumerateFiles(sourceDir))
+ {
+ var destFile = Path.Combine(destDir, Path.GetFileName(file));
+ File.Copy(file, destFile, overwrite: true);
+ EnsureExecutable(destFile);
+ }
+
+ foreach (var subDir in Directory.EnumerateDirectories(sourceDir))
+ {
+ CopyDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir)));
+ }
+ }
+
+ ///
+ /// On Unix-like systems, ensures the destination file has the user-execute bit set.
+ /// Native libs don't strictly require +x, but the dotnet host and apphost do, and the
+ /// File.Copy + overwrite path can drop the bit if the destination didn't have it.
+ ///
+ private static void EnsureExecutable(string path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ try
+ {
+ var current = File.GetUnixFileMode(path);
+ var withExec = current
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherExecute;
+ if (current != withExec)
+ {
+ File.SetUnixFileMode(path, withExec);
+ }
+ }
+ catch
+ {
+ // Best-effort; some filesystems (FAT, network shares) don't support mode bits.
+ }
+ }
+
+ private static string FindDirectory(string root, string directoryName)
+ {
+ if (!Directory.Exists(root))
+ {
+ return null;
+ }
+
+ foreach (var dir in Directory.GetDirectories(root))
+ {
+ if (Path.GetFileName(dir).Equals(directoryName, StringComparison.OrdinalIgnoreCase))
+ {
+ return dir;
+ }
+ }
+
+ return null;
+ }
+
+ private static string FindCorehostDirectory(string extractDir, string rid)
+ {
+ var primary = Path.Combine(extractDir, $"{rid}.Release", "corehost");
+ if (Directory.Exists(primary))
+ {
+ return primary;
+ }
+
+ var alternate = Path.Combine(extractDir, "corehost");
+ if (Directory.Exists(alternate))
+ {
+ return alternate;
+ }
+
+ return null;
+ }
+
+ // --- Platform / RID mapping ---------------------------------------------------
+
+ private static string ResolveBuildCacheConfig(string buildCacheConfig)
+ {
+ if (!string.IsNullOrEmpty(buildCacheConfig))
+ {
+ return buildCacheConfig;
+ }
+
+ var rid = GetPlatformMoniker();
+ if (PlatformToBcsConfig.TryGetValue(rid, out var mapped))
+ {
+ return mapped.configKey;
+ }
+
+ throw new InvalidOperationException(
+ $"No Build Cache configuration mapping for platform '{rid}'. Specify buildCacheConfig explicitly.");
+ }
+
+ private static string GetArtifactFile(string buildCacheConfig)
+ {
+ var match = PlatformToBcsConfig.Values.FirstOrDefault(v =>
+ string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase));
+
+ if (match.artifactFile == null)
+ {
+ throw new InvalidOperationException(
+ $"Unknown Build Cache configuration key: '{buildCacheConfig}'.");
+ }
+
+ return match.artifactFile;
+ }
+
+ ///
+ /// Maps a BCS config key back to its RID. Use this for overlay path discovery so an
+ /// explicit musl/cross-arch override actually finds the right runtime pack inside the
+ /// archive instead of falling back to the host's detected RID.
+ ///
+ internal static string GetRidForConfig(string buildCacheConfig)
+ {
+ var match = PlatformToBcsConfig.Values.FirstOrDefault(v =>
+ string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase));
+
+ if (match.rid == null)
+ {
+ throw new InvalidOperationException(
+ $"Unknown Build Cache configuration key: '{buildCacheConfig}'.");
+ }
+
+ return match.rid;
+ }
+
+ internal static string GetNativeLibName(string baseName)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return $"{baseName}.dll";
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return $"lib{baseName}.dylib";
+ }
+
+ return $"lib{baseName}.so";
+ }
+
+ private static string GetDotnetExecutableName()
+ => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+
+ internal static string GetPlatformMoniker()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.Arm64 => "win-arm64",
+ Architecture.X86 => "win-x86",
+ _ => "win-x64",
+ };
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64";
+ }
+
+ return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64";
+ }
+
+ private static string SanitizeForPath(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return "default";
+ }
+
+ var invalid = Path.GetInvalidFileNameChars();
+ return string.Concat(value.Select(c => invalid.Contains(c) ? '_' : c));
+ }
+
+ internal static string ShortSha(string commitSha)
+ => string.IsNullOrEmpty(commitSha)
+ ? string.Empty
+ : commitSha.Substring(0, Math.Min(8, commitSha.Length));
+
+ // --- DTOs ---------------------------------------------------------------------
+
+ internal class LatestBuildsResponse
+ {
+ public string BranchName { get; set; }
+ public Dictionary Entries { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+ }
+
+ internal class LatestBuildEntry
+ {
+ public string CommitSha { get; set; }
+ public string CommitTime { get; set; }
+ }
+ }
+}
diff --git a/src/Microsoft.Crank.Agent/JobContext.cs b/src/Microsoft.Crank.Agent/JobContext.cs
index a358b0e42..6959636bb 100644
--- a/src/Microsoft.Crank.Agent/JobContext.cs
+++ b/src/Microsoft.Crank.Agent/JobContext.cs
@@ -32,6 +32,21 @@ public class JobContext
public string DockerImage { get; set; }
public string DockerContainerId { get; set; }
+ ///
+ /// Per-job isolated dotnet home used for buildcache runs. When non-null, the agent should
+ /// run the application using this root (so the BCS-overlaid runtime is loaded), and the
+ /// global dotnet home is left untouched for concurrent jobs. The directory is owned by
+ /// the job and deleted when the job completes.
+ ///
+ public string BuildCacheDotnetHome { get; set; }
+
+ ///
+ /// Temporary directory holding the extracted BCS archive for this job. Deleted at the
+ /// end of the job; the underlying archive in the parent commit directory is kept so
+ /// subsequent jobs for the same commit can reuse it without re-downloading.
+ ///
+ public string BuildCacheExtractDir { get; set; }
+
public ulong EventPipeSessionId { get; set; }
public Task EventPipeTask { get; set; }
public bool EventPipeTerminated { get; set; }
diff --git a/src/Microsoft.Crank.Agent/Startup.cs b/src/Microsoft.Crank.Agent/Startup.cs
index 8168a2e88..be2dbf116 100644
--- a/src/Microsoft.Crank.Agent/Startup.cs
+++ b/src/Microsoft.Crank.Agent/Startup.cs
@@ -109,6 +109,11 @@ public class Startup
"https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2"
];
+ // Build Cache Service configuration
+ private static string _buildCacheBaseUrl = "https://pvscmdupload.z22.web.core.windows.net";
+ private static string _buildCacheRepoName = "runtime";
+ private static bool _buildCacheEnabled = true;
+
// Cached lists of SDKs and runtimes already installed
private static readonly HashSet _installedAspNetRuntimes = new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet _installedDotnetRuntimes = new(StringComparer.OrdinalIgnoreCase);
@@ -270,6 +275,10 @@ public static int Main(string[] args)
_certSniAuth = app.Option("--cert-sni", "Enable subject name / issuer based authentication (SNI).", CommandOptionType.NoValue);
_managedIdentityClientId = app.Option("--mi-client-id", "Client ID of the user-assigned managed identity to use for authentication.", CommandOptionType.SingleValue);
+ var buildCacheBaseUrlOption = app.Option("--build-cache-base-url", $"Base URL for Build Cache Service blob storage. Default is '{_buildCacheBaseUrl}'.", CommandOptionType.SingleValue);
+ var buildCacheRepoNameOption = app.Option("--build-cache-repo-name", $"Repository name for Build Cache Service. Default is '{_buildCacheRepoName}'.", CommandOptionType.SingleValue);
+ var buildCacheDisabledOption = app.Option("--build-cache-disabled", "Disable Build Cache Service integration.", CommandOptionType.NoValue);
+
app.OnExecute(() =>
{
var logConf = new LoggerConfiguration()
@@ -278,6 +287,21 @@ public static int Main(string[] args)
.Enrich.FromLogContext()
.WriteTo.Console(theme: AnsiConsoleTheme.Code);
+ if (buildCacheBaseUrlOption.HasValue())
+ {
+ _buildCacheBaseUrl = buildCacheBaseUrlOption.Value();
+ }
+
+ if (buildCacheRepoNameOption.HasValue())
+ {
+ _buildCacheRepoName = buildCacheRepoNameOption.Value();
+ }
+
+ if (buildCacheDisabledOption.HasValue())
+ {
+ _buildCacheEnabled = false;
+ }
+
if (_runAsService.HasValue() && OperatingSystem != OperatingSystem.Windows)
{
throw new PlatformNotSupportedException($"--service is only available on Windows");
@@ -1015,7 +1039,7 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca
{
buildAndRunTask = Task.Run(async () =>
{
- benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, cts.Token);
+ benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, context, cts.Token);
if (benchmarksDir == null)
{
@@ -1027,7 +1051,10 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca
{
try
{
- process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, _dotnethome, context);
+ // For buildcache jobs the per-job isolated dotnet home holds the
+ // BCS-overlaid runtime; run against it instead of the global home.
+ var runtimeDotnetHome = context.BuildCacheDotnetHome ?? _dotnethome;
+ process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, runtimeDotnetHome, context);
Log.Info($"Process started: {job.ProcessId}");
@@ -1910,6 +1937,22 @@ async Task DeleteJobAsync()
await TryDeleteDirAsync(tempDir);
}
+ // Build Cache: clean up per-job extracted artifacts and the isolated
+ // dotnet home so concurrent / future jobs do not see stale state and
+ // /tmp does not accumulate multi-GB extracts.
+ if (_cleanup && !job.NoClean)
+ {
+ BuildCacheClient.CleanupExtractDir(context.BuildCacheExtractDir);
+
+ if (!string.IsNullOrEmpty(context.BuildCacheDotnetHome))
+ {
+ await TryDeleteDirAsync(context.BuildCacheDotnetHome);
+ }
+
+ context.BuildCacheExtractDir = null;
+ context.BuildCacheDotnetHome = null;
+ }
+
// Delete temporary attachment files
// NB: Attachments are already deleted once they are copied, unless the job fails
// to reach that point.
@@ -2832,7 +2875,7 @@ private static async Task DockerCleanUpAsync(string containerId, string imageNam
}
}
- private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, CancellationToken cancellationToken = default)
+ private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, JobContext jobContext = null, CancellationToken cancellationToken = default)
{
var reuseFolder = await RetrieveSourcesAsync(job, path);
@@ -2933,23 +2976,83 @@ private static async Task CloneRestoreAndBuild(string path, Job job, str
runtimeVersion = channel;
}
+ // For buildcache channel, SDK/ASP.NET/Desktop use "latest" since BCS only has runtime
+ var nonRuntimeChannel = String.Equals(channel, "buildcache", StringComparison.OrdinalIgnoreCase) ? "latest" : channel;
+
if (String.IsNullOrEmpty(desktopVersion))
{
- desktopVersion = channel;
+ desktopVersion = nonRuntimeChannel;
}
if (String.IsNullOrEmpty(aspNetCoreVersion))
{
- aspNetCoreVersion = channel;
+ aspNetCoreVersion = nonRuntimeChannel;
}
if (String.IsNullOrEmpty(sdkVersion))
{
- sdkVersion = channel;
+ sdkVersion = nonRuntimeChannel;
}
runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, runtimeVersion);
+ // Build Cache Service: if the runtime version is "BuildCache", prepare BCS artifacts
+ // and resolve a real "Latest" runtime version for the NuGet build.
+ var useBuildCache = String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase);
+ string buildCacheCommitSha = null;
+ string buildCacheExtractDir = null;
+
+ string buildCacheConfigResolved = null;
+ if (useBuildCache)
+ {
+ if (!_buildCacheEnabled)
+ {
+ job.Error = "Build Cache channel was requested but Build Cache Service is disabled on this agent (--build-cache-disabled).";
+ return null;
+ }
+
+ // Validate user-supplied commit SHA early so we can fail with a clear message instead
+ // of throwing later from a Substring call.
+ if (!string.IsNullOrEmpty(job.BuildCacheCommitSha) && job.BuildCacheCommitSha.Length < 8)
+ {
+ job.Error = $"Build Cache: 'buildCacheCommitSha' must be at least 8 characters long (got '{job.BuildCacheCommitSha}').";
+ return null;
+ }
+
+ try
+ {
+ var branch = !string.IsNullOrEmpty(job.BuildCacheBranch) ? job.BuildCacheBranch : "main";
+ var commitSha = job.BuildCacheCommitSha;
+ var buildCacheConfig = job.BuildCacheConfig;
+
+ // Resolve which commit and config to use
+ var resolved = await BuildCacheClient.ResolveCommitAsync(
+ _buildCacheBaseUrl, _buildCacheRepoName, branch, commitSha, buildCacheConfig, cancellationToken);
+
+ buildCacheCommitSha = resolved.commitSha;
+ buildCacheConfigResolved = resolved.buildCacheConfig;
+
+ // Download and extract the BCS artifacts to a per-job temp directory
+ buildCacheExtractDir = await BuildCacheClient.DownloadAndExtractAsync(
+ _buildCacheBaseUrl, _buildCacheRepoName, buildCacheCommitSha, buildCacheConfigResolved,
+ cancellationToken);
+
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+ Log.Info($"Build Cache: Artifacts for commit {shortSha} ready for post-build overlay");
+
+ // Resolve a REAL runtime version from feeds for the NuGet build. We deliberately keep
+ // runtimeVersion pointing at this feed-resolved version so PatchRuntimeConfig and the
+ // dotnet-install steps agree; the BCS bits are overlaid on top of that exact version.
+ runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, "Latest");
+ Log.Info($"Runtime for build: {runtimeVersion} (Latest from feeds, will be overlaid with BCS commit {shortSha})");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: {ex.Message}";
+ return null;
+ }
+ }
+
sdkVersion = await ResolveSdkVersion(sdkVersion, targetFramework);
aspNetCoreVersion = await ResolveAspNetCoreVersion(aspNetCoreVersion, targetFramework);
@@ -3206,6 +3309,44 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
var dotnetDir = dotnetHome;
+ // Build Cache: build a per-job dotnet home that contains BCS-overlaid runtime + asp.net
+ // + host. We DO NOT mutate the global dotnet home — concurrent jobs and subsequent
+ // non-buildcache jobs must remain unaffected. The publish step continues to use the
+ // global dotnetDir (it has the SDK); only the runtime-resolution paths (metadata
+ // reading, crossgen/symbols emit, StartProcess) point at the per-job home.
+ var runtimeHomeDir = dotnetDir;
+
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ try
+ {
+ var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome(
+ dotnetDir,
+ buildCacheExtractDir,
+ runtimeVersion,
+ aspNetCoreVersion,
+ buildCacheCommitSha,
+ job.BuildCacheConfig);
+
+ runtimeHomeDir = bcsHome;
+
+ // Stash on the JobContext so StartProcess uses this isolated home (FDD) and
+ // so the cleanup pass at job end can delete it.
+ if (jobContext != null)
+ {
+ jobContext.BuildCacheDotnetHome = bcsHome;
+ jobContext.BuildCacheExtractDir = buildCacheExtractDir;
+ }
+
+ Log.Info($"Build Cache: Isolated dotnet home: {bcsHome}");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: failed to build isolated dotnet home: {ex.Message}";
+ return null;
+ }
+ }
+
// Updating Job to reflect actual versions used
job.AspNetCoreVersion = aspNetCoreVersion;
job.RuntimeVersion = runtimeVersion;
@@ -3239,7 +3380,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
try
{
- var aspNetCoreVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version");
+ var aspNetCoreVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version");
(_, var aspnetCoreCommitHash) = await ParseLatestVersionFile(aspNetCoreVersionFileName);
job.Metadata.Enqueue(new MeasurementMetadata
@@ -3272,7 +3413,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
try
{
- var netCoreAppVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version");
+ var netCoreAppVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version");
(_, var netCoreAppCommitHash) = await ParseLatestVersionFile(netCoreAppVersionFileName);
job.Metadata.Enqueue(new MeasurementMetadata
@@ -3434,6 +3575,50 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
Log.Info($"Application published successfully in {job.BuildTime.TotalMilliseconds} ms");
+ // Build Cache: overlay BCS runtime binaries onto the just-published app. The
+ // per-job dotnet home was built earlier (used for FDD execution + metadata);
+ // here we cover the SCD case where the runtime ships in the publish output.
+ // PatchRuntimeConfig still runs with the feed-resolved runtimeVersion so
+ // runtimeconfig.json points to a real installed shared-framework dir.
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+
+ int publishedOverlay;
+ try
+ {
+ var publishProjectFileName = Path.Combine(benchmarkedApp, FormatPathSeparators(job.Project));
+ var assemblyName = GetAssemblyName(job, publishProjectFileName);
+
+ publishedOverlay = BuildCacheClient.OverlayPublishedOutput(
+ buildCacheExtractDir,
+ outputFolder,
+ job.BuildCacheConfig,
+ assemblyName);
+
+ Log.Info($"Build Cache: Overlaid {publishedOverlay} files into published output (commit {shortSha})");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: published-output overlay failed: {ex.Message}";
+ return null;
+ }
+
+ // For self-contained publishes the published output must contain runtime binaries
+ // (managed + native + apphost). For framework-dependent publishes 0 is acceptable
+ // here because the per-job dotnet home already provides BCS bits at runtime.
+ if (job.SelfContained && publishedOverlay == 0)
+ {
+ job.Error = $"Build Cache: published-output overlay copied 0 files for self-contained " +
+ $"commit {shortSha}. The archive layout may have changed or the platform is not supported.";
+ return null;
+ }
+
+ // Record the BCS commit alongside the runtime version for reporting. We append rather than
+ // replace so PatchRuntimeConfig still sees a valid feed-resolved version below.
+ job.RuntimeVersion = $"{runtimeVersion}+buildcache.{shortSha}";
+ }
+
PatchRuntimeConfig(job, outputFolder, aspNetCoreVersion, runtimeVersion);
}
@@ -3532,7 +3717,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
var crossgenFolder = job.SelfContained
? outputFolder
- : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
+ : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
;
var crossgenFilename = Path.Combine(crossgenFolder, "crossgen");
@@ -3560,7 +3745,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
var symbolsFolder = job.SelfContained
? outputFolder
- : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
+ : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
;
// dotnet symbol --symbols --output mySymbols /usr/share/dotnet/shared/Microsoft.NETCore.App/2.1.0/lib*.so
@@ -4582,6 +4767,13 @@ private static async Task ResolveRuntimeVersion(string buildToolsPath, s
break;
}
}
+ else if (String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase))
+ {
+ // BuildCache channel: version resolution is deferred to InstallRuntimeFromBuildCacheAsync
+ // because it needs to download artifacts. We return a placeholder here.
+ runtimeVersion = "BuildCache";
+ Log.Info($"Runtime: will be resolved from Build Cache Service");
+ }
else
{
// Custom version
diff --git a/src/Microsoft.Crank.Models/Job.cs b/src/Microsoft.Crank.Models/Job.cs
index 1128cd0f0..ee1537cb4 100644
--- a/src/Microsoft.Crank.Models/Job.cs
+++ b/src/Microsoft.Crank.Models/Job.cs
@@ -71,6 +71,11 @@ public class Job
public string UseMonoRuntime { get; set; } = "";
public bool NoGlobalJson { get; set; }
+ // Build Cache Service properties for per-commit runtime resolution
+ public string BuildCacheCommitSha { get; set; } = "";
+ public string BuildCacheBranch { get; set; } = "";
+ public string BuildCacheConfig { get; set; } = "";
+
// Delay from the process started to the console receiving "Application started"
public TimeSpan StartupMainMethod { get; set; }
public TimeSpan BuildTime { get; set; }
@@ -399,7 +404,10 @@ public BuildKeyData GetBuildKeyData()
DockerPull = DockerPull,
DockerFile = DockerFile,
DockerImageName = DockerImageName,
- DockerContextDirectory = DockerContextDirectory
+ DockerContextDirectory = DockerContextDirectory,
+ BuildCacheCommitSha = BuildCacheCommitSha,
+ BuildCacheBranch = BuildCacheBranch,
+ BuildCacheConfig = BuildCacheConfig
};
}
@@ -508,5 +516,8 @@ public class BuildKeyData
public string DockerFile { get; set; }
public string DockerImageName { get; set; }
public string DockerContextDirectory { get; set; }
+ public string BuildCacheCommitSha { get; set; }
+ public string BuildCacheBranch { get; set; }
+ public string BuildCacheConfig { get; set; }
}
}
diff --git a/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
new file mode 100644
index 000000000..21cfe42bd
--- /dev/null
+++ b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
@@ -0,0 +1,578 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Microsoft.Crank.Agent;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Crank.UnitTests
+{
+ public class BuildCacheClientTests : IDisposable
+ {
+ private readonly ITestOutputHelper _output;
+ private readonly string _testDir;
+
+ public BuildCacheClientTests(ITestOutputHelper output)
+ {
+ _output = output;
+ _testDir = Path.Combine(Path.GetTempPath(), "crank_buildcache_tests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_testDir);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_testDir))
+ {
+ Directory.Delete(_testDir, true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // ParseLatestBuilds
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void ParseLatestBuilds_PascalCase_ParsesCommitShaAndTime()
+ {
+ const string json = """
+ {
+ "BranchName": "main",
+ "coreclr_x64_linux": {
+ "CommitSha": "abc123def456",
+ "CommitTime": "2025-01-01T00:00:00Z"
+ }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal("main", result.BranchName);
+ Assert.Equal("abc123def456", result.Entries["coreclr_x64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_SnakeCase_ParsesCommitShaAndTime()
+ {
+ const string json = """
+ {
+ "branch_name": "release/10.0",
+ "coreclr_arm64_linux": {
+ "commit_sha": "deadbeef",
+ "commit_time": "2025-02-02T00:00:00Z"
+ }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal("release/10.0", result.BranchName);
+ Assert.Equal("deadbeef", result.Entries["coreclr_arm64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_MixedCasing_ParsesAllConfigs()
+ {
+ const string json = """
+ {
+ "branch_name": "main",
+ "coreclr_x64_windows": { "CommitSha": "win123", "CommitTime": "2025-03-03" },
+ "coreclr_x64_linux": { "commit_sha": "lnx456", "commit_time": "2025-04-04" }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal(2, result.Entries.Count);
+ Assert.Equal("win123", result.Entries["coreclr_x64_windows"].CommitSha);
+ Assert.Equal("lnx456", result.Entries["coreclr_x64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_NonObjectValues_AreSkipped()
+ {
+ const string json = """
+ {
+ "branch_name": "main",
+ "schemaVersion": 2,
+ "lastUpdated": "2025-01-01",
+ "coreclr_x64_linux": { "CommitSha": "abc" }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Single(result.Entries);
+ Assert.True(result.Entries.ContainsKey("coreclr_x64_linux"));
+ }
+
+ // -------------------------------------------------------------------
+ // ValidateCommitSha
+ // -------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("abcdef12")] // min length
+ [InlineData("ABCDEF12")] // upper hex
+ [InlineData("603403d9cb49d3d1c35b56bcff024ce99a8c5c3a")] // full 40
+ public void ValidateCommitSha_AcceptsValid(string sha)
+ {
+ BuildCacheClient.ValidateCommitSha(sha);
+ }
+
+ [Theory]
+ [InlineData("abc")] // too short
+ [InlineData("ghijklmn")] // non-hex
+ [InlineData("abcd 1234")] // contains space
+ [InlineData("../../../etc/passwd")] // path traversal attempt
+ [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] // 41 chars, too long
+ public void ValidateCommitSha_RejectsInvalid(string sha)
+ {
+ Assert.Throws(() => BuildCacheClient.ValidateCommitSha(sha));
+ }
+
+ // -------------------------------------------------------------------
+ // ShortSha
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void ShortSha_LongInput_ReturnsFirstEight()
+ {
+ Assert.Equal("abcdef12", BuildCacheClient.ShortSha("abcdef1234567890"));
+ }
+
+ [Fact]
+ public void ShortSha_ShortInput_ReturnsAsIs()
+ {
+ Assert.Equal("abc", BuildCacheClient.ShortSha("abc"));
+ }
+
+ [Fact]
+ public void ShortSha_NullOrEmpty_ReturnsEmpty()
+ {
+ Assert.Equal(string.Empty, BuildCacheClient.ShortSha(null));
+ Assert.Equal(string.Empty, BuildCacheClient.ShortSha(""));
+ }
+
+ // -------------------------------------------------------------------
+ // Platform / RID mapping
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void GetPlatformMoniker_ReturnsKnownRid()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+
+ var validRids = new[]
+ {
+ "linux-x64", "linux-arm64",
+ "win-x64", "win-arm64", "win-x86",
+ "osx-x64", "osx-arm64",
+ };
+
+ Assert.Contains(rid, validRids);
+ }
+
+ [Theory]
+ [InlineData("coreclr_x64_linux", "linux-x64")]
+ [InlineData("coreclr_arm64_linux", "linux-arm64")]
+ [InlineData("coreclr_muslx64_linux", "linux-musl-x64")]
+ [InlineData("coreclr_x64_windows", "win-x64")]
+ [InlineData("coreclr_arm64_windows", "win-arm64")]
+ [InlineData("coreclr_x86_windows", "win-x86")]
+ public void GetRidForConfig_ReturnsMatchingRid(string configKey, string expectedRid)
+ {
+ Assert.Equal(expectedRid, BuildCacheClient.GetRidForConfig(configKey));
+ }
+
+ [Fact]
+ public void GetRidForConfig_UnknownConfig_Throws()
+ {
+ Assert.Throws(() => BuildCacheClient.GetRidForConfig("totally_unknown"));
+ }
+
+ // -------------------------------------------------------------------
+ // SelectHighestManagedDir (numeric-aware)
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void SelectHighestManagedDir_NumericOrderNotLexicographic()
+ {
+ var libDir = Path.Combine(_testDir, "lib");
+ Directory.CreateDirectory(Path.Combine(libDir, "net8.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net9.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net10.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net11.0"));
+
+ // Lexicographic: net9.0 > net8.0 > net11.0 > net10.0 (wrong).
+ // Numeric: net11.0 > net10.0 > net9.0 > net8.0 (correct).
+ var selected = BuildCacheClient.SelectHighestManagedDir(libDir);
+
+ Assert.Equal("net11.0", Path.GetFileName(selected));
+ }
+
+ [Fact]
+ public void SelectHighestManagedDir_NoDirs_ReturnsNull()
+ {
+ var libDir = Path.Combine(_testDir, "empty-lib");
+ Directory.CreateDirectory(libDir);
+
+ Assert.Null(BuildCacheClient.SelectHighestManagedDir(libDir));
+ }
+
+ [Fact]
+ public void SelectHighestManagedDir_MissingDir_ReturnsNull()
+ {
+ Assert.Null(BuildCacheClient.SelectHighestManagedDir(Path.Combine(_testDir, "does-not-exist")));
+ }
+
+ // -------------------------------------------------------------------
+ // OverlayPublishedOutput
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void OverlayPublishedOutput_CopiesRuntimeFilesAndHostpolicyButNotApphost()
+ {
+ // The BCS archive ships an unbound apphost (the SDK normally binds the published
+ // managed DLL path into the executable during publish). Overlaying the raw BCS apphost
+ // on top of the SDK-bound one breaks the published app, so we deliberately skip it.
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: true);
+
+ var outputFolder = Path.Combine(_testDir, "published");
+ Directory.CreateDirectory(outputFolder);
+
+ // Pre-existing SDK-bound apphost that must NOT be overwritten.
+ var apphostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MyApp.exe" : "MyApp";
+ File.WriteAllText(Path.Combine(outputFolder, apphostName), "SDK_BOUND_APPHOST");
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+
+ // managed + native + hostpolicy (no apphost contribution)
+ Assert.True(copied >= managed.Count + native.Count + 1);
+
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, dll)), $"Missing managed file {dll}");
+ }
+ foreach (var n in native)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, n)), $"Missing native file {n}");
+ }
+
+ Assert.True(File.Exists(Path.Combine(outputFolder, BuildCacheClient.GetNativeLibName("hostpolicy"))));
+
+ // SDK-bound apphost preserved.
+ Assert.Equal("SDK_BOUND_APPHOST", File.ReadAllText(Path.Combine(outputFolder, apphostName)));
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_EmptyExtract_ReturnsZero()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+
+ var extractDir = Path.Combine(_testDir, "empty");
+ Directory.CreateDirectory(extractDir);
+
+ var outputFolder = Path.Combine(_testDir, "output");
+ Directory.CreateDirectory(outputFolder);
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+ Assert.Equal(0, copied);
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_SkipsPdbAndDbg()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, runtimesDir, _, _) = BuildFakeBcsArchive(rid, includeHost: false, includeApphost: false);
+
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ File.WriteAllText(Path.Combine(nativeDir, "coreclr.pdb"), "pdb");
+ File.WriteAllText(Path.Combine(nativeDir, "libcoreclr.dbg"), "dbg");
+
+ var outputFolder = Path.Combine(_testDir, "published-pdb");
+ Directory.CreateDirectory(outputFolder);
+
+ BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+
+ Assert.False(File.Exists(Path.Combine(outputFolder, "coreclr.pdb")));
+ Assert.False(File.Exists(Path.Combine(outputFolder, "libcoreclr.dbg")));
+ }
+
+ // -------------------------------------------------------------------
+ // CreateBuildCacheDotnetHome — the heart of round 3
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_MirrorsGlobalAndOverlaysBcs()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5.26256.117";
+ const string aspNetCoreVersion = "11.0.0-preview.5.26256.117";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion);
+ var commitSha = "603403d9cb49d3d1c35b56bcff024ce99a8c5c3a";
+
+ var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion, commitSha, configKey);
+
+ try
+ {
+ // 1. Global dotnet home must NOT be touched (no cross-job pollution).
+ var globalVersion = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ Assert.Contains("FEED_COMMIT", globalVersion);
+ Assert.DoesNotContain(commitSha, globalVersion);
+
+ // 2. Per-job home exists with BCS overlay applied.
+ Assert.True(Directory.Exists(bcsHome));
+ var bcsNetCoreApp = Path.Combine(bcsHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, dll)), $"Missing BCS managed {dll}");
+ }
+ foreach (var n in native)
+ {
+ Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, n)), $"Missing BCS native {n}");
+ }
+
+ // 3. .version was rewritten with BCS commit.
+ var bcsVersion = File.ReadAllText(Path.Combine(bcsNetCoreApp, ".version"));
+ Assert.Contains(commitSha, bcsVersion);
+
+ // 4. ASP.NET Core dir was mirrored (from global, not overlaid).
+ Assert.True(Directory.Exists(Path.Combine(bcsHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion)));
+
+ // 5. dotnet host binary is present.
+ var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+ Assert.True(File.Exists(Path.Combine(bcsHome, dotnetExeName)));
+
+ // 6. host/fxr was mirrored AND overlaid.
+ var hostFxrFile = Path.Combine(bcsHome, "host", "fxr", runtimeVersion, BuildCacheClient.GetNativeLibName("hostfxr"));
+ Assert.True(File.Exists(hostFxrFile));
+ }
+ finally
+ {
+ try { Directory.Delete(bcsHome, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_NoBcsBitsForPlatform_Throws()
+ {
+ // Build a BCS archive layout for an RID that doesn't match the host RID, so the
+ // overlay finds nothing.
+ var hostRid = BuildCacheClient.GetPlatformMoniker();
+ var wrongRid = hostRid == "linux-x64" ? "win-x64" : "linux-x64";
+ var (extractDir, _, _, _) = BuildFakeBcsArchive(wrongRid, includeHost: false, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5";
+ const string aspNetCoreVersion = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion);
+
+ // Will resolve config from host RID and search for hostRid-shaped subtree → 0 files.
+ var ex = Assert.Throws(() =>
+ BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion,
+ "abcdef0123456789", buildCacheConfig: null));
+
+ Assert.Contains("0 files", ex.Message);
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_TwoConcurrentJobs_AreIsolated()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir1, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+ var (extractDir2, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, runtimeVersion);
+ var sha1 = "1111aaaa2222bbbb3333cccc4444dddd55556666";
+ var sha2 = "6666eeee7777ffff8888aaaa9999bbbbccccdddd";
+
+ var home1 = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir1, runtimeVersion, runtimeVersion, sha1, configKey);
+ var home2 = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir2, runtimeVersion, runtimeVersion, sha2, configKey);
+
+ try
+ {
+ Assert.NotEqual(home1, home2);
+
+ var v1 = File.ReadAllText(Path.Combine(home1, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ var v2 = File.ReadAllText(Path.Combine(home2, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+
+ Assert.Contains(sha1, v1);
+ Assert.DoesNotContain(sha2, v1);
+ Assert.Contains(sha2, v2);
+ Assert.DoesNotContain(sha1, v2);
+
+ // Global home untouched.
+ var globalV = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ Assert.DoesNotContain(sha1, globalV);
+ Assert.DoesNotContain(sha2, globalV);
+ }
+ finally
+ {
+ try { Directory.Delete(home1, recursive: true); } catch { }
+ try { Directory.Delete(home2, recursive: true); } catch { }
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // CleanupExtractDir
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void CleanupExtractDir_DeletesDirectory()
+ {
+ var dir = Path.Combine(_testDir, "cleanup-target");
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "x.txt"), "hi");
+
+ BuildCacheClient.CleanupExtractDir(dir);
+
+ Assert.False(Directory.Exists(dir));
+ }
+
+ [Fact]
+ public void CleanupExtractDir_MissingDir_DoesNotThrow()
+ {
+ BuildCacheClient.CleanupExtractDir(Path.Combine(_testDir, "never-existed"));
+ BuildCacheClient.CleanupExtractDir(null);
+ BuildCacheClient.CleanupExtractDir("");
+ }
+
+ // -------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------
+
+ private static string ConfigKeyForRid(string rid)
+ => BuildCacheClient.PlatformToBcsConfig.TryGetValue(rid, out var v) ? v.configKey : null;
+
+ ///
+ /// Builds a fake "global" dotnet home with .version files containing a FEED commit so
+ /// tests can detect whether the .version was overwritten with the BCS commit.
+ ///
+ private string BuildFakeGlobalDotnetHome(string runtimeVersion, string aspNetCoreVersion)
+ {
+ var home = Path.Combine(_testDir, "global-home-" + Guid.NewGuid().ToString("N"));
+ var netCoreApp = Path.Combine(home, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ var aspNetCoreApp = Path.Combine(home, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ var hostFxr = Path.Combine(home, "host", "fxr", runtimeVersion);
+
+ Directory.CreateDirectory(netCoreApp);
+ Directory.CreateDirectory(aspNetCoreApp);
+ Directory.CreateDirectory(hostFxr);
+
+ File.WriteAllText(Path.Combine(netCoreApp, ".version"), "FEED_COMMIT_DO_NOT_TOUCH\n" + runtimeVersion + "\n");
+ File.WriteAllText(Path.Combine(netCoreApp, "System.Private.CoreLib.dll"), "feed managed");
+ File.WriteAllText(Path.Combine(netCoreApp, BuildCacheClient.GetNativeLibName("hostpolicy")), "feed hostpolicy");
+
+ File.WriteAllText(Path.Combine(aspNetCoreApp, ".version"), "FEED_ASPNET\n" + aspNetCoreVersion + "\n");
+ File.WriteAllText(Path.Combine(aspNetCoreApp, "Microsoft.AspNetCore.dll"), "feed aspnet");
+
+ File.WriteAllText(Path.Combine(hostFxr, BuildCacheClient.GetNativeLibName("hostfxr")), "feed hostfxr");
+
+ var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+ File.WriteAllText(Path.Combine(home, dotnetExeName), "feed dotnet host");
+
+ return home;
+ }
+
+ ///
+ /// Builds a fake BCS extraction at microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/
+ /// + corehost layout. adds the renamed-by-SDK apphost binary.
+ ///
+ private (string extractDir, string runtimesDir, List managed, List native)
+ BuildFakeBcsArchive(string rid, bool includeHost, bool includeApphost)
+ {
+ var extractDir = Path.Combine(_testDir, "extracted-" + Guid.NewGuid().ToString("N"));
+ var nugetPkg = Path.Combine(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ var runtimesDir = Path.Combine(nugetPkg, "Release", "runtimes", rid);
+ var libDir = Path.Combine(runtimesDir, "lib", "net11.0");
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ Directory.CreateDirectory(libDir);
+ Directory.CreateDirectory(nativeDir);
+
+ var managed = new List
+ {
+ "System.Private.CoreLib.dll",
+ "System.Runtime.dll",
+ "System.Console.dll",
+ };
+ foreach (var dll in managed)
+ {
+ File.WriteAllText(Path.Combine(libDir, dll), "BCS managed " + dll);
+ }
+
+ List native;
+ if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase))
+ {
+ native = new List { "coreclr.dll", "clrjit.dll" };
+ }
+ else if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase))
+ {
+ native = new List { "libcoreclr.dylib", "libclrjit.dylib" };
+ }
+ else
+ {
+ native = new List { "libcoreclr.so", "libclrjit.so" };
+ }
+ foreach (var n in native)
+ {
+ File.WriteAllText(Path.Combine(nativeDir, n), "BCS native " + n);
+ }
+
+ if (includeHost)
+ {
+ var hostDir = Path.Combine(extractDir, $"{rid}.Release", "corehost");
+ Directory.CreateDirectory(hostDir);
+ File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostpolicy")), "BCS hostpolicy");
+ File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostfxr")), "BCS hostfxr");
+ File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "dotnet.exe" : "dotnet"), "BCS dotnet host");
+
+ if (includeApphost)
+ {
+ File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "apphost.exe" : "apphost"), "BCS apphost");
+ }
+ }
+
+ return (extractDir, runtimesDir, managed, native);
+ }
+
+ private static string NativeLibForRid(string rid, string baseName)
+ {
+ if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{baseName}.dll";
+ }
+ if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"lib{baseName}.dylib";
+ }
+ return $"lib{baseName}.so";
+ }
+ }
+}