Skip to content

Commit 8d78862

Browse files
davidfowlCopilot
andcommitted
Stabilize localhive CLI flows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 39e85c9 commit 8d78862

30 files changed

+1245
-128
lines changed

.github/skills/cli-e2e-testing/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ ASPIRE_E2E_ARCHIVE=/tmp/aspire-e2e.tar.gz \
139139
-- --filter-method "*.YourTestName"
140140

141141
# 4. If it fails, check the asciinema recording
142-
# Recordings are saved to $TMPDIR/aspire-cli-e2e/recordings/
142+
# Recordings are saved under the test output TestResults/recordings/ directory
143143
# Play with: asciinema play /path/to/YourTestName.cast
144144

145145
# 5. Fix and repeat from step 1 or 2

localhive.sh

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,17 @@ if [[ $USE_COPY -eq 1 ]]; then
278278
log "Populating hive '$HIVE_NAME' by copying .nupkg files (version suffix: $VERSION_SUFFIX)"
279279
mkdir -p "$HIVE_PATH"
280280
# Only copy packages matching the current version suffix to avoid accumulating stale packages
281+
copied_packages=0
282+
shopt -s nullglob
281283
for pkg in "$PKG_DIR"/*"$VERSION_SUFFIX"*.nupkg; do
282-
[ -f "$pkg" ] && cp -f "$pkg" "$HIVE_PATH"/
284+
pkg_name="$(basename "$pkg")"
285+
if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then
286+
cp -f "$pkg" "$HIVE_PATH"/
287+
copied_packages=$((copied_packages + 1))
288+
fi
283289
done
284-
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)."
290+
shopt -u nullglob
291+
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)."
285292
else
286293
log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR"
287294
mkdir -p "$HIVE_ROOT"
@@ -290,8 +297,17 @@ else
290297
else
291298
warn "Symlink not supported; copying .nupkg files instead"
292299
mkdir -p "$HIVE_PATH"
293-
cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true
294-
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)."
300+
copied_packages=0
301+
shopt -s nullglob
302+
for pkg in "$PKG_DIR"/*.nupkg; do
303+
pkg_name="$(basename "$pkg")"
304+
if [[ -f "$pkg" ]] && [[ "$pkg_name" != ._* ]]; then
305+
cp -f "$pkg" "$HIVE_PATH"/
306+
copied_packages=$((copied_packages + 1))
307+
fi
308+
done
309+
shopt -u nullglob
310+
log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied $copied_packages packages)."
295311
fi
296312
fi
297313

@@ -411,7 +427,7 @@ if [[ $ARCHIVE -eq 1 ]]; then
411427
else
412428
ARCHIVE_PATH="${ARCHIVE_BASE}.tar.gz"
413429
log "Creating archive: $ARCHIVE_PATH"
414-
tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" .
430+
COPYFILE_DISABLE=1 tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" .
415431
fi
416432
log "Archive created: $ARCHIVE_PATH"
417433
fi

src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Runtime.CompilerServices;
88
using Aspire.Cli.Commands;
99
using Aspire.Cli.Utils;
10+
using Aspire.Hosting.Backchannel;
1011
using Microsoft.Extensions.FileProviders;
1112
using Microsoft.Extensions.Hosting;
1213
using Microsoft.Extensions.Logging;
@@ -153,7 +154,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
153154
// Ensure the backchannels directory exists
154155
if (!Directory.Exists(_backchannelsDirectory))
155156
{
156-
Directory.CreateDirectory(_backchannelsDirectory);
157+
if (OperatingSystem.IsWindows())
158+
{
159+
Directory.CreateDirectory(_backchannelsDirectory);
160+
}
161+
else
162+
{
163+
Directory.CreateDirectory(
164+
_backchannelsDirectory,
165+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
166+
}
157167
}
158168

159169
// Scan for existing sockets on startup.
@@ -496,7 +506,7 @@ private static async Task DisconnectAsync(IAppHostAuxiliaryBackchannel connectio
496506
private static string GetBackchannelsDirectory()
497507
{
498508
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
499-
return Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
509+
return BackchannelConstants.GetBackchannelsDirectory(homeDirectory);
500510
}
501511

502512
/// <summary>

src/Aspire.Cli/DotNet/DotNetCliRunner.cs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,14 +569,17 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
569569
using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client);
570570

571571
// NOTE: The change to @ over :: for template version separator (now enforced in .NET 10.0 SDK).
572-
List<string> cliArgs = ["new", "install", $"{packageName}@{version}"];
572+
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;
573+
var localPackagePath = ResolveLocalTemplatePackagePath(packageName, version, nugetSource, workingDirectory);
574+
575+
List<string> cliArgs = ["new", "install", localPackagePath?.FullName ?? $"{packageName}@{version}"];
573576

574577
if (force)
575578
{
576579
cliArgs.Add("--force");
577580
}
578581

579-
if (nugetSource is not null)
582+
if (localPackagePath is null && nugetSource is not null)
580583
{
581584
cliArgs.Add("--nuget-source");
582585
cliArgs.Add(nugetSource);
@@ -602,7 +605,6 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
602605
// folder as the working directory for the command. If we are using an implicit channel
603606
// then we just use the current execution context for the CLI and inherit whatever
604607
// NuGet.configs that may or may not be laying around.
605-
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;
606608

607609
var exitCode = await ExecuteAsync(
608610
args: [.. cliArgs],
@@ -634,6 +636,11 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
634636
}
635637
else
636638
{
639+
if (localPackagePath is not null)
640+
{
641+
return (exitCode, version);
642+
}
643+
637644
if (stdout is null)
638645
{
639646
logger.LogError("Failed to read stdout from the process. This should never happen.");
@@ -659,6 +666,45 @@ public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild,
659666
}
660667
}
661668

669+
private static FileInfo? ResolveLocalTemplatePackagePath(string packageName, string version, string? nugetSource, DirectoryInfo workingDirectory)
670+
{
671+
if (string.IsNullOrWhiteSpace(nugetSource))
672+
{
673+
return null;
674+
}
675+
676+
string sourcePath;
677+
if (Uri.TryCreate(nugetSource, UriKind.Absolute, out var uri))
678+
{
679+
if (!uri.IsFile)
680+
{
681+
return null;
682+
}
683+
684+
sourcePath = uri.LocalPath;
685+
}
686+
else
687+
{
688+
sourcePath = Path.GetFullPath(nugetSource, workingDirectory.FullName);
689+
}
690+
691+
if (File.Exists(sourcePath) && string.Equals(Path.GetExtension(sourcePath), ".nupkg", StringComparison.OrdinalIgnoreCase))
692+
{
693+
return new FileInfo(sourcePath);
694+
}
695+
696+
if (!Directory.Exists(sourcePath))
697+
{
698+
return null;
699+
}
700+
701+
var expectedFileName = $"{packageName}.{version}.nupkg";
702+
var packagePath = Directory.EnumerateFiles(sourcePath, "*.nupkg", SearchOption.TopDirectoryOnly)
703+
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgnoreCase));
704+
705+
return packagePath is null ? null : new FileInfo(packagePath);
706+
}
707+
662708
internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version)
663709
{
664710
var lines = stdout.Split(Environment.NewLine);

src/Aspire.Cli/DotNet/ProcessExecution.cs

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,30 +90,23 @@ public async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
9090
_logger.LogDebug("{FileName}({ProcessId}) exited with code: {ExitCode}", FileName, _process.Id, _process.ExitCode);
9191
}
9292

93-
// Explicitly close the streams to unblock any pending ReadLineAsync calls.
94-
// In some environments (particularly CI containers), the stream handles may not
95-
// be automatically closed when the process exits, causing ReadLineAsync to block
96-
// indefinitely. Disposing the streams forces them to close.
97-
_logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams", FileName, _process.Id);
98-
_process.StandardOutput.Close();
99-
_process.StandardError.Close();
100-
101-
// Wait for all the stream forwarders to finish so we know we've got everything
102-
// fired off through the callbacks. Use a timeout as a safety net in case
103-
// something else is unexpectedly holding the streams open.
93+
// Wait for the stream forwarders to drain naturally first so we don't cut off the
94+
// tail of the process output. In some environments the stream handles can stay open
95+
// after the process exits, so we fall back to closing them only if the forwarders
96+
// fail to complete within the timeout.
10497
if (_stdoutForwarder is not null && _stderrForwarder is not null)
10598
{
106-
var forwarderTimeout = Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
10799
var forwardersCompleted = Task.WhenAll([_stdoutForwarder, _stderrForwarder]);
108-
109-
var completedTask = await Task.WhenAny(forwardersCompleted, forwarderTimeout);
110-
if (completedTask == forwarderTimeout)
111-
{
112-
_logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within timeout after stream close", FileName, _process.Id);
113-
}
114-
else
100+
if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false))
115101
{
116-
_logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id);
102+
_logger.LogDebug("{FileName}({ProcessId}) closing stdout/stderr streams after forwarder timeout", FileName, _process.Id);
103+
_process.StandardOutput.Close();
104+
_process.StandardError.Close();
105+
106+
if (!await WaitForForwardersAsync(forwardersCompleted, cancellationToken).ConfigureAwait(false))
107+
{
108+
_logger.LogWarning("{FileName}({ProcessId}) stream forwarders did not complete within timeout after stream close", FileName, _process.Id);
109+
}
117110
}
118111
}
119112

@@ -165,4 +158,22 @@ private async Task ForwardStreamToLoggerAsync(StreamReader reader, string identi
165158
_logger.LogDebug("{FileName}({ProcessId}) {Identifier} stream forwarder completed - stream was closed", FileName, _process.Id, identifier);
166159
}
167160
}
161+
162+
private async Task<bool> WaitForForwardersAsync(Task forwardersCompleted, CancellationToken cancellationToken)
163+
{
164+
try
165+
{
166+
await forwardersCompleted.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
167+
_logger.LogDebug("{FileName}({ProcessId}) forwarders completed", FileName, _process.Id);
168+
return true;
169+
}
170+
catch (TimeoutException)
171+
{
172+
return false;
173+
}
174+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
175+
{
176+
return false;
177+
}
178+
}
168179
}

src/Aspire.Cli/Packaging/PackagingService.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,16 @@ public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken canc
4343
foreach (var prHive in prHives)
4444
{
4545
// The packages subdirectory contains the actual .nupkg files
46+
var packagesDirectory = new DirectoryInfo(Path.Combine(prHive.FullName, "packages"));
47+
var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory);
48+
4649
// Use forward slashes for cross-platform NuGet config compatibility
47-
var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/');
50+
var packagesPath = packagesDirectory.FullName.Replace('\\', '/');
4851
var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Both, new[]
4952
{
5053
new PackageMapping("Aspire*", packagesPath),
5154
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
52-
}, nuGetPackageCache);
55+
}, nuGetPackageCache, pinnedVersion: pinnedVersion);
5356

5457
prPackageChannels.Add(prChannel);
5558
}
@@ -179,4 +182,32 @@ private PackageChannelQuality GetStagingQuality()
179182
var plusIndex = cliVersion.IndexOf('+');
180183
return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion;
181184
}
185+
186+
// Local hive channels point at a flat directory of .nupkg files instead of a searchable feed.
187+
// Derive a concrete Aspire version from the hive contents and pin the channel to it so template
188+
// and package resolution stays on the same locally built version instead of asking NuGet for "latest".
189+
// Prefer Aspire.ProjectTemplates because it drives `aspire new`, then fall back to common packages
190+
// that are still present when the templates package is absent.
191+
private static string? GetLocalHivePinnedVersion(DirectoryInfo packagesDirectory)
192+
{
193+
if (!packagesDirectory.Exists)
194+
{
195+
return null;
196+
}
197+
198+
return FindHighestVersion("Aspire.ProjectTemplates")
199+
?? FindHighestVersion("Aspire.Hosting")
200+
?? FindHighestVersion("Aspire.AppHost.Sdk");
201+
202+
string? FindHighestVersion(string packageId)
203+
{
204+
return packagesDirectory
205+
.EnumerateFiles($"{packageId}.*.nupkg")
206+
.Select(static file => file.Name)
207+
.Select(fileName => fileName[(packageId.Length + 1)..^".nupkg".Length])
208+
.Where(version => SemVersion.TryParse(version, SemVersionStyles.Strict, out _))
209+
.OrderByDescending(version => SemVersion.Parse(version, SemVersionStyles.Strict), SemVersion.PrecedenceComparer)
210+
.FirstOrDefault();
211+
}
212+
}
182213
}

src/Aspire.Cli/Utils/CliPathHelper.cs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,24 @@ private static string CreateSocketPath(string socketPrefix, bool isGuestAppHost)
2929
return socketName;
3030
}
3131

32-
var socketDirectory = GetCliSocketDirectory();
33-
Directory.CreateDirectory(socketDirectory);
32+
var socketDirectory = BackchannelConstants.GetRuntimeSocketsDirectory(
33+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
34+
socketPrefix);
35+
CreateSocketDirectory(socketDirectory);
3436
return Path.Combine(socketDirectory, socketName);
3537
}
3638

37-
private static string GetCliHomeDirectory()
38-
=> Path.Combine(GetAspireHomeDirectory(), "cli");
39-
40-
private static string GetCliRuntimeDirectory()
41-
=> Path.Combine(GetCliHomeDirectory(), "runtime");
42-
43-
private static string GetCliSocketDirectory()
44-
=> Path.Combine(GetCliRuntimeDirectory(), "sockets");
39+
private static void CreateSocketDirectory(string socketDirectory)
40+
{
41+
if (OperatingSystem.IsWindows())
42+
{
43+
Directory.CreateDirectory(socketDirectory);
44+
}
45+
else
46+
{
47+
Directory.CreateDirectory(
48+
socketDirectory,
49+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
50+
}
51+
}
4552
}

src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
5151
if (directory != null && !Directory.Exists(directory))
5252
{
5353
logger.LogDebug("Creating backchannels directory: {Directory}", directory);
54-
Directory.CreateDirectory(directory);
54+
if (OperatingSystem.IsWindows())
55+
{
56+
Directory.CreateDirectory(directory);
57+
}
58+
else
59+
{
60+
Directory.CreateDirectory(
61+
directory,
62+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
63+
}
5564
}
5665

5766
// Clean up orphaned sockets from crashed instances of this same AppHost

src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
<None Include="templates\**\*" />
2525
</ItemGroup>
2626

27+
<ItemGroup>
28+
<ProjectReference Include="$(RepoRoot)tools\ReplaceText\ReplaceText.csproj"
29+
ReferenceOutputAssembly="false"
30+
Private="false" />
31+
</ItemGroup>
32+
2733
<ItemGroup>
2834
<!-- Template config files: .template.config\*.json -->
2935
<TemplateConfigFiles Include="$(MSBuildThisFileDirectory)templates\**\.template.config\*.json">
@@ -66,8 +72,16 @@
6672
</Target>
6773

6874
<!-- Replaces the versions referenced by the templates projects to use the version of the packages being live-built -->
75+
<Target Name="CalculateReplaceTextToolPath">
76+
<MSBuild Projects="$(RepoRoot)tools\ReplaceText\ReplaceText.csproj" Targets="GetTargetPath">
77+
<Output TaskParameter="TargetOutputs" PropertyName="_ReplaceTextToolPath" />
78+
</MSBuild>
79+
80+
<Error Condition="'$(_ReplaceTextToolPath)' == ''" Text="Could not find ReplaceText tool output." />
81+
</Target>
82+
6983
<Target Name="ReplacePackageVersionOnTemplates"
70-
DependsOnTargets="CopyTemplatesToIntermediateOutputPath">
84+
DependsOnTargets="CopyTemplatesToIntermediateOutputPath;CalculateReplaceTextToolPath">
7185

7286
<PropertyGroup>
7387
<!-- Extract purely numeric portions before any '-' prerelease tag -->
@@ -77,7 +91,6 @@
7791
<VersionMinor>$(_VersionBase.Split('.')[1])</VersionMinor>
7892
<VersionMajorMinor>$(VersionMajor).$(VersionMinor)</VersionMajorMinor>
7993
<_RspFilePath>$(IntermediateOutputPath)replace-text-args.rsp</_RspFilePath>
80-
<_ReplaceTextScriptPath>$(RepoRoot)tools/scripts/replace-text.cs</_ReplaceTextScriptPath>
8194
</PropertyGroup>
8295

8396
<ItemGroup>
@@ -131,8 +144,7 @@
131144
Overwrite="true"
132145
WriteOnlyWhenDifferent="true" />
133146

134-
<!-- Execute the script with the .rsp file, using double dash to pass arguments directly to the script -->
135-
<Exec Command="dotnet &quot;$(_ReplaceTextScriptPath)&quot; -- @&quot;$(_RspFilePath)&quot;"
147+
<Exec Command="dotnet exec &quot;$(_ReplaceTextToolPath)&quot; @&quot;$(_RspFilePath)&quot;"
136148
WorkingDirectory="$(MSBuildThisFileDirectory)"
137149
StandardOutputImportance="Normal"
138150
StandardErrorImportance="Normal" />

0 commit comments

Comments
 (0)