Skip to content

Commit 521a617

Browse files
davidfowlCopilot
andauthored
Optimize AppHost project inspection (#17246)
Centralize .NET AppHost MSBuild metadata inspection, coalesce per-process probes, and add a content-keyed disk cache so warm no-build startup can skip repeated project evaluations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2d57e64 commit 521a617

16 files changed

Lines changed: 1274 additions & 83 deletions

src/Aspire.Cli/Caching/AppHostInfoDiskCache.cs

Lines changed: 349 additions & 0 deletions
Large diffs are not rendered by default.

src/Aspire.Cli/JsonSourceGenerationContext.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.Text.Json;
66
using System.Text.Json.Serialization;
77
using System.Text.Json.Nodes;
8+
using Aspire.Cli.Caching;
9+
using Aspire.Cli.Projects;
810
using Aspire.Cli.Certificates;
911
using Aspire.Cli.Commands;
1012
using Aspire.Cli.Configuration;
@@ -49,6 +51,8 @@ namespace Aspire.Cli;
4951
[JsonSerializable(typeof(IntegrationSearchResult[]))]
5052
[JsonSerializable(typeof(string[]))]
5153
[JsonSerializable(typeof(List<CandidateAppHostDisplayInfo>))]
54+
[JsonSerializable(typeof(AppHostInfoCacheEntry))]
55+
[JsonSerializable(typeof(AppHostProjectInspectionOutput))]
5256
internal partial class JsonSourceGenerationContext : JsonSerializerContext
5357
{
5458
private static JsonSourceGenerationContext? s_relaxedEscaping;

src/Aspire.Cli/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, CliStartu
385385

386386
builder.Services.AddTransient<IDotNetCliRunner, DotNetCliRunner>();
387387
builder.Services.AddSingleton<IDiskCache, DiskCache>();
388+
builder.Services.AddSingleton<IAppHostInfoDiskCache, AppHostInfoDiskCache>();
389+
builder.Services.AddSingleton<IAppHostInfoResolver, AppHostInfoResolver>();
388390
builder.Services.AddSingleton<IDotNetSdkInstaller, DotNetSdkInstaller>();
389391
builder.Services.AddTransient<IAppHostCliBackchannel, AppHostCliBackchannel>();
390392

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Concurrent;
5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
using Aspire.Cli.Caching;
8+
using Aspire.Cli.DotNet;
9+
10+
namespace Aspire.Cli.Projects;
11+
12+
internal interface IAppHostInfoResolver
13+
{
14+
Task<AppHostProjectInfo> GetAppHostInfoAsync(FileInfo projectFile, CancellationToken cancellationToken);
15+
}
16+
17+
internal sealed class AppHostInfoResolver(IDotNetCliRunner runner, IAppHostInfoDiskCache diskCache) : IAppHostInfoResolver
18+
{
19+
private readonly ConcurrentDictionary<(string Path, DateTime LastWriteUtc), Task<AppHostProjectInfo>> _cache = new();
20+
21+
public async Task<AppHostProjectInfo> GetAppHostInfoAsync(FileInfo projectFile, CancellationToken cancellationToken)
22+
{
23+
// Refresh so FileInfo reflects the on-disk mtime even when callers pass a long-lived instance.
24+
projectFile.Refresh();
25+
var key = (projectFile.FullName, projectFile.LastWriteTimeUtc);
26+
var task = GetOrAddSharedFetch(key, projectFile);
27+
28+
try
29+
{
30+
// The MSBuild probe is shared by all callers for this project snapshot, so it cannot
31+
// use any one caller's token. Apply cancellation to each waiter instead; otherwise one
32+
// cancelled validation could cancel the shared probe for unrelated callers.
33+
var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false);
34+
if (result.ExitCode != 0)
35+
{
36+
// Transient MSBuild failures should not poison long-lived CLI processes.
37+
_cache.TryRemove(key, out _);
38+
}
39+
40+
return result;
41+
}
42+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested && !task.IsCompleted)
43+
{
44+
throw;
45+
}
46+
catch
47+
{
48+
// A cancelled or faulted in-flight probe must not become the answer for later runs.
49+
_cache.TryRemove(key, out _);
50+
throw;
51+
}
52+
}
53+
54+
private Task<AppHostProjectInfo> GetOrAddSharedFetch((string Path, DateTime LastWriteUtc) key, FileInfo projectFile)
55+
{
56+
while (true)
57+
{
58+
if (_cache.TryGetValue(key, out var existingTask))
59+
{
60+
return existingTask;
61+
}
62+
63+
// ConcurrentDictionary.GetOrAdd can run value factories more than once under contention.
64+
// Add a TaskCompletionSource placeholder first, then run the MSBuild probe only if this
65+
// caller won the TryAdd race. Losers loop back and await the winner's task.
66+
var completion = new TaskCompletionSource<AppHostProjectInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
67+
if (_cache.TryAdd(key, completion.Task))
68+
{
69+
_ = CompleteSharedFetchAsync(key, projectFile, completion);
70+
return completion.Task;
71+
}
72+
}
73+
}
74+
75+
private async Task CompleteSharedFetchAsync((string Path, DateTime LastWriteUtc) key, FileInfo projectFile, TaskCompletionSource<AppHostProjectInfo> completion)
76+
{
77+
try
78+
{
79+
var result = await FetchAppHostInfoCoreAsync(projectFile, CancellationToken.None).ConfigureAwait(false);
80+
completion.TrySetResult(result);
81+
}
82+
catch (Exception ex)
83+
{
84+
// Match the retry behavior in GetAppHostInfoAsync: transient MSBuild/cache failures
85+
// should complete all current waiters, then allow the next caller to try again.
86+
completion.TrySetException(ex);
87+
_cache.TryRemove(key, out _);
88+
}
89+
}
90+
91+
private async Task<AppHostProjectInfo> FetchAppHostInfoCoreAsync(FileInfo projectFile, CancellationToken cancellationToken)
92+
{
93+
// First, see if a previous CLI invocation already cached the answer on disk. The
94+
// cache key includes mtimes of the tracked inputs that affect this metadata query, so a
95+
// hit means those inputs have not changed since that previous evaluation. This is not a
96+
// build-output cache; normal build/no-build semantics still decide whether .cs changes are
97+
// compiled before the AppHost runs.
98+
var diskEntry = await diskCache.TryGetAsync(projectFile, cancellationToken).ConfigureAwait(false);
99+
if (diskEntry is not null)
100+
{
101+
return new AppHostProjectInfo(
102+
ExitCode: diskEntry.ExitCode,
103+
IsAspireHost: diskEntry.IsAspireHost,
104+
AspireHostingVersion: diskEntry.AspireHostingVersion,
105+
IsUsingCliBundle: diskEntry.IsUsingCliBundle,
106+
UserSecretsId: diskEntry.UserSecretsId);
107+
}
108+
109+
// Capture the input fingerprint before evaluating MSBuild. If any tracked input changes
110+
// while MSBuild is running, SetAsync will skip writing this now-stale result.
111+
var expectedCacheKey = diskCache.GetCacheKey(projectFile);
112+
113+
// Mirror the property/item shape used by DotNetCliRunner.GetAppHostInformationAsync and
114+
// additionally request AspireUseCliBundle and UserSecretsId so the CLI bundle handoff
115+
// and --isolated user-secrets clone do not require their own MSBuild evaluations.
116+
// Adding extra -getProperty names is an evaluation-only cost.
117+
var (exitCode, jsonDocument) = await runner.GetProjectItemsAndPropertiesAsync(
118+
projectFile,
119+
items: ["PackageReference", "AspireProjectOrPackageReference", "PackageVersion"],
120+
properties: ["IsAspireHost", "AspireHostingSDKVersion", "AspireUseCliBundle", "UserSecretsId"],
121+
new ProcessInvocationOptions(),
122+
cancellationToken).ConfigureAwait(false);
123+
124+
if (exitCode != 0 || jsonDocument is null)
125+
{
126+
// Do not persist failure responses to memory or disk — a transient MSBuild error should
127+
// not sit in a cache and short-circuit later successful evaluations.
128+
// DotNetCliRunner already logged the failing MSBuild stdout/stderr before returning
129+
// null here; keep the non-zero exit code so callers can surface the normal
130+
// "not buildable AppHost" project-resolution behavior instead of treating this as a
131+
// negative cache entry.
132+
return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: false, UserSecretsId: null);
133+
}
134+
135+
AppHostProjectInfo info;
136+
using (jsonDocument)
137+
{
138+
var msbuildOutput = JsonSerializer.Deserialize(
139+
jsonDocument.RootElement,
140+
JsonSourceGenerationContext.Default.AppHostProjectInspectionOutput);
141+
142+
info = ParseAppHostInfo(msbuildOutput, exitCode);
143+
}
144+
145+
// Persist successful evaluations so the next CLI process can short-circuit MSBuild
146+
// entirely when the inputs are unchanged. Failures inside the cache write are
147+
// swallowed; cache misses never break a run.
148+
await diskCache.SetAsync(projectFile, expectedCacheKey, new AppHostInfoCacheEntry
149+
{
150+
ExitCode = info.ExitCode,
151+
IsAspireHost = info.IsAspireHost,
152+
AspireHostingVersion = info.AspireHostingVersion,
153+
IsUsingCliBundle = info.IsUsingCliBundle,
154+
UserSecretsId = info.UserSecretsId,
155+
}, cancellationToken).ConfigureAwait(false);
156+
157+
return info;
158+
}
159+
160+
private static AppHostProjectInfo ParseAppHostInfo(AppHostProjectInspectionOutput? msbuildOutput, int exitCode)
161+
{
162+
var properties = msbuildOutput?.Properties;
163+
if (properties is null)
164+
{
165+
return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: false, UserSecretsId: null);
166+
}
167+
168+
var isUsingCliBundle = string.Equals(properties.AspireUseCliBundle, "true", StringComparison.OrdinalIgnoreCase);
169+
170+
var userSecretsId = string.IsNullOrWhiteSpace(properties.UserSecretsId) ? null : properties.UserSecretsId;
171+
172+
var isAspireHost = string.Equals(properties.IsAspireHost, "true", StringComparison.Ordinal);
173+
174+
if (!isAspireHost)
175+
{
176+
return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: false, AspireHostingVersion: null, IsUsingCliBundle: isUsingCliBundle, UserSecretsId: userSecretsId);
177+
}
178+
179+
// Try to get Aspire.Hosting version from PackageReference items first, then fall back
180+
// to AspireProjectOrPackageReference (for SDK-provided refs) and PackageVersion (CPM),
181+
// then finally to the SDK version. Mirrors DotNetCliRunner.GetAppHostInformationAsync.
182+
string? aspireHostingVersion = null;
183+
184+
var items = msbuildOutput?.Items;
185+
if (items is not null)
186+
{
187+
aspireHostingVersion = GetPackageVersionFromItems(items.PackageReference, "Aspire.Hosting")
188+
?? GetPackageVersionFromItems(items.PackageReference, "Aspire.Hosting.AppHost");
189+
190+
aspireHostingVersion ??= GetPackageVersionFromItems(items.AspireProjectOrPackageReference, "Aspire.Hosting")
191+
?? GetPackageVersionFromItems(items.AspireProjectOrPackageReference, "Aspire.Hosting.AppHost");
192+
193+
aspireHostingVersion ??= GetPackageVersionFromItems(items.PackageVersion, "Aspire.Hosting")
194+
?? GetPackageVersionFromItems(items.PackageVersion, "Aspire.Hosting.AppHost");
195+
}
196+
197+
aspireHostingVersion ??= properties.AspireHostingSDKVersion;
198+
199+
return new AppHostProjectInfo(ExitCode: exitCode, IsAspireHost: true, AspireHostingVersion: aspireHostingVersion, IsUsingCliBundle: isUsingCliBundle, UserSecretsId: userSecretsId);
200+
}
201+
202+
private static string? GetPackageVersionFromItems(IReadOnlyList<AppHostProjectInspectionItem>? items, string packageId)
203+
{
204+
if (items is null)
205+
{
206+
return null;
207+
}
208+
209+
foreach (var item in items)
210+
{
211+
if (string.Equals(item.Identity, packageId, StringComparison.Ordinal))
212+
{
213+
return item.Version;
214+
}
215+
}
216+
217+
return null;
218+
}
219+
}
220+
221+
internal sealed record AppHostProjectInfo(
222+
int ExitCode,
223+
bool IsAspireHost,
224+
string? AspireHostingVersion,
225+
bool IsUsingCliBundle,
226+
string? UserSecretsId);
227+
228+
internal sealed record AppHostProjectInspectionOutput
229+
{
230+
[JsonPropertyName("Properties")]
231+
public AppHostProjectInspectionProperties? Properties { get; init; }
232+
233+
[JsonPropertyName("Items")]
234+
public AppHostProjectInspectionItems? Items { get; init; }
235+
}
236+
237+
internal sealed record AppHostProjectInspectionProperties
238+
{
239+
[JsonPropertyName("IsAspireHost")]
240+
public string? IsAspireHost { get; init; }
241+
242+
[JsonPropertyName("AspireHostingSDKVersion")]
243+
public string? AspireHostingSDKVersion { get; init; }
244+
245+
[JsonPropertyName("AspireUseCliBundle")]
246+
public string? AspireUseCliBundle { get; init; }
247+
248+
[JsonPropertyName("UserSecretsId")]
249+
public string? UserSecretsId { get; init; }
250+
}
251+
252+
internal sealed record AppHostProjectInspectionItems
253+
{
254+
[JsonPropertyName("PackageReference")]
255+
public IReadOnlyList<AppHostProjectInspectionItem>? PackageReference { get; init; }
256+
257+
[JsonPropertyName("AspireProjectOrPackageReference")]
258+
public IReadOnlyList<AppHostProjectInspectionItem>? AspireProjectOrPackageReference { get; init; }
259+
260+
[JsonPropertyName("PackageVersion")]
261+
public IReadOnlyList<AppHostProjectInspectionItem>? PackageVersion { get; init; }
262+
}
263+
264+
internal sealed record AppHostProjectInspectionItem
265+
{
266+
[JsonPropertyName("Identity")]
267+
public string? Identity { get; init; }
268+
269+
[JsonPropertyName("Version")]
270+
public string? Version { get; init; }
271+
}

0 commit comments

Comments
 (0)