// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.Documentation;
using Aspire.Cli.DotNet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Projects;
/// <summary>
/// AppHost server project for local Aspire development that uses the .NET SDK to build.
/// Uses project references to the local Aspire repository (ASPIRE_REPO_ROOT).
/// </summary>
internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject
{
private const string ProjectHashFileName = ".projecthash";
private const string AppsFolder = "hosts";
/// <summary>
/// Bump when the scaffold's shape changes in a way that requires a rewrite even though the
/// generated content for a given input would hash the same as a previously-cached scaffold.
/// </summary>
private const int ScaffoldSchemaVersion = 1;
public const string ProjectFileName = "AppHostServer.csproj";
private const string ProjectDllName = "AppHostServer.dll";
internal const string TargetFramework = "net10.0";
public const string BuildFolder = "build";
private const string AssemblyName = "AppHostServer";
private readonly string _projectModelPath;
private readonly string _appPath;
private readonly string _socketPath;
private readonly string _userSecretsId;
private readonly string _repoRoot;
private readonly IDotNetCliRunner _dotNetCliRunner;
private readonly IPackagingService _packagingService;
private readonly IProcessExecutionFactory _processExecutionFactory;
private readonly IEnvironment _environment;
private readonly ILogger _logger;
private readonly string? _logFilePath;
public DotNetBasedAppHostServerProject(
string appPath,
string socketPath,
string repoRoot,
IDotNetCliRunner dotNetCliRunner,
IPackagingService packagingService,
IProcessExecutionFactory processExecutionFactory,
IEnvironment environment,
ILogger<DotNetBasedAppHostServerProject> logger,
string? projectModelPath = null,
string? logFilePath = null)
{
_appPath = Path.GetFullPath(appPath);
_appPath = new Uri(_appPath).LocalPath;
_appPath = OperatingSystem.IsWindows() ? _appPath.ToLowerInvariant() : _appPath;
_socketPath = socketPath;
_repoRoot = Path.GetFullPath(repoRoot) + Path.DirectorySeparatorChar;
_dotNetCliRunner = dotNetCliRunner;
_packagingService = packagingService;
_processExecutionFactory = processExecutionFactory;
_environment = environment;
_logger = logger;
_logFilePath = logFilePath;
var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath));
if (projectModelPath is not null)
{
_projectModelPath = projectModelPath;
}
else
{
var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
_projectModelPath = Path.Combine(CliPathHelper.GetAspireHomeDirectory(), AppsFolder, pathDir);
}
// Create a stable UserSecretsId based on the app path hash
_userSecretsId = new Guid(pathHash[..16]).ToString();
Directory.CreateDirectory(_projectModelPath);
}
/// <inheritdoc />
public string AppDirectoryPath => _appPath;
public string ProjectModelPath => _projectModelPath;
public string UserSecretsId => _userSecretsId;
public string BuildPath => Path.Combine(_projectModelPath, BuildFolder);
internal string? LogFilePath => _logFilePath;
/// <summary>
/// Gets the full path to the AppHost server project file.
/// </summary>
public string GetProjectFilePath() => Path.Combine(_projectModelPath, ProjectFileName);
private string GetProjectHash()
{
var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName);
if (File.Exists(hashFilePath))
{
return File.ReadAllText(hashFilePath);
}
return string.Empty;
}
private void SaveProjectHash(string hash)
{
var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName);
File.WriteAllText(hashFilePath, hash);
}
/// <summary>
/// Creates the project .csproj content using project references to the local Aspire repository.
/// </summary>
private XDocument CreateProjectFile(IEnumerable<IntegrationReference> integrations)
{
// Determine OS/architecture for DCP package name
var (buildOs, buildArch) = GetBuildPlatform();
var dcpPackageName = $"microsoft.developercontrolplane.{buildOs}-{buildArch}";
var dcpVersion = GetDcpVersionFromRepo(_repoRoot, buildOs, buildArch);
var template = $"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>exe</OutputType>
<TargetFramework>{TargetFramework}</TargetFramework>
<AssemblyName>{AssemblyName}</AssemblyName>
<OutDir>{BuildFolder}</OutDir>
<UserSecretsId>{_userSecretsId}</UserSecretsId>
<IsAspireHost>true</IsAspireHost>
<IsPublishable>false</IsPublishable>
<SelfContained>false</SelfContained>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningLevel>0</WarningLevel>
<EnableNETAnalyzers>false</EnableNETAnalyzers>
<EnableRoslynAnalyzers>false</EnableRoslynAnalyzers>
<RunAnalyzers>false</RunAnalyzers>
<NoWarn>$(NoWarn);1701;1702;1591;CS8019;CS1591;CS1573;CS0168;CS0219;CS8618;CS8625;CS1998;CS1999</NoWarn>
<!-- Properties for in-repo building -->
<RepoRoot>{_repoRoot}</RepoRoot>
<SkipValidateAspireHostProjectResources>true</SkipValidateAspireHostProjectResources>
<SkipAddAspireDefaultReferences>true</SkipAddAspireDefaultReferences>
<SkipAspireIntegrationAnalyzersReference>true</SkipAspireIntegrationAnalyzersReference>
<AspireHostingSDKVersion>42.42.42</AspireHostingSDKVersion>
<!-- DCP and Dashboard paths for local development -->
<DcpDir>$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)')){dcpPackageName}/{dcpVersion}/tools/</DcpDir>
<AspireDashboardDir>{_repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/</AspireDashboardDir>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="Google.Protobuf" />
</ItemGroup>
</Project>
""";
var doc = XDocument.Parse(template);
// Add project references for Aspire.Hosting.* packages, NuGet for others
var projectRefGroup = new XElement("ItemGroup");
var addedProjects = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var otherPackages = new List<(string Name, string Version)>();
foreach (var integration in integrations)
{
if (integration.IsProjectReference)
{
// Explicit project reference from settings.json
if (addedProjects.Add(integration.Name))
{
projectRefGroup.Add(new XElement("ProjectReference",
new XAttribute("Include", integration.ProjectPath!),
new XElement("IsAspireProjectResource", "false")));
}
}
else if (integration.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase) &&
!integration.DisableLocalProjectSubstitution)
{
var projectPath = Path.Combine(_repoRoot, "src", integration.Name, $"{integration.Name}.csproj");
if (File.Exists(projectPath) && addedProjects.Add(integration.Name))
{
projectRefGroup.Add(new XElement("ProjectReference",
new XAttribute("Include", projectPath),
new XElement("IsAspireProjectResource", "false")));
}
}
else
{
if (integration.Version is null)
{
throw new InvalidOperationException($"Integration '{integration.Name}' is neither a project reference nor a package reference (both Version and ProjectPath are null).");
}
otherPackages.Add((integration.Name, integration.Version));
}
}
// Always add Aspire.Hosting project reference
var hostingPath = Path.Combine(_repoRoot, "src", "Aspire.Hosting", "Aspire.Hosting.csproj");
if (File.Exists(hostingPath) && addedProjects.Add("Aspire.Hosting"))
{
projectRefGroup.Add(new XElement("ProjectReference",
new XAttribute("Include", hostingPath),
new XElement("IsAspireProjectResource", "false")));
}
if (projectRefGroup.HasElements)
{
doc.Root!.Add(projectRefGroup);
}
if (otherPackages.Count > 0)
{
doc.Root!.Add(new XElement("ItemGroup",
otherPackages.Select(p => new XElement("PackageReference",
new XAttribute("Include", p.Name),
new XAttribute("VersionOverride", p.Version)))));
}
// Add imports for in-repo AppHost building
var appHostInTargets = Path.Combine(_repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets");
var sdkInTargets = Path.Combine(_repoRoot, "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets");
if (File.Exists(appHostInTargets))
{
doc.Root!.Add(new XElement("Import", new XAttribute("Project", appHostInTargets)));
}
if (File.Exists(sdkInTargets))
{
doc.Root!.Add(new XElement("Import", new XAttribute("Project", sdkInTargets)));
}
// Add Dashboard and RemoteHost project references
var dashboardProject = Path.Combine(_repoRoot, "src", "Aspire.Dashboard", "Aspire.Dashboard.csproj");
if (File.Exists(dashboardProject))
{
doc.Root!.Add(new XElement("ItemGroup",
new XElement("ProjectReference", new XAttribute("Include", dashboardProject))));
}
var remoteHostProject = Path.Combine(_repoRoot, "src", "Aspire.Hosting.RemoteHost", "Aspire.Hosting.RemoteHost.csproj");
if (File.Exists(remoteHostProject))
{
doc.Root!.Add(new XElement("ItemGroup",
new XElement("ProjectReference", new XAttribute("Include", remoteHostProject))));
}
// Disable Aspire SDK code generation
doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteHostProjectMetadataSources")));
doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteProjectMetadataSources")));
return doc;
}
/// <summary>
/// Scaffolds the project files.
/// </summary>
public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync(
IEnumerable<IntegrationReference> integrations,
string? requestedChannel = null,
string? packageSourceOverride = null,
CancellationToken cancellationToken = default)
{
// Create Program.cs
var programCs = """
await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args);
""";
File.WriteAllText(Path.Combine(_projectModelPath, "Program.cs"), programCs);
// Create appsettings.json with ATS assemblies
var atsAssemblies = new List<string> { "Aspire.Hosting" };
foreach (var integration in integrations)
{
// Skip SDK-only packages that don't produce runtime assemblies
if (integration.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) ||
integration.Name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!atsAssemblies.Contains(integration.Name, StringComparer.OrdinalIgnoreCase))
{
atsAssemblies.Add(integration.Name);
}
}
var assembliesJson = string.Join(",\n ", atsAssemblies.Select(a => $"\"{a}\""));
var appSettingsJson = $$"""
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
},
"AtsAssemblies": [
{{assembliesJson}}
]
}
""";
// Handle NuGet config and channel resolution
string? channelName = null;
var userNugetConfig = FindNuGetConfig(_appPath);
var nugetConfigContent = userNugetConfig is not null
? File.ReadAllText(userNugetConfig)
: null;
var configuredChannelName = requestedChannel
?? AspireConfigFile.Load(_appPath)?.Channel
?? AspireJsonConfiguration.Load(_appPath)?.Channel;
var channels = await _packagingService.GetChannelsAsync(cancellationToken, configuredChannelName);
// Resolve channel sources and add them via RestoreAdditionalProjectSources
// This is additive — it preserves the user's nuget.config and adds channel-specific sources
var channelSources = new List<string>();
var matchedChannels = !string.IsNullOrEmpty(configuredChannelName)
? channels.Where(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase))
: channels.Where(c => c.Type == PackageChannelType.Explicit);
foreach (var ch in matchedChannels)
{
channelName ??= ch.Name;
if (ch.Mappings is not null)
{
foreach (var mapping in ch.Mappings)
{
if (!channelSources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase))
{
channelSources.Add(mapping.Source);
}
}
}
}
// Thread an explicit `--source` override into the restore sources so the dogfood
// `aspire new --source <pr-hive>` flow is honored in dev mode (in-repo). Prepending
// makes the override the first source NuGet evaluates, which matters when the same
// Aspire package version exists in both the hive and a channel feed. Note: unlike
// PrebuiltAppHostServer this path does not emit Package Source Mappings, so NuGet
// may still consult other sources if the override does not satisfy a request — the
// override is best-effort here, sufficient for the in-repo developer scenario where
// most Aspire.* dependencies come from ProjectReference, not PackageReference.
if (!string.IsNullOrWhiteSpace(packageSourceOverride) &&
!channelSources.Contains(packageSourceOverride, StringComparer.OrdinalIgnoreCase))
{
channelSources.Insert(0, packageSourceOverride);
}
// Create the project file
var doc = CreateProjectFile(integrations);
// Add channel sources to the project
if (channelSources.Count > 0)
{
var sourceList = string.Join(";", channelSources);
doc.Root!.Descendants("PropertyGroup").First()
.Add(new XElement("RestoreAdditionalProjectSources", sourceList));
}
// Add appsettings.json to output
doc.Root!.Add(new XElement("ItemGroup",
new XElement("None",
new XAttribute("Include", "appsettings.json"),
new XAttribute("CopyToOutputDirectory", "PreserveNewest"))));
// Create Directory.Packages.props to enable central package management
// This ensures transitive dependencies use versions from the repo's Directory.Packages.props
var repoDirectoryPackagesProps = Path.Combine(_repoRoot, "Directory.Packages.props");
var directoryPackagesProps = $"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<Import Project="{repoDirectoryPackagesProps}" />
</Project>
""";
var projectFileName = Path.Combine(_projectModelPath, ProjectFileName);
// Log the full project XML for debugging
_logger.LogTrace("Generated AppHostServer project file:\n{ProjectXml}", doc.ToString());
// Every file this method owns, keyed by name relative to _projectModelPath. Writing the
// scaffold wipes obj/, so the whole set is fingerprinted together and rewritten only when
// something actually changed. Directory.Build.props/targets are deliberately empty: they
// stop MSBuild walking up into the repo's Arcade infrastructure, which rewrites project
// reference paths and breaks resolution from this directory.
var scaffold = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Program.cs"] = programCs,
["appsettings.json"] = appSettingsJson,
["Directory.Packages.props"] = directoryPackagesProps,
["Directory.Build.props"] = "<Project />",
["Directory.Build.targets"] = "<Project />",
[ProjectFileName] = doc.ToString(),
};
// nuget.config is copied from the user's config rather than generated, so its *content*
// participates in the fingerprint. When the user's config disappears the key is absent,
// which both busts the hash and drives deletion of the stale copy below.
if (nugetConfigContent is not null)
{
scaffold["nuget.config"] = nugetConfigContent;
}
if (TryReuseScaffold(scaffold))
{
_logger.LogDebug("AppHostServer scaffold is up to date; preserving restore artifacts in {ProjectModelPath}", _projectModelPath);
return (projectFileName, channelName);
}
WriteScaffold(scaffold, doc);
return (projectFileName, channelName);
}
/// <summary>
/// Determines whether the on-disk scaffold already matches <paramref name="scaffold"/> and is
/// backed by a usable NuGet restore, in which case it can be left alone.
/// </summary>
/// <remarks>
/// Rewriting the scaffold deletes obj/, which discards project.assets.json and the generated
/// nuget.g.props/targets. That forces a full restore and a full project-reference graph walk on
/// the next build. Doing that on every launch dominated in-repo polyglot startup, so the
/// destructive path is now taken only when the generated content actually changes.
///
/// This deliberately does not attempt to detect changes to the repo sources this project
/// references. <c>BuildAsync</c> still runs on every launch, so MSBuild's own incremental
/// build remains the source of truth for those; skipping the scaffold only preserves the
/// restore output that describes an unchanged project.
/// </remarks>
private bool TryReuseScaffold(Dictionary<string, string> scaffold)
{
var fingerprint = ComputeScaffoldFingerprint(scaffold);
if (!string.Equals(GetProjectHash(), fingerprint, StringComparison.Ordinal))
{
return false;
}
// A matching fingerprint only says the *inputs* are unchanged. The outputs still have to be
// present: a half-written scaffold or a hand-deleted file would otherwise be cached forever.
foreach (var fileName in scaffold.Keys)
{
if (!File.Exists(Path.Combine(_projectModelPath, fileName)))
{
return false;
}
}
// Preserving obj/ is the entire point of the cache, so there has to be something worth
// preserving. Without project.assets.json the next build would restore from scratch anyway,
// and an interrupted restore can leave obj/ populated but unusable.
if (!File.Exists(Path.Combine(_projectModelPath, "obj", "project.assets.json")))
{
return false;
}
return true;
}
private void WriteScaffold(Dictionary<string, string> scaffold, XDocument projectDocument)
{
// Clean obj folder to ensure fresh NuGet restore
var objPath = Path.Combine(_projectModelPath, "obj");
if (Directory.Exists(objPath))
{
try
{
Directory.Delete(objPath, recursive: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath);
}
}
foreach (var (fileName, content) in scaffold)
{
// The csproj is written through XDocument.Save so it keeps its XML declaration;
// the dictionary holds only the declaration-less string form used for hashing.
if (string.Equals(fileName, ProjectFileName, StringComparison.Ordinal))
{
continue;
}
File.WriteAllText(Path.Combine(_projectModelPath, fileName), content);
}
projectDocument.Save(Path.Combine(_projectModelPath, ProjectFileName));
// The user's nuget.config can be removed between launches. Leaving our copy behind would
// keep feeding the build sources the user has deleted.
if (!scaffold.ContainsKey("nuget.config"))
{
var staleNugetConfig = Path.Combine(_projectModelPath, "nuget.config");
if (File.Exists(staleNugetConfig))
{
File.Delete(staleNugetConfig);
}
}
// Persisted last so an interrupted write can never be mistaken for a complete scaffold.
SaveProjectHash(ComputeScaffoldFingerprint(scaffold));
}
private static string ComputeScaffoldFingerprint(Dictionary<string, string> scaffold)
{
var builder = new StringBuilder();
foreach (var fileName in scaffold.Keys.Order(StringComparer.Ordinal))
{
// The NUL separators keep file boundaries unambiguous: without them a rename that
// shifts content across the boundary could hash identically.
builder.Append(fileName).Append('\0').Append(scaffold[fileName]).Append('\0');
}
return SourceContentFingerprint.Compute(builder.ToString(), ScaffoldSchemaVersion);
}
/// <summary>
/// Restores and builds the project.
/// </summary>
public async Task<(bool Success, OutputCollector Output)> BuildAsync(CancellationToken cancellationToken = default)
{
var outputCollector = new OutputCollector();
var projectFile = new FileInfo(Path.Combine(_projectModelPath, ProjectFileName));
var options = new ProcessInvocationOptions
{
StandardOutputCallback = outputCollector.AppendOutput,
StandardErrorCallback = outputCollector.AppendError
};
var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, noRestore: false, options, cancellationToken);
return (exitCode == 0, outputCollector);
}
/// <inheritdoc />
public async Task<AppHostServerPrepareResult> PrepareAsync(
string sdkVersion,
IEnumerable<IntegrationReference> integrations,
string? requestedChannel = null,
string? packageSourceOverride = null,
CancellationToken cancellationToken = default)
{
var (_, channelName) = await CreateProjectFilesAsync(integrations, requestedChannel, packageSourceOverride, cancellationToken);
var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken);
if (!buildSuccess)
{
return new AppHostServerPrepareResult(
Success: false,
Output: buildOutput,
ChannelName: channelName,
NeedsCodeGeneration: false);
}
return new AppHostServerPrepareResult(
Success: true,
Output: buildOutput,
ChannelName: channelName,
NeedsCodeGeneration: true);
}
/// <inheritdoc />
public string GetInstanceIdentifier() => GetProjectFilePath();
/// <inheritdoc />
public async Task<AppHostServerRunResult> RunAsync(
int hostPid,
IReadOnlyDictionary<string, string>? environmentVariables,
string[]? additionalArgs,
bool debug,
AppHostServerRunControl? runControl)
{
var assemblyPath = Path.Combine(BuildPath, ProjectDllName);
var dotnetExe = _environment.IsWindows() ? "dotnet.exe" : "dotnet";
// Build the canonical ProcessStartInfo first, then translate to IsolatedProcessStartInfo
// only if the isolated path is requested. Sharing the env/arg construction avoids drift
// between the two branches — every env var and argument lives in exactly one place.
var startInfo = new ProcessStartInfo(dotnetExe)
{
WorkingDirectory = _projectModelPath,
WindowStyle = ProcessWindowStyle.Minimized,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("exec");
startInfo.ArgumentList.Add(assemblyPath);
if (additionalArgs is { Length: > 0 })
{
startInfo.ArgumentList.Add("--");
foreach (var arg in additionalArgs)
{
startInfo.ArgumentList.Add(arg);
}
}
startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = _socketPath;
startInfo.Environment[KnownConfigNames.CliLogFilePath] = _logFilePath;
// Stamp the launching CLI (hostPid) as the parent under both the RemoteHost and generic CLI
// key pairs. Resolve the start time once and pair it with the PID so the RemoteHost orphan
// detector verifies both and does not keep the server alive against a recycled PID.
var hostStartedUnix = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(hostPid);
OrphanDetectionEnvironment.Apply(startInfo.Environment, hostPid, hostStartedUnix, KnownConfigNames.RemoteAppHostProcessId, KnownConfigNames.RemoteAppHostProcessStarted);
OrphanDetectionEnvironment.Apply(startInfo.Environment, hostPid, hostStartedUnix, KnownConfigNames.CliProcessId, KnownConfigNames.CliProcessStarted);
// Dev mode uses debug builds which require Development environment
// for the dashboard to resolve static web assets correctly
startInfo.Environment[KnownAspNetCoreConfigNames.Environment] = "Development";
// Wire WithTerminal() for guest/polyglot AppHosts running from the repo. The
// generated AppHostServer references Aspire.Hosting from the repo and DCP resolves
// the terminal host via ASPIRE_TERMINAL_HOST_PATH or assembly metadata. No per-RID
// NuGet stamps the metadata path today, so without this env var the AppHostServer
// would always resolve to <unresolved-aspire-terminalhost> in repo mode.
// Mirrors the same injection that DotNetAppHostProject performs for .NET AppHosts.
// Skipped when the caller pre-populates the path so a user-side override always wins.
if (BundleDiscovery.TryGetRepoLocalManagedPath(_repoRoot) is { } terminalHostPath
&& !ContainsKey(environmentVariables, BundleDiscovery.TerminalHostPathEnvVar))
{
startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar] = terminalHostPath;
if (!ContainsKey(environmentVariables, BundleDiscovery.TerminalHostInvocationArgsEnvVar))
{
startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "terminalhost";
}
}
if (environmentVariables is not null)
{
foreach (var (key, value) in environmentVariables)
{
startInfo.Environment[key] = value;
}
}
if (debug)
{
startInfo.Environment[KnownConfigNames.AspireLogLevel] = "Debug";
_logger.LogDebug("Enabling debug logging for AppHostServer");
}
startInfo.RedirectStandardInput = true;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
var outputCollector = new OutputCollector();
// The execution local is forward-referenced by the log callbacks so they can read the
// child's pid per line. ProcessInvocationOptions.StandardOutputCallback is Action<string>
// (line only), but the AppHost wants the pid in each trace line (#16729). ProcessExecution
// publishes the child pid before it starts stdout/stderr pumps so immediate output can read
// ProcessId.
IProcessExecution execution = null!;
void OnStdout(string line)
{
_logger.LogTrace("AppHostServer({ProcessId}) stdout: {Line}", execution.ProcessId, line);
outputCollector.AppendOutput(line);
}
void OnStderr(string line)
{
_logger.LogTrace("AppHostServer({ProcessId}) stderr: {Line}", execution.ProcessId, line);
outputCollector.AppendError(line);
}
var options = new ProcessInvocationOptions
{
StandardOutputCallback = OnStdout,
StandardErrorCallback = OnStderr,
IsolateConsole = runControl?.IsolateConsole ?? false,
KillOnParentExit = runControl?.KillOnParentExit ?? false,
GracefulShutdownSignaler = runControl?.GracefulShutdownSignaler,
ShutdownService = runControl?.ShutdownService,
// The graceful ladder always tree-kills on escalation; this fallback only matters when
// graceful services were not wired (non-Run callers), where it preserves the old session
// behavior of force-killing the tree on Unix but only the root on Windows.
KillEntireProcessTreeOnCancel = !_environment.IsWindows(),
};
execution = _processExecutionFactory.CreateExecution(startInfo, options);
try
{
await execution.StartAsync(CancellationToken.None).ConfigureAwait(false);
}
catch
{
await execution.DisposeAsync().ConfigureAwait(false);
throw;
}
return new AppHostServerRunResult(_socketPath, outputCollector, execution);
}
private static string? FindNuGetConfig(string workingDirectory)
{
try
{
var startInfo = new ProcessStartInfo("dotnet")
{
Arguments = "nuget config paths",
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
return null;
}
var configPaths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
var workingDirFullPath = Path.GetFullPath(workingDirectory);
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var globalNuGetPath = Path.Combine(userProfile, ".nuget");
foreach (var configPath in configPaths)
{
if (File.Exists(configPath))
{
var configFullPath = Path.GetFullPath(configPath);
var configDir = Path.GetDirectoryName(configFullPath);
if (configDir is not null &&
!configDir.StartsWith(globalNuGetPath, StringComparison.OrdinalIgnoreCase) &&
(workingDirFullPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase) ||
configDir.StartsWith(workingDirFullPath, StringComparison.OrdinalIgnoreCase)))
{
return configFullPath;
}
}
}
return null;
}
catch
{
return null;
}
}
private (string Os, string Arch) GetBuildPlatform()
{
var os = _environment.IsLinux() ? "linux"
: _environment.IsMacOS() ? "darwin"
: "windows";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "386",
Architecture.X64 => "amd64",
Architecture.Arm64 => "arm64",
_ => "amd64"
};
return (os, arch);
}
private static string GetDcpVersionFromRepo(string repoRoot, string buildOs, string buildArch)
{
const string fallbackVersion = "0.21.1";
try
{
var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props");
if (!File.Exists(versionsPropsPath))
{
return fallbackVersion;
}
var doc = XDocument.Load(versionsPropsPath);
var propertyName = $"MicrosoftDeveloperControlPlane{buildOs}{buildArch}Version";
var version = doc.Descendants(propertyName).FirstOrDefault()?.Value;
return version ?? fallbackVersion;
}
catch
{
return fallbackVersion;
}
}
private static bool ContainsKey(IReadOnlyDictionary<string, string>? env, string key)
{
return env is not null && env.ContainsKey(key);
}
}