Keep launch-configuration AppHost targets out of the workspace default - #19126
Adam Ratzman (adamint) merged 31 commits into
Conversation
Keep explicit --apphost selection one-shot while preserving implicit and prompted selection persistence. Add focused CLI contract coverage and a real Extension Host launch-configuration regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
Keep explicit launch-configuration targets session-scoped while preserving persistence for directory discovery, direct CLI selection, and extension user selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
…launch-config-persistence
Move the explicit-launch-configuration check out of RunCommand and into ProjectLocator.CreateSettingsFileAsync, the single point that writes aspire.config.json. A VS Code launch configuration can name run, publish, deploy, or do, and every one of those commands resolved its AppHost with createSettingsFile: true, so the workspace default was still clobbered for all commands other than run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da1d8af7-a583-4a0c-b434-3dd5d6feb7ff
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19126Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19126" |
There was a problem hiding this comment.
Pull request overview
Prevents explicit VS Code launch configurations from changing the workspace’s persisted default AppHost.
Changes:
- Adds AppHost-selection provenance across the extension-to-CLI boundary.
- Suppresses configuration persistence for explicit launch targets.
- Adds unit and extension E2E regression coverage.
Show a summary per file
| File | Description |
|---|---|
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs |
Supplies CLI configuration to ProjectLocator. |
tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs |
Tests origin-based persistence behavior. |
tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs |
Verifies commands request centralized persistence. |
src/Shared/KnownConfigNames.cs |
Defines the provenance environment variable. |
src/Aspire.Cli/Projects/ProjectLocator.cs |
Suppresses persistence for explicit launch configurations. |
extension/src/utils/environment.ts |
Adds the environment-variable identifier. |
extension/src/types/extensionApi.ts |
Adds E2E named-configuration launching. |
extension/src/testing/e2eStateFileBridge.ts |
Implements the new E2E control command. |
extension/src/test/aspireDebugSession.test.ts |
Tests provenance forwarding to the CLI. |
extension/src/test/aspireDebugConfigurationProvider.test.ts |
Tests provenance classification and preservation. |
extension/src/test/appHostLaunchService.test.ts |
Verifies user-selection classification. |
extension/src/test-e2e/debugDashboard.e2e.test.ts |
Covers alternating named launch configurations. |
extension/src/services/AppHostLaunchService.ts |
Marks UI-selected AppHosts. |
extension/src/server/interactionService.ts |
Classifies RPC-started debug sessions. |
extension/src/debugger/AspireDebugSession.ts |
Passes provenance through the CLI environment. |
extension/src/debugger/AspireDebugConfigurationProvider.ts |
Classifies launch targets by origin. |
extension/src/debugger/AspireDebugConfigurationMetadata.ts |
Defines internal provenance metadata. |
extension/src/dcp/types.ts |
Types the provenance field. |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Address code review of the AppHost selection-origin work. The debug configuration provider was registered once for both the Dynamic and Initial trigger kinds. VS Code serializes an Initial provider's result verbatim into a newly created launch.json, so the internal selection-origin marker was written into a user-owned file alongside a concrete AppHost program. Because resolve-time classification short-circuits when the marker is already present, such an entry stayed pinned to 'default-discovery' and kept rewriting the workspace default AppHost -- reproducing issue microsoft#19080 for launch configurations the extension itself generated. Register separate provider instances per trigger kind and only stamp the marker on ephemeral Dynamic configurations. An entry that lands in launch.json is now classified when it is resolved, so a generated configuration naming a specific AppHost is correctly treated as explicit. Also narrow the directory downgrade. Any directory-valued program used to be demoted to 'default-discovery', so a hand-written per-AppHost directory configuration still clobbered the workspace default. Only a program pointing at the workspace folder root delegates back to discovery now. Test fixes: create the secondary AppHost candidate inside the try block and remove it in suite teardown so a throw cannot leak it into later tests, give the multi-configuration test its own timeout, and have the E2E control command wait for VS Code to see a newly written launch configuration before starting it by name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da1d8af7-a583-4a0c-b434-3dd5d6feb7ff
There was a problem hiding this comment.
Review details
Suppressed comments (1)
extension/src/debugger/AspireDebugConfigurationProvider.ts:90
- The documented contract says any
programthat resolves to a directory is downgraded todefault-discoveryand therefore persists the selected AppHost. This condition only does that for the workspace root; AppHost subdirectories remainexplicit-launch-configuration(as the new test also codifies). Please align the implementation and tests with the stated directory behavior, or update the PR description if subdirectories are intentionally session-scoped.
if (aspireConfig[appHostSelectionOriginConfigKey] === 'explicit-launch-configuration' && this.isWorkspaceFolderRoot(program, folder)) {
// Only a program pointing at the workspace folder root delegates the choice back to
// normal discovery, which is what the extension's own default configuration does. A
// configuration naming a specific AppHost file *or* subdirectory is scoped to that
// target and must not become the workspace default.
aspireConfig[appHostSelectionOriginConfigKey] = 'default-discovery';
- Files reviewed: 20/20 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Second round of code review fixes. Suppressing the write outright meant a repo whose only AppHost was launched from a generated launch.json never got an aspire.config.json at all. The startup prompt from checkForExistingAppHostPathInWorkspace is not gated on ambiguity, so those users would be asked to create the file on every window open instead of having the first launch settle it. Narrow the rule to what issue microsoft#19080 actually reports: an explicit launch configuration must not *replace* a workspace default the user already has. It may still establish one when there is nothing to preserve, and it still heals a default whose AppHost no longer exists, since there is no choice left to protect in either case. Alternating between per-AppHost launch configurations remains stable, which is the behavior being fixed. Also lower the new waitForLaunchConfiguration budget to 5s. At 15s it sat above the 10s test-side control-command timeout, so its diagnostic was unreachable and a timed-out test would leave the handler running long enough to start a debug session behind the test's own teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da1d8af7-a583-4a0c-b434-3dd5d6feb7ff
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Aspire.Cli/Projects/ProjectLocator.cs:155
- This summary contradicts the persistence rule below: an explicit launch target can become the workspace default when no valid default exists; it only must not replace an existing valid default. Describing it as “never” becoming the default could lead future changes to break the intentional first-launch behavior.
/// <summary>
/// Identifies a CLI invocation whose AppHost target came from an editor launch configuration
/// (for example a VS Code <c>launch.json</c> entry with an explicit <c>program</c>). Such a target
/// is owned by the individual debug session, so it must never become the workspace default.
/// </summary>
- Files reviewed: 20/20 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The explicit-launch-configuration check asked whether a recorded default existed, but it did not read that default the way the rest of the CLI does. Two gaps: - It loaded the config with a plain Load, which does not see a legacy .aspire/settings.json workspace. A workspace that had never been migrated therefore looked like it had no recorded default, so a launch configuration was free to write one -- the exact overwrite microsoft#19080 reports. LoadOrCreate cannot be called from a read-only predicate because it persists the migration as a side effect, so the config the upward search already loaded is threaded out and reused instead. - It resolved the recorded path with Path.GetFullPath alone, unlike the canonical readers, which normalize separators first. A config committed from Windows carries backslashes, so on a Unix checkout the recorded path was read as a literal filename. The predicate also no longer requires the recorded file to exist. A missing file is indistinguishable from a branch switch or a sparse checkout, and treating it as "nothing to preserve" would let the next launch permanently re-point a default the user still wants. Stale entries are still healed by every other selection origin, which is where a deliberate choice comes from. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da1d8af7-a583-4a0c-b434-3dd5d6feb7ff
ASPIRE_CLI_APPHOST_SELECTION_ORIGIN describes one CLI invocation: it tells ProjectLocator that this run's AppHost was named by a VS Code launch configuration and therefore must not rewrite the workspace default. The extension sets it on the CLI process, but ProcessExecutionFactory only stripped the ASPIRE_CLI_* identity overrides, so every child inherited the marker -- `dotnet build`, the AppHost itself, and the env block the CLI hands to the extension for an IDE-launched AppHost. Any nested `aspire` invocation inside that tree would then believe its own target came from the outer launch configuration and silently skip recording its workspace default. Strip the marker alongside the identity overrides in both factory overloads. The (fileName, args, env, ...) overload applies caller env after the strip, so AppHostLauncher re-adds the marker for its detached child CLI, which continues the same logical invocation and re-resolves the AppHost. The ProcessStartInfo overload serves only AppHost/guest spawn paths, so it always strips. Tests cover both directions: the marker is removed from a parent-inherited block and from an explicitly populated ProcessStartInfo, and it survives on the detached-child-CLI path spawned with AppHostLauncher's own env and options. Both strip tests fail against the previous behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
src/Aspire.Cli/Projects/ProjectLocator.cs:1081
- This process-scoped handoff is being read through the merged
IConfiguration, but workspace/global settings are registered after environment variables (Program.cs:299-312,ConfigurationHelper.cs:73-83) and therefore take precedence. A same-named top-level setting can make an explicit launch rewrite the default, or make ordinary CLI invocations suppress persistence. Read this internal signal from the existingIEnvironment.GetEnvironmentVariable(...)abstraction instead, and remove the newIConfigurationconstructor plumbing.
var isExplicitLaunchConfiguration = string.Equals(
configuration[KnownConfigNames.CliAppHostSelectionOrigin],
ExplicitLaunchConfigurationSelectionOrigin,
StringComparison.OrdinalIgnoreCase);
src/Aspire.Cli/Projects/ProjectLocator.cs:156
- This documentation contradicts the implemented “establish, but never replace” rule:
CreateSettingsFileAsyncintentionally allows this origin to become the default when none exists. Describe that exception so future callers do not incorrectly suppress all persistence.
/// Identifies a CLI invocation whose AppHost target came from an editor launch configuration
/// (for example a VS Code <c>launch.json</c> entry with an explicit <c>program</c>). Such a target
/// is owned by the individual debug session, so it must never become the workspace default.
/// </summary>
- Files reviewed: 25/25 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (1)
tests/Aspire.Cli.Tests/DotNet/ProcessExecutionFactoryEnvironmentTests.cs:21
- These tests mutate process-wide environment variables, but this class is not serialized with the repository's existing
EnvVarMutatingTestCollection. While the overrides on lines 42–43 or 78 are active, an unrelated test can snapshotASPIRE_CLI_APPHOST_SELECTION_ORIGINintoIConfigurationor a child process and unexpectedly suppress its config write. Put this class in the same non-parallel collection used by the otherEnvVarOverridesuites.
public sealed class ProcessExecutionFactoryEnvironmentTests
- Files reviewed: 25/25 changed files
- Comments generated: 1
- Review effort level: Balanced
TryGetRecordedAppHostDefault resolved appHost.path only to hand the result to a
LogDebug argument: once the File.Exists check was dropped, every caller consumed
it through an `is { }` null test. The resolution could not change an outcome, but
it could still throw.
GetAppHostProjectFileFromSettingsAsync guards its resolution with
IsValidConfiguredAppHostPath, which rejects NUL bytes and other invalid
characters that survive JSON parsing precisely because Path.GetFullPath throws
ArgumentException on them (microsoft#17624). This helper had no such guard. The upward
search resolves the config it finds and would throw first, but the
`recordedConfig ??= AspireConfigFile.Load(...)` fallback reaches the
working-directory config in the case where the AppHost sits outside that config's
tree -- a config nothing else in this method had ever resolved. An explicit
--apphost never runs the settings reader, so no earlier validation applies.
Return the recorded path as written instead. Presence is the whole decision, the
raw string is the more useful log value because it is what appears in the file,
and the throw path disappears with the dead code. The config directory is now
logged separately so the message still says which file was consulted.
The remarks no longer claim stale entries are healed by every other origin. The
extension's startup prompt only checks that appHost.path is present, never that
it resolves, so a default left stale by a rename is repaired only by a selection
the user actually makes. That is the real cost of preferring preservation, and it
belongs in the remarks rather than an overstated reassurance.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: da1d8af7-a583-4a0c-b434-3dd5d6feb7ff
…robe Joining only the segments before the differing one drops the root separator on a rooted Windows path: C:\Foo and C:\foo rebuild the parent as "C:", which names the process's current directory on drive C rather than the drive root. The probe then enumerated the wrong directory, missed both spellings, and reported the two AppHosts as one file. Rebuilding through the differing segment and taking its parent keeps rooted-path semantics on every platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Aspire.Cli/Projects/ProjectLocator.cs:1334
- This platform shortcut contradicts the filesystem probe below: Linux can use case-insensitive filesystems (for example, an SMB or exFAT workspace). There, two casing variants can open the same AppHost, but this branch always reports different files and unnecessarily rewrites the recorded default. Run the existing
PathExists/directory-entry probe on every platform; it already distinguishes a case-sensitive directory containing both variants.
// A casing-only difference means two files wherever the volume is case-sensitive, which is
// the default on Linux.
if (!environment.IsWindows() && !environment.IsMacOS())
{
return false;
}
- Files reviewed: 30/30 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The sibling case-insensitive test already skips where its premise fails, but
this one still asserts unconditionally that a second project differing only in
casing can be promoted to the workspace default. Where the volume folds the two
spellings there is no second project: CreateSubdirectory("foo") hands back the
"Foo" created a line earlier, both writes land in one file, and the explicit
"foo" path is normalized to the on-disk "Foo" before the first assertion runs.
That is the windows-latest failure, which compared
...\Workspace\3jhpl2ni.4fb\foo\AppHost.csproj
...\Workspace\3jhpl2ni.4fb\Foo\AppHost.csproj
The guard probes the volume the workspace actually sits on rather than switching
on OperatingSystem, matching the sibling tests, because NTFS and APFS can each
be formatted either way. Verified both ways locally: the test runs and passes on
a case-sensitive APFS volume and skips on a case-insensitive one.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Carry the resolved settings file, scoped base directory, and recorded config together through ProjectLocator config preservation and writing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c
There was a problem hiding this comment.
Review details
Suppressed comments (2)
src/Aspire.Cli/Configuration/ConfigurationService.cs:411
includeGlobalSettingshas no production caller that passesfalse, so this new branch is dead and does not implement ProjectLocator's local-only check (that code readsRecordedConfigdirectly). It expandsIConfigurationService, changes every test double, and adds untested behavior without affecting the fix. Please remove the parameter/branch, or wire the intended local-only production call and add directConfigurationServiceTestscoverage.
if (includeGlobalSettings && File.Exists(globalSettingsFile.FullName))
tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs:2058
- This comment no longer matches the implementation:
ProjectLocatordecides whether a workspace default exists fromWorkspaceConfigTarget.RecordedAppHostPath; directory-scoped reads are only used here to inherit SDK settings for polyglot AppHosts. Please describe that current purpose so future changes do not preserve this test wiring for the wrong reason.
// Directory-scoped reads are answered by the real implementation rather than stubbed out.
// ProjectLocator uses them to decide whether the workspace already records an AppHost
// default, and that is a question about config files these tests actually write to disk, so
// a stub returning null would quietly make every preservation assertion vacuous. The global
- Files reviewed: 30/30 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
src/Aspire.Cli/Projects/ProjectLocator.cs:1331
- This hard-codes Linux as effectively case-sensitive, but the surrounding remarks correctly note that case sensitivity is a volume/directory property (e.g., SMB/CIFS mounts, FUSE, WSL/DrvFS scenarios). With the current early-return, two paths that differ only by casing will never be treated as the same entry on Linux even when the underlying filesystem folds them, which can cause incorrect ‘heal/replace’ decisions. Consider removing the OS gate and using the existing
BothCaseVariantsExistprobe (with its exception-tolerant behavior) on all platforms for the casing-only path case.
// A casing-only difference means two files wherever the volume is case-sensitive, which is
// the default on Linux.
if (!environment.IsWindows() && !environment.IsMacOS())
{
return false;
}
extension/src/utils/appHostDiscovery.ts:1223
- Using
fs.statSyncin extension-host code can block the event loop (notably on networked/slow filesystems) and this helper is called from debug-configuration resolution paths. Since the surrounding APIs are alreadyasync, consider switching this helper to usefs.promises.stat(orvscode.workspace.fs.stat) and making callers await it, to avoid potential UI/extension-host stalls.
export function isSameFileSystemEntry(left: string, right: string): boolean {
const resolvedLeft = path.resolve(left);
const resolvedRight = path.resolve(right);
if (resolvedLeft === resolvedRight) {
return true;
}
try {
const leftStat = fs.statSync(resolvedLeft, { bigint: true });
const rightStat = fs.statSync(resolvedRight, { bigint: true });
// Windows reports ino as 0 on filesystems with no stable file index, which would make every
// unrelated pair look like one entry.
if (leftStat.ino !== 0n && rightStat.ino !== 0n) {
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
}
catch {
// A path that cannot be stat'ed, because it is missing or unreadable, carries no identity
// to compare. Fall back to the textual comparison below.
}
return isSamePath(resolvedLeft, resolvedRight);
}
- Files reviewed: 30/30 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c
There was a problem hiding this comment.
Review details
Suppressed comments (3)
src/Aspire.Cli/Projects/ProjectLocator.cs:1141
- This read/decision is not serialized with the later whole-file update. Compound launch configurations can start multiple explicit AppHosts concurrently while no default exists; each process can observe no path and then write its own target, so the last writer silently becomes the workspace default (and concurrent JSON rewrites can collide). The complete resolve/read/decision/write operation needs a cross-process lock or atomic compare-and-set scoped to the resolved config file.
if (isExplicitLaunchConfiguration && File.Exists(resolvedPath))
{
logger.LogDebug(
"Not replacing recorded AppHost default {RecordedAppHost} with {AppHost} because the latter was selected by {SelectionOrigin}.",
resolvedPath,
projectFile.FullName,
selectionOrigin);
return;
extension/src/debugger/AspireDebugConfigurationProvider.ts:159
isSamePathfolds case on all macOS systems, so this can misclassify an explicit target as the workspace root on a case-sensitive APFS volume. For example, separate/repoand/Repodirectories compare equal, changing the origin todefault-discoveryand allowing that launch target to overwrite the workspace default. Use filesystem-canonical identity (which also handles case-insensitive volumes) for this classification.
private isWorkspaceFolderRoot(program: string, folder: vscode.WorkspaceFolder | undefined): boolean {
const owningFolder = folder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(program));
return owningFolder !== undefined && isSamePath(program, owningFolder.uri.fsPath);
src/Aspire.Cli/Projects/ProjectLocator.cs:1121
- This assumes every macOS volume is case-insensitive. On a case-sensitive APFS volume,
Foo/AppHost.csprojandfoo/AppHost.csprojcan both exist; this comparison treats them as the same target and returns without recording the project that was actually selected. Determine path identity from the filesystem/volume (or canonicalize both existing paths) rather than choosing comparison rules from the OS.
var pathComparison = environment.IsWindows() || environment.IsMacOS()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
- Files reviewed: 21/21 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Ella Hathaway (ellahathaway)
left a comment
There was a problem hiding this comment.
I reviewed the final, reduced diff again, including the full extension-to-CLI AppHost-selection flow: Initial/Dynamic debug configuration behavior, hidden metadata classification, environment merging and process-boundary propagation, ProjectLocator persistence semantics, and the updated focused tests. I also rechecked the earlier feedback against the current head and completed another Aspire-specific architecture pass.
The structure makes sense. The extension owns selection provenance, the CLI owns workspace-default policy, ProjectLocator remains the common persistence point, and ProcessExecutionFactory is the right boundary for keeping the marker invocation-scoped. I did not find any new correctness or architectural issues in the final diff.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3253974d-4f18-486f-863c-281a607656d4
There was a problem hiding this comment.
Review details
Suppressed comments (4)
extension/src/utils/appHostDiscovery.ts:1229
- Making this shared comparator case-sensitive on macOS regresses existing callers that were not migrated to filesystem identity. For example,
findCandidateForEditorFileuses it at line 801; on default case-insensitive APFS, a.csprojpath emitted byaspire lswith different casing from the VS Code path no longer matches, and.csprojhas no source-file fallback. Either retain the prior macOS fallback or migrate all existing-file comparisons to the identity-aware helper.
const comparison = process.platform === 'win32'
? 'case-insensitive'
: 'case-sensitive';
extension/src/utils/appHostDiscovery.ts:1198
- The PR description says filesystem-identity probes are intentionally excluded, but this adds synchronous
stat-based identity probing and corresponding tests. Either remove this unrelated behavior from the change or update the PR description and validation scope to reflect it.
export function isSameFileSystemEntry(
left: string,
right: string,
getIdentity: FileSystemEntryIdentityProvider = tryGetFileSystemEntryIdentity): boolean {
extension/src/debugger/AspireDebugConfigurationProvider.ts:90
- This regression crosses VS Code configuration resolution, debug-session environment forwarding, CLI configuration binding, and on-disk persistence, but the added tests exercise those layers only in isolation. Add an extension E2E scenario with two AppHosts and an existing default that starts the other through an explicit launch configuration and verifies
aspire.config.jsonremains unchanged; otherwise a broken handoff between these unit-tested layers can reintroduce #19080.
if (aspireConfig[appHostSelectionOriginConfigKey] === 'explicit-launch-configuration' && this.isWorkspaceFolderRoot(program, folder)) {
// Only a program pointing at the workspace folder root delegates the choice back to
// normal discovery, which is what the extension's own default configuration does. A
// configuration naming a specific AppHost file *or* subdirectory is scoped to that
// target and must not become the workspace default.
aspireConfig[appHostSelectionOriginConfigKey] = 'default-discovery';
extension/src/debugger/AspireDebugConfigurationMetadata.ts:13
- This documentation says an explicit launch target can never replace a default, but the CLI intentionally replaces a missing or deleted target. Qualify this as a valid existing default so the documented contract matches
ProjectLocator.
* one invocation and must never replace a default the user already has.
- Files reviewed: 22/22 changed files
- Comments generated: 1
- Review effort level: Balanced
Resolve the target config before reading it, then hold the CLI cross-process file lock through migration, the preserve decision, and every config write. This prevents compound launch configurations from each observing an empty default and replacing one another. Add concurrent ProjectLocator coverage that verifies only one launch writes while unrelated config survives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (6)
src/Aspire.Cli/Projects/ProjectLocator.cs:1090
- The lock key is derived after
ResolveWorkspaceConfigTargethas already fallen back to the current working directory. Concurrent explicit launches for AppHosts in different subdirectories can therefore choose different config files and different locks when no config exists, allowing both to establish defaults (and potentially creating multiple nestedaspire.config.jsonfiles). Resolve and revalidate the target under a stable lock, reacquiring the lock if the resolved config root changes before the read/write section.
using var configLock = await TryAcquireWorkspaceConfigLockAsync(settingsFile, cancellationToken);
extension/src/utils/appHostDiscovery.ts:1229
isSamePathis shared by editor-file matching, selected-candidate parsing, and workspace selection, so removing macOS from the case-insensitive branch changes all of those callers—not just launch-origin classification. On the default case-insensitive APFS volume, differently-cased paths still identify the same entry; these callers can now fail to find/select that AppHost. Keep the existing macOS behavior for textual path matching and use the identity-aware comparison only where case-sensitive volumes must distinguish real entries, or migrate every affected caller to identity-aware matching.
const comparison = process.platform === 'win32'
? 'case-insensitive'
: 'case-sensitive';
extension/src/debugger/AspireDebugSession.ts:311
- The new unit tests verify the provider, spawn arguments, and CLI persistence separately, but none exercises the regression across the actual VS Code-to-CLI boundary. A dropped or overwritten marker between debug configuration resolution and the spawned CLI would leave every focused test passing while
launch.jsonstill replacesaspire.config.json. Add an extension E2E scenario that records AppHost A as the default, launches AppHost B fromlaunch.json, and asserts the config remains unchanged.
void this.spawnAspireCommand(args, appHostPath, noDebug, commandLabel, this.getAppHostSelectionOriginEnvironment(appHostSelectionOrigin));
src/Aspire.Cli/Projects/ProjectLocator.cs:1216
- The PR description says the cross-process lock is intentionally not part of this fix, but this hunk adds and uses that lock. Please either remove this out-of-scope behavior or update the PR description and validation notes so reviewers can evaluate the actual concurrency and operational scope.
return await FileLock.AcquireAsync(lockPath, cancellationToken, s_workspaceConfigLockTimeout);
extension/src/utils/appHostDiscovery.ts:1198
- The PR description explicitly excludes filesystem-identity probes, but this function introduces synchronous
stat-based identity probing and changes configured-candidate matching to use it. Please remove this unrelated change or update the stated scope and validation to cover the additional cross-platform filesystem behavior.
This issue also appears on line 1227 of the same file.
export function isSameFileSystemEntry(
left: string,
right: string,
getIdentity: FileSystemEntryIdentityProvider = tryGetFileSystemEntryIdentity): boolean {
src/Aspire.Cli/Projects/ProjectLocator.cs:1075
- This makes persisted-path equality case-sensitive on every macOS volume. Default APFS is case-insensitive, so two spellings of the same AppHost now fall through to
SetConfigurationInFileAsync, rewriting the config and reporting an update during normal discovery/direct selection even though the target did not change. The existing discovery path explicitly accounts for default macOS case-insensitivity atProjectLocator.cs:462-467; use filesystem identity (or volume-aware comparison) here so case-sensitive APFS can still distinguish genuinely different entries.
This issue also appears on line 1090 of the same file.
var pathComparison = environment.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Fixes #19080.
A
launch.jsonconfiguration that points at a specific AppHost is choosing what to run for that session. It should not silently replace the workspace default inaspire.config.json.This change carries that distinction from the VS Code debug configuration into the CLI's existing
ProjectLocatorpersistence path:launch.jsontargets preserve a valid existing defaultlaunch.jsonentries do not persist the extension's internal markerThe persistence decision stays in
ProjectLocator, so it applies consistently to every command that resolves an AppHost. The cross-process lock, filesystem-identity probes, E2E control plumbing, and unrelated configuration-service changes are intentionally not part of this fix.Validation
Checklist