From b773dd1cac3f0d406dc7b7abb08980de4cb3a42a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 19:40:00 -0400 Subject: [PATCH 01/90] Expose evaluated project assembly name to resource snapshots An AppHost that sets AssemblyName - commonly through an imported Directory.Build.props - launches an assembly whose name has no relation to the project file name. Consumers that need to identify the running process (debugger attach being the motivating case in #18937) read the project file name and target something that does not exist. Bake the MSBuild-evaluated assembly name into the generated IProjectMetadata while the AppHost is built, and project it onto the resource snapshot as an optional "project.assemblyName" property. There is no run-time evaluation: the value is resolved by the same MSBuild project-reference machinery the AppHost SDK already drives, so nothing is re-evaluated when a resource restarts and no subprocess is spawned. The property is additive in both directions. IProjectMetadata gains a default interface member returning null, so metadata created from a path, file-based apps, and third-party implementations stay source and binary compatible; the snapshot property is written only when a name resolved, so its absence is the capability signal for consumers. This does not close #18602. Replicas and descendant process identity still need a DCP-side contract; this is the fallback for the common single-instance case where the assembly name is enough to find the process. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb --- .../Model/LegacyResourcePropertyMetadata.cs | 1 + .../Resources/Resources.Designer.cs | 9 + src/Aspire.Dashboard/Resources/Resources.resx | 3 + .../Resources/xlf/Resources.cs.xlf | 5 + .../Resources/xlf/Resources.de.xlf | 5 + .../Resources/xlf/Resources.es.xlf | 5 + .../Resources/xlf/Resources.fr.xlf | 5 + .../Resources/xlf/Resources.it.xlf | 5 + .../Resources/xlf/Resources.ja.xlf | 5 + .../Resources/xlf/Resources.ko.xlf | 5 + .../Resources/xlf/Resources.pl.xlf | 5 + .../Resources/xlf/Resources.pt-BR.xlf | 5 + .../Resources/xlf/Resources.ru.xlf | 5 + .../Resources/xlf/Resources.tr.xlf | 5 + .../Resources/xlf/Resources.zh-Hans.xlf | 5 + .../Resources/xlf/Resources.zh-Hant.xlf | 5 + .../build/Aspire.Hosting.AppHost.in.targets | 132 ++++++++++- .../ResourcePropertySnapshotMetadata.cs | 1 + .../Dcp/ResourceSnapshotBuilder.cs | 36 ++- src/Aspire.Hosting/IProjectMetadata.cs | 19 ++ .../Resources/MessageStrings.Designer.cs | 9 + .../Resources/MessageStrings.resx | 3 + .../Resources/xlf/MessageStrings.cs.xlf | 5 + .../Resources/xlf/MessageStrings.de.xlf | 5 + .../Resources/xlf/MessageStrings.es.xlf | 5 + .../Resources/xlf/MessageStrings.fr.xlf | 5 + .../Resources/xlf/MessageStrings.it.xlf | 5 + .../Resources/xlf/MessageStrings.ja.xlf | 5 + .../Resources/xlf/MessageStrings.ko.xlf | 5 + .../Resources/xlf/MessageStrings.pl.xlf | 5 + .../Resources/xlf/MessageStrings.pt-BR.xlf | 5 + .../Resources/xlf/MessageStrings.ru.xlf | 5 + .../Resources/xlf/MessageStrings.tr.xlf | 5 + .../Resources/xlf/MessageStrings.zh-Hans.xlf | 5 + .../Resources/xlf/MessageStrings.zh-Hant.xlf | 5 + src/Shared/Model/KnownProperties.cs | 7 + .../ResourceSnapshotMapperTests.cs | 28 +++ .../Model/KnownPropertyLookupTests.cs | 1 + .../Model/ResourceViewModelTests.cs | 1 + .../AppHostSdkTargetsTests.cs | 221 ++++++++++++++++++ .../Dcp/ResourceSnapshotBuilderTests.cs | 68 ++++++ .../ProjectResourceBuilderExtensionTests.cs | 21 ++ 42 files changed, 677 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs b/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs index cc099b7c05c..4254e0bbe1b 100644 --- a/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs +++ b/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs @@ -30,6 +30,7 @@ internal static (int SortOrder, KnownProperty KnownProperty)? Get(string resourc (KnownResourceTypes.Project, KnownProperties.Project.Path) => Create(KnownProperties.Project.Path, nameof(ResourcesDetailsProjectPathProperty), 0), (KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile) => Create(KnownProperties.Project.LaunchProfile, nameof(ResourcesDetailsProjectLaunchProfileProperty), 1), (KnownResourceTypes.Project, KnownProperties.Executable.Pid) => Create(KnownProperties.Executable.Pid, nameof(ResourcesDetailsExecutableProcessIdProperty), 2), + (KnownResourceTypes.Project, KnownProperties.Project.AssemblyName) => Create(KnownProperties.Project.AssemblyName, nameof(ResourcesDetailsProjectAssemblyNameProperty), 3), (KnownResourceTypes.Parameter, KnownProperties.Parameter.Value) => Create(KnownProperties.Parameter.Value, nameof(ResourcesDetailsParameterValueProperty), 0), _ => ((int SortOrder, KnownProperty KnownProperty)?)null }; diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index 9c493a0338e..9d4619f889b 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -419,6 +419,15 @@ public static string ResourcesDetailsProjectPathProperty { } } + /// + /// Looks up a localized string similar to Assembly name. + /// + public static string ResourcesDetailsProjectAssemblyNameProperty { + get { + return ResourceManager.GetString("ResourcesDetailsProjectAssemblyNameProperty", resourceCulture); + } + } + /// /// Looks up a localized string similar to Launch profile. /// diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index b5935f76fcc..b701aa5e83f 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -199,6 +199,9 @@ Project path + + Assembly name + Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index fdc85c79944..85af657b1ce 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index 7a04a97d2cf..8cda922702c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index 7bcca9159dd..344c1c8f47c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index 1bdd2b3cb2c..38cae00d8f7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index 0c004cda6e5..16a89fa3fca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index b11b9e1e5d0..a95563b5064 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index a18ed518ad3..270bc0de658 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index ae2cb002314..886a6e271c5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index 573c1f18e5a..f23d9aa68dd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index b764f5dd86d..77eebba4eb0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 69eb6294138..530cceb4c07 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index 3d575adcdda..ea815bab081 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index b6dc74b983b..4208b72b9a0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -197,6 +197,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index bfa347add2a..7bec7d637e7 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -51,7 +51,135 @@ - + + + + + + + + + + <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> + TargetFramework=$([System.Text.RegularExpressions.Regex]::Match('%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)', '^[^;]*')) + + RuntimeIdentifier;SelfContained + TargetFramework;RuntimeIdentifier;SelfContained + + + + + + + + + + + + + + <_AspireResolvedProjectFile>%(_AspireProjectResourceTargetPath.MSBuildSourceProjectFile) + <_AspireResolvedProjectFile Condition="'$(_AspireResolvedProjectFile)' != ''">$([System.IO.Path]::GetFullPath('$(_AspireResolvedProjectFile)')) + <_AspireResolvedAssemblyName>%(_AspireProjectResourceTargetPath.Filename) + + <_AspireResolvedAssemblyNameLiteral>$(_AspireResolvedAssemblyName.Replace('"', '""')) + + + + + $(_AspireResolvedAssemblyName) + $(_AspireResolvedAssemblyNameLiteral) + + + + + + + + + + + /// The assembly name that the ]]>%(ClassName) + /// + /// Evaluated by MSBuild when this AppHost was built, so it reflects any AssemblyName set by the project or + /// imported into it rather than the project file name. + /// +#nullable enable + public string? AssemblyName => @"]]>%(AspireProjectMetadataSource.ProjectAssemblyNameLiteral) + + + + @@ -73,7 +201,7 @@ namespace Projects%3B /// The path to the ]]>%(ClassName) public string ProjectPath => """]]>%(ProjectPath)%(AspireProjectMetadataSource.AssemblyNameMember) /// Gets a value indicating whether building the project before running it should be suppressed. /// diff --git a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs index 09696328913..eecb22b4af3 100644 --- a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs @@ -50,6 +50,7 @@ internal static (string? DisplayName, bool IsHighlighted, int? SortOrder) Get(st (KnownResourceTypes.Project, KnownProperties.Project.Path) => (ResourcePropertyProjectPathDisplayName, true, 0), (KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile) => (ResourcePropertyProjectLaunchProfileDisplayName, true, 1), (KnownResourceTypes.Project, KnownProperties.Executable.Pid) => (ResourcePropertyExecutableProcessIdDisplayName, true, 2), + (KnownResourceTypes.Project, KnownProperties.Project.AssemblyName) => (ResourcePropertyProjectAssemblyNameDisplayName, true, 3), _ => (null, false, null) }; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 495a6d3f430..fc41cee72b3 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -131,6 +131,7 @@ public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourc public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSnapshot previous) { string? projectPath = null; + string? projectAssemblyName = null; string? launchProfileName = null; IResource? appModelResource = null; @@ -139,13 +140,16 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn { if (appModelResource is ProjectResource projectResource) { - projectPath = projectResource.GetProjectMetadata().ProjectPath; + var metadata = projectResource.GetProjectMetadata(); + projectPath = metadata.ProjectPath; + projectAssemblyName = metadata.AssemblyName; launchProfileName = projectResource.GetEffectiveLaunchProfile()?.Name; } else if (appModelResource.TryGetProjectMetadata(out var projectMetadata)) { // New-style, annotation-based C# service (DotnetProjectResource) projectPath = projectMetadata.ProjectPath; + projectAssemblyName = projectMetadata.AssemblyName; launchProfileName = appModelResource.GetEffectiveLaunchProfile()?.Name; } } @@ -171,21 +175,31 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn if (projectPath is not null) { + List projectProperties = [ + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Path, executable.Spec.ExecutablePath), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Args, effectiveArgs ?? [], isSensitive: true), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Pid, executable.Status?.ProcessId), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.Path, projectPath), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), + new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, + new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + ]; + + // The assembly name is only known when the AppHost build baked it into the generated project metadata. + // Its absence is the capability signal for consumers, so nothing is written when it could not be resolved + // rather than writing a null or empty placeholder they would have to special-case. + if (!string.IsNullOrWhiteSpace(projectAssemblyName)) + { + projectProperties.Add(ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.AssemblyName, projectAssemblyName)); + } + return previous with { ResourceType = previous.ResourceType ?? KnownResourceTypes.Project, State = state, ExitCode = executable.Status?.ExitCode, - Properties = previous.Properties.SetResourcePropertyRange([ - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Path, executable.Spec.ExecutablePath), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Args, effectiveArgs ?? [], isSensitive: true), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Pid, executable.Status?.ProcessId), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.Path, projectPath), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), - new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, - new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, - ]), + Properties = previous.Properties.SetResourcePropertyRange([.. projectProperties]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), diff --git a/src/Aspire.Hosting/IProjectMetadata.cs b/src/Aspire.Hosting/IProjectMetadata.cs index b1f4ca6167e..9aee358f6f5 100644 --- a/src/Aspire.Hosting/IProjectMetadata.cs +++ b/src/Aspire.Hosting/IProjectMetadata.cs @@ -31,6 +31,25 @@ public interface IProjectMetadata : IResourceAnnotation /// public bool SuppressBuild => false; + /// + /// Gets the assembly name that the project evaluates to, or when it is unknown. + /// + /// + /// + /// This value is baked into the generated project metadata when the AppHost is built. It is the MSBuild-evaluated + /// name of the built output (TargetName, which defaults to AssemblyName) rather than the project file + /// name. That distinction matters when a project sets AssemblyName - often from an imported + /// Directory.Build.props - because the launched assembly is then named after the assembly and not after the + /// project. + /// + /// + /// Implementations that are not produced by the AppHost build - for example metadata created from a project + /// path at runtime, file-based apps, or third-party implementations - return . Consumers + /// must therefore treat the value as an optional hint and fall back to their existing behavior when it is absent. + /// + /// + public string? AssemblyName => null; + /// /// Gets a value indicating whether the project is a file-based app (a .cs file) rather than a full project (.csproj). /// diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 574805eb806..a04230d8852 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -222,6 +222,15 @@ internal static string ResourcePropertyParameterValueDisplayName { } } + /// + /// Looks up a localized string similar to Assembly name. + /// + internal static string ResourcePropertyProjectAssemblyNameDisplayName { + get { + return ResourceManager.GetString("ResourcePropertyProjectAssemblyNameDisplayName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Launch profile. /// diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index 48397cad0f8..70e0695a89e 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -171,6 +171,9 @@ Value + + Assembly name + Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index f3aabb2f61c..7802f6f0491 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index bc2a6356284..ced4c9987e6 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index f8ed94f521f..d9fe111c316 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index 2c74c66c1fb..fbc6d104588 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index 9fc89535a7c..c545d39d96d 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index c1d2d60294b..31ebe7b40c9 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index 8167488898a..4568536864d 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index ff7a68442b1..07d59506b91 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 17901aec38d..8946af49a06 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index 9c70a113dc0..5a62134f3ff 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 003f91a38df..4a53abb469b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index 041eed0fb4e..7cd42b5581c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 23302c2edbb..31e37366f30 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -122,6 +122,11 @@ Value + + Assembly name + Assembly name + + Launch profile Launch profile diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index e7d6a482e9b..914c54969d4 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -55,6 +55,13 @@ public static class Project { public const string Path = "project.path"; public const string LaunchProfile = "project.launchProfile"; + + /// + /// The MSBuild-evaluated assembly name of the project, baked into the generated project metadata at + /// AppHost build time. Only present for project resources added through a ProjectReference; the absence + /// of the property is the signal that the producer could not determine the name. + /// + public const string AssemblyName = "project.assemblyName"; } public static class Terminal diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 83c02f3da3f..b0cce81cf55 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -330,6 +330,34 @@ public void MapToResourceJson_ResolvesWaitingForDependencies() Assert.Equal(["messaging"], result.WaitingFor); } + [Fact] + public void MapToResourceJson_PreservesProjectAssemblyNameProperty() + { + // The CLI/backchannel property bag is a pass-through, so the build-time project.assemblyName + // contract reaches `aspire describe` without any mapper-specific handling. + var resource = new ResourceSnapshot + { + Name = "frontend", + DisplayName = "frontend", + ResourceType = "Project", + State = "Running", + Properties = new Dictionary + { + ["project.path"] = JsonValue.Create("/repo/Worker/Worker.csproj"), + ["project.assemblyName"] = JsonValue.Create("My Attach Service") + } + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource]); + + Assert.NotNull(result.Properties); + Assert.Equal("My Attach Service", result.Properties["project.assemblyName"]?.GetValue()); + + var json = JsonSerializer.Serialize(result, ResourcesCommandJsonContext.RelaxedEscaping.ResourceJson); + using var document = JsonDocument.Parse(json); + Assert.Equal("My Attach Service", document.RootElement.GetProperty("properties").GetProperty("project.assemblyName").GetString()); + } + [Fact] public void MapToResourceJson_MapsListPropertiesAsJsonArrays() { diff --git a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs index b026519809a..a3ebeaeabf8 100644 --- a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs @@ -23,6 +23,7 @@ public void FindProperty_GenericResourceProperty_ReturnsKnownProperty() [Theory] [InlineData(KnownProperties.Project.Path)] [InlineData(KnownProperties.Project.LaunchProfile)] + [InlineData(KnownProperties.Project.AssemblyName)] [InlineData(KnownProperties.Executable.Path)] [InlineData(KnownProperties.Executable.WorkDir)] [InlineData(KnownProperties.Executable.Args)] diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs index 94f797a135a..2c0f514b2b3 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs @@ -205,6 +205,7 @@ public void ToViewModel_ProducerSuppliedPropertyMetadata_DoesNotRequireKnownProp [InlineData(KnownResourceTypes.Project, KnownProperties.Project.Path, nameof(DashboardResources.ResourcesDetailsProjectPathProperty), 0)] [InlineData(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, nameof(DashboardResources.ResourcesDetailsProjectLaunchProfileProperty), 1)] [InlineData(KnownResourceTypes.Project, KnownProperties.Executable.Pid, nameof(DashboardResources.ResourcesDetailsExecutableProcessIdProperty), 2)] + [InlineData(KnownResourceTypes.Project, KnownProperties.Project.AssemblyName, nameof(DashboardResources.ResourcesDetailsProjectAssemblyNameProperty), 3)] [InlineData(KnownResourceTypes.Parameter, KnownProperties.Parameter.Value, nameof(DashboardResources.ResourcesDetailsParameterValueProperty), 0)] public void ToViewModel_LegacyBuiltInResourceSpecificPropertyMetadata_AppliesFallback(string resourceType, string propertyName, string expectedDisplayNameResourceName, int expectedProducerSortOrder) { diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 9d5389daa98..da0e87c5e66 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -92,6 +92,107 @@ public async Task AddReferenceToDashboardAndDcpFallsBackToRuntimeIdentifierToolF AssertDashboardAndOrchestrationReferences(packageReferences); } + [Fact] + public async Task ProjectMetadataUsesAssemblyNameImportedFromDirectoryBuildProps() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // The AssemblyName deliberately lives outside the project file. Reading the raw project XML + // would fall back to the file name ("Worker") and produce a process name that does not exist. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + + """, + referencedDirectoryBuildPropsXml: """ + + My Attach Service + + """); + + Assert.Equal(""" public string? AssemblyName => @"My Attach Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesConfigurationConditionedAssemblyName() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Released Service + Debugged Service + + """, + extraArguments: ["-p:Configuration=Release"], + configuration: "Release"); + + Assert.Equal(""" public string? AssemblyName => @"Released Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesTargetFrameworkConditionedAssemblyNameForMultiTargetedReference() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0;net9.0 + Eight Service + Nine Service + + """); + + Assert.Equal(""" public string? AssemblyName => @"Eight Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataEscapesAssemblyNameForCSharpSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Ünicode "quoted" O'Brien + + """); + + Assert.Equal(""" public string? AssemblyName => @"Ünicode ""quoted"" O'Brien";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataOmitsAssemblyNameWhenResolutionIsDisabled() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + My Attach Service + + """, + extraArguments: ["-p:SkipAspireProjectResourceAssemblyName=true"]); + + Assert.Null(GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled() { @@ -743,6 +844,126 @@ await File.WriteAllTextAsync(Path.Combine(projectDirectory, "AppHost.csproj"), return await File.ReadAllLinesAsync(packageReferencesPath); } + /// + /// Builds a throwaway AppHost that ProjectReferences a single worker project, runs the Aspire + /// codegen target, and returns the generated IProjectMetadata source for the worker. + /// + private static async Task GenerateProjectMetadataSourceAsync( + TemporaryWorkspace workspace, + string referencedProjectXml, + string? referencedDirectoryBuildPropsXml = null, + string[]? extraArguments = null, + string targetFramework = "net8.0", + string configuration = "Debug") + { + var repoRoot = GetRepoRoot(); + + // Terminate MSBuild's upward Directory.Build.props/targets probe at the workspace root so the + // generated metadata only reflects what this test authored, not whatever happens to sit above + // the temp directory on the machine running the test. + await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.props"), ""); + await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.targets"), ""); + + var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Worker")).FullName; + await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Worker.csproj"), + $""" + + + {referencedProjectXml} + + + """); + await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Program.cs"), """ + System.Console.WriteLine("worker"); + """); + + if (referencedDirectoryBuildPropsXml is not null) + { + await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Directory.Build.props"), + $""" + + + {referencedDirectoryBuildPropsXml} + + + """); + } + + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "AppHost")).FullName; + var appHostTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets")); + var appHostProjectFile = Path.Combine(appHostDirectory, "AppHost.csproj"); + + // The SDK props/targets are imported explicitly so the Aspire AppHost targets land *after* + // Sdk.targets, which is where a NuGet package's build/*.targets normally gets imported. The + // ordering matters because the codegen writes to $(IntermediateOutputPath), which is only + // defined once Microsoft.Common.CurrentVersion.targets has been evaluated. + // The ProjectReference metadata mirrors what Aspire.AppHost.Sdk defaults for Aspire project + // resources; this test imports only the AppHost targets, so the defaults are spelled out. + await File.WriteAllTextAsync(appHostProjectFile, + $$""" + + + + + + Exe + {{targetFramework}} + true + <_AspireTasksAssembly>{{SecurityElement.Escape(GetAspireHostingTasksAssemblyPath())}} + true + true + + + + + + + + + + + + """); + await File.WriteAllTextAsync(Path.Combine(appHostDirectory, "Program.cs"), """ + System.Console.WriteLine("apphost"); + """); + + var arguments = new List + { + "msbuild", + "-nologo", + "-restore", + "-t:WriteAspireProjectMetadataSources", + appHostProjectFile + }; + + if (extraArguments is not null) + { + arguments.AddRange(extraArguments); + } + + var result = await RunDotNetWithArgumentsAsync(appHostDirectory, [.. arguments]); + Assert.True(result.ExitCode == 0, result.Output); + + var generatedPath = Path.Combine(appHostDirectory, "obj", configuration, targetFramework, "Aspire", "references", "Worker.ProjectMetadata.g.cs"); + Assert.True(File.Exists(generatedPath), $"Generated project metadata was not found at '{generatedPath}'.{Environment.NewLine}{result.Output}"); + + return await File.ReadAllTextAsync(generatedPath); + } + + private static string? GetGeneratedAssemblyNameMember(string generatedSource) + { + return generatedSource + .Split('\n') + .Select(line => line.TrimEnd('\r')) + .SingleOrDefault(line => line.Contains("AssemblyName =>", StringComparison.Ordinal)); + } + private static async Task CreateRunHookProjectAsync( string workspace, bool aspireUseCliBundle, diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 7f8a83a822c..1b9d2fe0bbe 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -84,6 +84,72 @@ public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties() AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 2); } + [Fact] + public void ProjectSnapshotAddsAssemblyNameWhenProjectMetadataSuppliesIt() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata { AssemblyName = "My Attach Service" }); + project.Annotations.Add(new LaunchProfileAnnotation("https")); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + AssertHighlightedProperty(snapshot, KnownProperties.Project.AssemblyName, "Assembly name", isSensitive: false, sortOrder: 3); + Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.AssemblyName).Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ProjectSnapshotOmitsAssemblyNameWhenProjectMetadataDoesNotSupplyIt(string? assemblyName) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata { AssemblyName = assemblyName }); + project.Annotations.Add(new LaunchProfileAnnotation("https")); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Empty(snapshot.Properties.Where(p => p.Name == KnownProperties.Project.AssemblyName)); + } + + [Fact] + public void ExecutableSnapshotWithoutProjectMetadataOmitsAssemblyName() + { + var executable = Executable.Create("exe", "dotnet"); + executable.Spec.WorkingDirectory = "/app"; + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Empty(snapshot.Properties.Where(p => p.Name == KnownProperties.Project.AssemblyName)); + } + [Fact] public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations() { @@ -294,6 +360,8 @@ private sealed class TestProjectMetadata : IProjectMetadata { public string ProjectPath => "/app/project.csproj"; + public string? AssemblyName { get; init; } + public LaunchSettings LaunchSettings { get; } = new() { Profiles = diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs index 1b893626839..ec7fd91d0d1 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs @@ -106,6 +106,27 @@ public void WithProjectDefaultsAppliesToAProjectResourceThatWasAddedDirectly() Assert.Single(project.Resource.Annotations.OfType()); } + [Fact] + public void ProjectMetadataAssemblyNameDefaultsToNullForImplementationsThatDoNotSupplyIt() + { + // AssemblyName is a default interface member so metadata types that shipped before the + // build-time contract existed (external implementations, path-based and file-based apps) + // stay source and binary compatible. + IProjectMetadata metadata = new TestProject(); + + Assert.Null(metadata.AssemblyName); + } + + [Fact] + public void ProjectMetadataAssemblyNameIsNullForPathBasedProjects() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var project = builder.AddProject("project", Path.Combine(AppContext.BaseDirectory, "project.csproj"), options => options.ExcludeLaunchProfile = true); + + Assert.Null(project.Resource.GetProjectMetadata().AssemblyName); + } + [Fact] public void WithProjectDefaultsThrowsWhenResourceHasMultipleProjectMetadataAnnotations() { From a4038f1dc7e19d2adf3164acaf9aeee8d7e43afd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 00:36:19 -0400 Subject: [PATCH 02/90] Preserve project reference metadata for assembly names Carry the ProjectReference configuration, platform, and global-property removal metadata into the GetTargetPath evaluation used for generated assembly names. Preserve an explicitly selected target framework rather than replacing it with the first declared framework. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb --- .../build/Aspire.Hosting.AppHost.in.targets | 26 ++-- .../AppHostSdkTargetsTests.cs | 117 +++++++++++++++++- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 7bec7d637e7..253556a0f42 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -64,10 +64,11 @@ is $(TargetName), which defaults to $(AssemblyName), so %(Filename) is the evaluated assembly name including anything contributed by an imported Directory.Build.props or conditioned on Configuration/TargetFramework. - Single-targeted references are deliberately asked without an explicit TargetFramework: that matches the global - properties ResolveProjectReferences already uses, so MSBuild serves the result from its project cache instead of - evaluating the reference a second time. For those references TargetFramework is also undefined so a multi-targeted - AppHost's inner build cannot force its own TFM onto a reference that does not build for it. + When a single-targeted reference does not specify SetTargetFramework, it is deliberately asked without an explicit + TargetFramework: that matches the global properties ResolveProjectReferences already uses, so MSBuild serves the + result from its project cache instead of evaluating the reference a second time. For those references + TargetFramework is also undefined so a multi-targeted AppHost's inner build cannot force its own TFM onto a + reference that does not build for it. --> <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> - TargetFramework=$([System.Text.RegularExpressions.Regex]::Match('%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)', '^[^;]*')) + TargetFramework=$([System.Text.RegularExpressions.Regex]::Match('%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)', '^[^;]*')) - RuntimeIdentifier;SelfContained - TargetFramework;RuntimeIdentifier;SelfContained + RuntimeIdentifier;SelfContained + TargetFramework;RuntimeIdentifier;SelfContained diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index da0e87c5e66..4875f5e1b97 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -137,6 +137,73 @@ public async Task ProjectMetadataUsesConfigurationConditionedAssemblyName() Assert.Equal(""" public string? AssemblyName => @"Released Service";""", GetGeneratedAssemblyNameMember(generatedSource)); } + [Fact] + public async Task ProjectMetadataUsesProjectReferenceConfiguration() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Released Service + Debugged Service + + """, + projectReferenceMetadataXml: """ + Configuration=Release + """); + + Assert.Equal(""" public string? AssemblyName => @"Released Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesProjectReferencePlatform() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + 64-bit Service + Any CPU Service + + """, + projectReferenceMetadataXml: """ + Platform=x64 + """); + + Assert.Equal(""" public string? AssemblyName => @"64-bit Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataRemovesProjectReferenceGlobalProperties() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Unflavored Service + Flavored Service + + """, + projectReferenceMetadataXml: """ + Flavor + """, + extraArguments: ["-p:Flavor=Chocolate"]); + + Assert.Equal(""" public string? AssemblyName => @"Unflavored Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ProjectMetadataUsesTargetFrameworkConditionedAssemblyNameForMultiTargetedReference() { @@ -156,6 +223,49 @@ public async Task ProjectMetadataUsesTargetFrameworkConditionedAssemblyNameForMu Assert.Equal(""" public string? AssemblyName => @"Eight Service";""", GetGeneratedAssemblyNameMember(generatedSource)); } + [Fact] + public async Task ProjectMetadataRespectsProjectReferenceTargetFramework() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0;net9.0 + Eight Service + Nine Service + + """, + projectReferenceMetadataXml: """ + TargetFramework=net9.0 + """); + + Assert.Equal(""" public string? AssemblyName => @"Nine Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataRespectsProjectReferenceTargetFrameworkForSingleTargetedReference() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Eight Service + + """, + projectReferenceMetadataXml: """ + TargetFramework=net8.0 + """); + + Assert.Equal(""" public string? AssemblyName => @"Eight Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ProjectMetadataEscapesAssemblyNameForCSharpSource() { @@ -854,7 +964,8 @@ private static async Task GenerateProjectMetadataSourceAsync( string? referencedDirectoryBuildPropsXml = null, string[]? extraArguments = null, string targetFramework = "net8.0", - string configuration = "Debug") + string configuration = "Debug", + string? projectReferenceMetadataXml = null) { var repoRoot = GetRepoRoot(); @@ -920,7 +1031,9 @@ await File.WriteAllTextAsync(appHostProjectFile, ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" ExcludeAssets="all" - Private="false" /> + Private="false"> + {{projectReferenceMetadataXml}} + From 8fced488502242ba880801a9b01f7ad21b126603 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 05:50:21 -0400 Subject: [PATCH 03/90] Use prepared references for project assembly names Resolve assembly names from the SDK-prepared project reference contract so solution-selected configuration and platform values match the selected output. Preserve explicit target frameworks while retaining all other global-property removals, and add regressions for both cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb --- .../build/Aspire.Hosting.AppHost.in.targets | 31 +++++++---- .../AppHostSdkTargetsTests.cs | 54 +++++++++++++++---- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 253556a0f42..0313713c40b 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -55,7 +55,11 @@ Resolves the MSBuild-evaluated assembly name for every Aspire project resource so it can be baked into the generated IProjectMetadata at AppHost build time. Nothing here runs at application run time. - Two phases are required: + PrepareProjectReferences first applies the SDK's canonical solution configuration, platform negotiation, + explicit target framework, and global-property removal metadata. The assembly name evaluation starts from + those prepared items so it uses the same reference contract as the project build. + + Two evaluation phases are then required: 1. GetTargetFrameworks reports whether a reference cross-targets and, when it does, which target frameworks it declares. GetTargetPath is only defined by the inner build (Microsoft.Common.CurrentVersion.targets); Microsoft.Common.CrossTargeting.targets does not define it, so a cross-targeting reference has to be asked @@ -71,15 +75,19 @@ reference that does not build for it. --> - + <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true'))" /> + + + @@ -97,12 +105,15 @@ <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> TargetFramework=$([System.Text.RegularExpressions.Regex]::Match('%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)', '^[^;]*')) + %(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove) + $([System.Text.RegularExpressions.Regex]::Replace('%(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove)', '(?i)(^|;)\s*TargetFramework\s*(?=;|$)', '$1')) + RuntimeIdentifier;SelfContained TargetFramework;RuntimeIdentifier;SelfContained @@ -113,7 +124,7 @@ BuildInParallel="$(BuildInParallel)" Properties="%(_AspireProjectResourceTargetFrameworkInfo.SetConfiguration); %(_AspireProjectResourceTargetFrameworkInfo.SetPlatform); %(_AspireProjectResourceTargetFrameworkInfo.SetTargetFramework)" ContinueOnError="true" - RemoveProperties="%(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove);%(_AspireProjectResourceTargetFrameworkInfo.RemoveGlobalProperties);$(_GlobalPropertiesToRemoveFromProjectReferences)" + RemoveProperties="%(_AspireProjectResourceTargetFrameworkInfo.EffectiveGlobalPropertiesToRemove);%(_AspireProjectResourceTargetFrameworkInfo.RemoveGlobalProperties);$(_GlobalPropertiesToRemoveFromProjectReferences)" SkipNonexistentProjects="true" SkipNonexistentTargets="true"> diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 4875f5e1b97..6f026093297 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -181,6 +181,25 @@ public async Task ProjectMetadataUsesProjectReferencePlatform() Assert.Equal(""" public string? AssemblyName => @"64-bit Service";""", GetGeneratedAssemblyNameMember(generatedSource)); } + [Fact] + public async Task ProjectMetadataUsesSolutionPreparedProjectReferenceConfigurationAndPlatform() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Worker_$(Configuration)_$(Platform)_$(TargetFramework) + + """, + solutionProjectConfiguration: "Release|x64"); + + Assert.Equal(""" public string? AssemblyName => @"Worker_Release_x64_net8.0";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ProjectMetadataRemovesProjectReferenceGlobalProperties() { @@ -246,7 +265,7 @@ public async Task ProjectMetadataRespectsProjectReferenceTargetFramework() } [Fact] - public async Task ProjectMetadataRespectsProjectReferenceTargetFrameworkForSingleTargetedReference() + public async Task ProjectMetadataPreservesExplicitTargetFrameworkWhenItIsAlsoRemoved() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -255,15 +274,20 @@ public async Task ProjectMetadataRespectsProjectReferenceTargetFrameworkForSingl referencedProjectXml: """ Exe - net8.0 - Eight Service + net8.0;net9.0 + Eight Clean Service + Eight Flavored Service + Nine Clean Service + Nine Flavored Service """, projectReferenceMetadataXml: """ - TargetFramework=net8.0 - """); + TargetFramework=net9.0 + Flavor;TargetFramework + """, + extraArguments: ["-p:Flavor=Chocolate"]); - Assert.Equal(""" public string? AssemblyName => @"Eight Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? AssemblyName => @"Nine Clean Service";""", GetGeneratedAssemblyNameMember(generatedSource)); } [Fact] @@ -965,7 +989,8 @@ private static async Task GenerateProjectMetadataSourceAsync( string[]? extraArguments = null, string targetFramework = "net8.0", string configuration = "Debug", - string? projectReferenceMetadataXml = null) + string? projectReferenceMetadataXml = null, + string? solutionProjectConfiguration = null) { var repoRoot = GetRepoRoot(); @@ -976,7 +1001,8 @@ private static async Task GenerateProjectMetadataSourceAsync( await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.targets"), ""); var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Worker")).FullName; - await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Worker.csproj"), + var workerProjectFile = Path.Combine(workerDirectory, "Worker.csproj"); + await File.WriteAllTextAsync(workerProjectFile, $""" @@ -1003,6 +1029,13 @@ await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Directory.Build.prop var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "AppHost")).FullName; var appHostTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets")); var appHostProjectFile = Path.Combine(appHostDirectory, "AppHost.csproj"); + var solutionConfigurationXml = solutionProjectConfiguration is null + ? null + : $$""" + + <SolutionConfiguration><ProjectConfiguration Project="{C42D47BF-C684-40EB-B438-FC98C4DC6F5D}" AbsolutePath="{{SecurityElement.Escape(workerProjectFile)}}" BuildProjectInSolution="True">{{solutionProjectConfiguration}}</ProjectConfiguration></SolutionConfiguration> + + """; // The SDK props/targets are imported explicitly so the Aspire AppHost targets land *after* // Sdk.targets, which is where a NuGet package's build/*.targets normally gets imported. The @@ -1026,16 +1059,19 @@ await File.WriteAllTextAsync(appHostProjectFile, - + {C42D47BF-C684-40EB-B438-FC98C4DC6F5D} {{projectReferenceMetadataXml}} + {{solutionConfigurationXml}} + From 09b3f8eb9a163f847ca3bdb47e50ce7ccfa32e9f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 10:55:42 -0400 Subject: [PATCH 04/90] Address review findings on the assembly name probe Remove the unreachable dashboard legacy metadata. The dashboard resolves project.assemblyName through the producer-supplied metadata that the AppHost already sends, so the LegacyResourcePropertyMetadata arm and its localized resource string could never be reached. Deleting them leaves src/Aspire.Dashboard with no diff at all for this feature. Fail the probe loudly during a real build. Both MSBuild calls now use ContinueOnError="!$(BuildingProject)", matching _ValidateAspireHostProjectResources, so a genuinely broken project reference surfaces as an error instead of silently producing metadata without an assembly name. Strengthen the snapshot tests. The two omission tests now assert the complete property set with Assert.Equal rather than the absence of a single name, so a future project property has to be acknowledged instead of slipping through. This immediately caught two resource properties missing from the expected set. Also pin ManagePackageVersionsCentrally=false in the SDK test workspace so an ambient Directory.Packages.props cannot leak into the generated metadata, and document the SkipAspireProjectResourceAssemblyName opt-out in the targets file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d5b4985-cae3-4ee6-b159-53ae9385b638 --- .../Model/LegacyResourcePropertyMetadata.cs | 1 - .../Resources/Resources.Designer.cs | 9 ------ src/Aspire.Dashboard/Resources/Resources.resx | 3 -- .../Resources/xlf/Resources.cs.xlf | 5 ---- .../Resources/xlf/Resources.de.xlf | 5 ---- .../Resources/xlf/Resources.es.xlf | 5 ---- .../Resources/xlf/Resources.fr.xlf | 5 ---- .../Resources/xlf/Resources.it.xlf | 5 ---- .../Resources/xlf/Resources.ja.xlf | 5 ---- .../Resources/xlf/Resources.ko.xlf | 5 ---- .../Resources/xlf/Resources.pl.xlf | 5 ---- .../Resources/xlf/Resources.pt-BR.xlf | 5 ---- .../Resources/xlf/Resources.ru.xlf | 5 ---- .../Resources/xlf/Resources.tr.xlf | 5 ---- .../Resources/xlf/Resources.zh-Hans.xlf | 5 ---- .../Resources/xlf/Resources.zh-Hant.xlf | 5 ---- .../build/Aspire.Hosting.AppHost.in.targets | 25 ++++++++++++++--- .../Resources/MessageStrings.Designer.cs | 2 +- .../Model/ResourceViewModelTests.cs | 1 - .../AppHostSdkTargetsTests.cs | 6 +++- .../Dcp/ResourceSnapshotBuilderTests.cs | 28 +++++++++++++++++-- 21 files changed, 53 insertions(+), 87 deletions(-) diff --git a/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs b/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs index 4254e0bbe1b..cc099b7c05c 100644 --- a/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs +++ b/src/Aspire.Dashboard/Model/LegacyResourcePropertyMetadata.cs @@ -30,7 +30,6 @@ internal static (int SortOrder, KnownProperty KnownProperty)? Get(string resourc (KnownResourceTypes.Project, KnownProperties.Project.Path) => Create(KnownProperties.Project.Path, nameof(ResourcesDetailsProjectPathProperty), 0), (KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile) => Create(KnownProperties.Project.LaunchProfile, nameof(ResourcesDetailsProjectLaunchProfileProperty), 1), (KnownResourceTypes.Project, KnownProperties.Executable.Pid) => Create(KnownProperties.Executable.Pid, nameof(ResourcesDetailsExecutableProcessIdProperty), 2), - (KnownResourceTypes.Project, KnownProperties.Project.AssemblyName) => Create(KnownProperties.Project.AssemblyName, nameof(ResourcesDetailsProjectAssemblyNameProperty), 3), (KnownResourceTypes.Parameter, KnownProperties.Parameter.Value) => Create(KnownProperties.Parameter.Value, nameof(ResourcesDetailsParameterValueProperty), 0), _ => ((int SortOrder, KnownProperty KnownProperty)?)null }; diff --git a/src/Aspire.Dashboard/Resources/Resources.Designer.cs b/src/Aspire.Dashboard/Resources/Resources.Designer.cs index 9d4619f889b..9c493a0338e 100644 --- a/src/Aspire.Dashboard/Resources/Resources.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Resources.Designer.cs @@ -419,15 +419,6 @@ public static string ResourcesDetailsProjectPathProperty { } } - /// - /// Looks up a localized string similar to Assembly name. - /// - public static string ResourcesDetailsProjectAssemblyNameProperty { - get { - return ResourceManager.GetString("ResourcesDetailsProjectAssemblyNameProperty", resourceCulture); - } - } - /// /// Looks up a localized string similar to Launch profile. /// diff --git a/src/Aspire.Dashboard/Resources/Resources.resx b/src/Aspire.Dashboard/Resources/Resources.resx index b701aa5e83f..b5935f76fcc 100644 --- a/src/Aspire.Dashboard/Resources/Resources.resx +++ b/src/Aspire.Dashboard/Resources/Resources.resx @@ -199,9 +199,6 @@ Project path - - Assembly name - Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf index 85af657b1ce..fdc85c79944 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.cs.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf index 8cda922702c..7a04a97d2cf 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.de.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf index 344c1c8f47c..7bcca9159dd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.es.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf index 38cae00d8f7..1bdd2b3cb2c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.fr.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf index 16a89fa3fca..0c004cda6e5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.it.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf index a95563b5064..b11b9e1e5d0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ja.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf index 270bc0de658..a18ed518ad3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ko.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf index 886a6e271c5..ae2cb002314 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pl.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf index f23d9aa68dd..573c1f18e5a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.pt-BR.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf index 77eebba4eb0..b764f5dd86d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.ru.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf index 530cceb4c07..69eb6294138 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.tr.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf index ea815bab081..3d575adcdda 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hans.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf index 4208b72b9a0..b6dc74b983b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Resources.zh-Hant.xlf @@ -197,11 +197,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 0313713c40b..2527d8bf1ba 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -55,6 +55,9 @@ Resolves the MSBuild-evaluated assembly name for every Aspire project resource so it can be baked into the generated IProjectMetadata at AppHost build time. Nothing here runs at application run time. + Set SkipAspireProjectResourceAssemblyName=true to opt out. The generated metadata then omits the AssemblyName + member and falls back to the IProjectMetadata.AssemblyName default interface member, which returns null. + PrepareProjectReferences first applies the SDK's canonical solution configuration, platform negotiation, explicit target framework, and global-property removal metadata. The assembly name evaluation starts from those prepared items so it uses the same reference contract as the project build. @@ -73,6 +76,12 @@ result from its project cache instead of evaluating the reference a second time. For those references TargetFramework is also undefined so a multi-targeted AppHost's inner build cannot force its own TFM onto a reference that does not build for it. + + Measured cost on a single-reference AppHost build: this adds one GetTargetFrameworks evaluation per reference and + no extra GetTargetPath evaluation, because ResolveProjectReferences already asks for GetTargetPath with the same + effective global properties and MSBuild serves the second request from its result cache. A reference whose TFM + differs from the AppHost's is a genuine second evaluation, which is the price of reading the name it actually + builds under. --> @@ -99,8 +108,11 @@ reference's TargetFrameworks metadata paired with its own project. %(TargetFrameworks) is a single semicolon-joined value such as "net8.0;net9.0"; the first entry is used because - it is the one a cross-targeting reference reports first and there is no better signal about which inner build - the AppHost will end up launching. + it is the one a cross-targeting reference reports first and Aspire never negotiates a TFM for these references, + so there is no better signal about which inner build the AppHost will end up launching. Guessing is worth it + here: a name resolved from the wrong inner build leaves a consumer exactly where omitting the property would + have - falling back to its own guess - while the common case, where every TFM produces the same assembly name, + resolves correctly. --> <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> @@ -123,7 +135,7 @@ Targets="GetTargetPath" BuildInParallel="$(BuildInParallel)" Properties="%(_AspireProjectResourceTargetFrameworkInfo.SetConfiguration); %(_AspireProjectResourceTargetFrameworkInfo.SetPlatform); %(_AspireProjectResourceTargetFrameworkInfo.SetTargetFramework)" - ContinueOnError="true" + ContinueOnError="!$(BuildingProject)" RemoveProperties="%(_AspireProjectResourceTargetFrameworkInfo.EffectiveGlobalPropertiesToRemove);%(_AspireProjectResourceTargetFrameworkInfo.RemoveGlobalProperties);$(_GlobalPropertiesToRemoveFromProjectReferences)" SkipNonexistentProjects="true" SkipNonexistentTargets="true"> @@ -138,6 +150,11 @@ This target is batched on the source project rather than using an ItemGroup, because MSBuild cannot join two item lists inside a single ItemGroup: the resolved target paths and the metadata sources are separate lists that have to be correlated by project path. + + GetTargetPath returns a single primary output for an SDK project, so each batch holds one target path. If a + reference ever returns more than one, the last wins - acceptable because the result is only ever a hint and the + property is dropped entirely when nothing resolves. Note the path comparison is MSBuild's, which is case + insensitive, so two references differing only in path casing would collide on a case-sensitive file system. --> /// Looks up a localized string similar to Launch profile. /// diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs index 2c0f514b2b3..94f797a135a 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs @@ -205,7 +205,6 @@ public void ToViewModel_ProducerSuppliedPropertyMetadata_DoesNotRequireKnownProp [InlineData(KnownResourceTypes.Project, KnownProperties.Project.Path, nameof(DashboardResources.ResourcesDetailsProjectPathProperty), 0)] [InlineData(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, nameof(DashboardResources.ResourcesDetailsProjectLaunchProfileProperty), 1)] [InlineData(KnownResourceTypes.Project, KnownProperties.Executable.Pid, nameof(DashboardResources.ResourcesDetailsExecutableProcessIdProperty), 2)] - [InlineData(KnownResourceTypes.Project, KnownProperties.Project.AssemblyName, nameof(DashboardResources.ResourcesDetailsProjectAssemblyNameProperty), 3)] [InlineData(KnownResourceTypes.Parameter, KnownProperties.Parameter.Value, nameof(DashboardResources.ResourcesDetailsParameterValueProperty), 0)] public void ToViewModel_LegacyBuiltInResourceSpecificPropertyMetadata_AppliesFallback(string resourceType, string propertyName, string expectedDisplayNameResourceName, int expectedProducerSortOrder) { diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 6f026093297..ebab923e4c9 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -996,9 +996,13 @@ private static async Task GenerateProjectMetadataSourceAsync( // Terminate MSBuild's upward Directory.Build.props/targets probe at the workspace root so the // generated metadata only reflects what this test authored, not whatever happens to sit above - // the temp directory on the machine running the test. + // the temp directory on the machine running the test. Directory.Packages.props is discovered + // independently of that probe, so central package management has to be switched off explicitly. await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.props"), ""); await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.targets"), ""); + await File.WriteAllTextAsync( + Path.Combine(workspace.Path, "Directory.Packages.props"), + "false"); var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Worker")).FullName; var workerProjectFile = Path.Combine(workerDirectory, "Worker.csproj"); diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 1b9d2fe0bbe..c54bc5d9689 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -131,7 +131,20 @@ public void ProjectSnapshotOmitsAssemblyNameWhenProjectMetadataDoesNotSupplyIt(s [project.Name] = project }).ToSnapshot(executable, CreatePreviousSnapshot()); - Assert.Empty(snapshot.Properties.Where(p => p.Name == KnownProperties.Project.AssemblyName)); + // Assert the complete property set rather than the absence of one name, so that a future property added + // to the project branch has to be acknowledged here instead of silently slipping through. + Assert.Equal( + [ + KnownProperties.Executable.Args, + KnownProperties.Executable.Path, + KnownProperties.Executable.Pid, + KnownProperties.Executable.WorkDir, + KnownProperties.Project.LaunchProfile, + KnownProperties.Project.Path, + KnownProperties.Resource.AppArgs, + KnownProperties.Resource.AppArgsSensitivity, + ], + snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); } [Fact] @@ -147,7 +160,18 @@ public void ExecutableSnapshotWithoutProjectMetadataOmitsAssemblyName() var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot()); - Assert.Empty(snapshot.Properties.Where(p => p.Name == KnownProperties.Project.AssemblyName)); + // An executable that is not a project resource never takes the project branch, so it gets the executable + // property set plus the shared resource properties, and no project properties at all. + Assert.Equal( + [ + KnownProperties.Executable.Args, + KnownProperties.Executable.Path, + KnownProperties.Executable.Pid, + KnownProperties.Executable.WorkDir, + KnownProperties.Resource.AppArgs, + KnownProperties.Resource.AppArgsSensitivity, + ], + snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); } [Fact] From 8a1856f198099f0e30ae5cd06ddca80a65f6494f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 11:18:58 -0400 Subject: [PATCH 05/90] Cover the strict probe path and skip it for the CLI server project The re-review pointed out that ContinueOnError="!$(BuildingProject)" was never actually exercised. Every SDK test invokes the metadata target directly with -t:, which never runs BuildOnlySettings, so BuildingProject stays at its default of false and the probe ran tolerant in all of them. Add a test that forces BuildingProject=true so the fatal-failure path is covered, and separately verify that a real `dotnet build` of tests/testproject/TestProject.AppHost still succeeds with 0 warnings and 0 errors. Skip the probe for the AppHost server project the CLI generates. Its ProjectReferences are Aspire.Hosting.* libraries rather than app resources anyone attaches a debugger to, several of them are multi-targeted, and it is built by a real `dotnet build`, so the probe would be pure cost on a newly fatal path. This matches the SkipValidateAspireHostProjectResources already set there for the same reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d5b4985-cae3-4ee6-b159-53ae9385b638 --- .../DotNetBasedAppHostServerProject.cs | 5 ++++ .../AppHostSdkTargetsTests.cs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index d6ab64c689c..a4e59236096 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -148,6 +148,11 @@ private XDocument CreateProjectFile(IEnumerable integratio {_repoRoot} true + + true true true 42.42.42 diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index ebab923e4c9..6da37bf953d 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -327,6 +327,29 @@ public async Task ProjectMetadataOmitsAssemblyNameWhenResolutionIsDisabled() Assert.Null(GetGeneratedAssemblyNameMember(generatedSource)); } + [Fact] + public async Task ProjectMetadataResolvesAssemblyNameWhenProbeFailuresAreFatal() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // The probe passes ContinueOnError="!$(BuildingProject)", so a real build makes a failed + // reference probe fatal while a design-time build stays tolerant. Every other test here runs + // a bare -t: invocation, which never executes BuildOnlySettings and therefore leaves + // BuildingProject at its default of false. Forcing it to true covers the strict path. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + My Attach Service + + """, + extraArguments: ["-p:BuildingProject=true"]); + + Assert.Equal(""" public string? AssemblyName => @"My Attach Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled() { From 14f5adee14eda3d5c22ab339cdf6b1b796ebe52b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 12:58:30 -0400 Subject: [PATCH 06/90] Remove the stale assembly name when the project no longer supplies one The project snapshot defines the *absence* of `project.assemblyName` as the capability signal telling consumers whether the evaluated assembly name can be relied on. Snapshots are merged into the previously published one and SetResourcePropertyRange only adds or replaces entries, so omitting the property when the metadata is blank left any earlier value in place. Consumers would then read a stale assembly name and believe the capability is present - exactly the failure mode the contract exists to prevent. Remove the known property from the carried-forward snapshot in the blank branch and cover it with a regression test that seeds a previous snapshot containing an assembly name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Dcp/ResourceSnapshotBuilder.cs | 15 +++- .../Dcp/ResourceSnapshotBuilderTests.cs | 72 ++++++++++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index fc41cee72b3..2a93ed225a1 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -187,19 +187,28 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ]; // The assembly name is only known when the AppHost build baked it into the generated project metadata. - // Its absence is the capability signal for consumers, so nothing is written when it could not be resolved - // rather than writing a null or empty placeholder they would have to special-case. + // Its absence - not a null or empty value - is the capability signal consumers use to decide whether the + // evaluated assembly name can be relied on, so nothing is written when it could not be resolved. + var previousProperties = previous.Properties; if (!string.IsNullOrWhiteSpace(projectAssemblyName)) { projectProperties.Add(ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.AssemblyName, projectAssemblyName)); } + else + { + // Snapshots are merged into the previously published one and SetResourcePropertyRange only adds or + // replaces, so simply omitting the property would leave an earlier value in place. That stale value + // would read as "the assembly name is available" and defeat the absence-is-the-signal contract, so + // the property has to be removed explicitly. + previousProperties = previousProperties.RemoveResourceProperty(KnownProperties.Project.AssemblyName); + } return previous with { ResourceType = previous.ResourceType ?? KnownResourceTypes.Project, State = state, ExitCode = executable.Status?.ExitCode, - Properties = previous.Properties.SetResourcePropertyRange([.. projectProperties]), + Properties = previousProperties.SetResourcePropertyRange([.. projectProperties]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index c54bc5d9689..cbe58172744 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Aspire.Dashboard.Model; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -147,6 +148,73 @@ public void ProjectSnapshotOmitsAssemblyNameWhenProjectMetadataDoesNotSupplyIt(s snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ProjectSnapshotRemovesStaleAssemblyNameWhenProjectMetadataNoLongerSuppliesIt(string? assemblyName) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata { AssemblyName = assemblyName }); + project.Annotations.Add(new LaunchProfileAnnotation("https")); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + // Snapshots are merged into the previously published one, so a carried-forward assembly name has to be + // removed rather than just omitted. Absence is the capability signal, and a surviving stale value would + // tell consumers the evaluated assembly name is still available. + var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.AssemblyName, "Stale.Assembly.Name")]); + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previous); + + Assert.Equal( + [ + KnownProperties.Executable.Args, + KnownProperties.Executable.Path, + KnownProperties.Executable.Pid, + KnownProperties.Executable.WorkDir, + KnownProperties.Project.LaunchProfile, + KnownProperties.Project.Path, + KnownProperties.Resource.AppArgs, + KnownProperties.Resource.AppArgsSensitivity, + ], + snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); + } + + [Fact] + public void ProjectSnapshotReplacesStaleAssemblyNameWhenProjectMetadataStillSuppliesIt() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata { AssemblyName = "My Attach Service" }); + project.Annotations.Add(new LaunchProfileAnnotation("https")); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.AssemblyName, "Stale.Assembly.Name")]); + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previous); + + Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.AssemblyName).Value); + } + [Fact] public void ExecutableSnapshotWithoutProjectMetadataOmitsAssemblyName() { @@ -342,12 +410,12 @@ private static DcpResourceSnapshotBuilder CreateSnapshotBuilder(IDictionary(), [])); } - private static CustomResourceSnapshot CreatePreviousSnapshot(string resourceType = "resource") + private static CustomResourceSnapshot CreatePreviousSnapshot(string resourceType = "resource", ImmutableArray properties = default) { return new() { ResourceType = resourceType, - Properties = [] + Properties = properties.IsDefault ? [] : properties }; } From 35356e970f7cec6f77ba2392010aacb70e77ef3b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 13:25:07 -0400 Subject: [PATCH 07/90] Accept the generated metadata snapshot for the new assembly name property The AppHost targets now emit an AssemblyName property into the generated project metadata, so MSBuildTests.ValidateMetadataSources failed on Hosting-6 with a VerifyException. Accept the regenerated snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MSBuildTests.ValidateMetadataSources.verified.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt index 13bc2952277..73bce7e424c 100644 --- a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt +++ b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt @@ -42,6 +42,17 @@ public class App : global::Aspire.Hosting.IProjectMetadata /// public string ProjectPath => """{AspirePath}/App/App.csproj"""; + /// + /// The assembly name that the App project builds to. + /// + /// + /// Evaluated by MSBuild when this AppHost was built, so it reflects any AssemblyName set by the project or + /// imported into it rather than the project file name. + /// +#nullable enable + public string? AssemblyName => @"App"; +#nullable restore + /// /// Gets a value indicating whether building the project before running it should be suppressed. /// From 0ab785c64603aeb4dde0a88bb7b781d1cd11bd8d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 15:20:54 -0400 Subject: [PATCH 08/90] Keep project paths containing an apostrophe out of MSBuild property functions CreateAspireProjectMetadataSources passed %(_AspireProjectResource.Identity) straight into GetFullPath and GetFileNameWithoutExtension. MSBuild substitutes %(...) into the unexpanded argument text of a property function, so an apostrophe in the value terminates the argument list, the parse is abandoned, and the whole expression is emitted back as a literal string. A project under a directory such as O'Brien therefore generated a ProjectPath of "$([System.IO.Path]::GetFullPath(../O'Brien/Worker.csproj))" and a source file named after the unparsed ClassName expression, which fails the AppHost build. Batch the target and route both values through properties first. $(...) is expanded only after the arguments are parsed, so the apostrophe never reaches the parser. This mirrors the property indirection already used for the assembly name in _SetAspireProjectMetadataAssemblyNames. The broken correlation also silently suppressed the new project.assemblyName property for such projects, which would have read as "the assembly name is not available" rather than as a failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../build/Aspire.Hosting.AppHost.in.targets | 25 ++++++++++++--- .../AppHostSdkTargetsTests.cs | 31 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 2527d8bf1ba..e90294afa00 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -41,12 +41,29 @@ + DependsOnTargets="_CreateAspireProjectResources" + Outputs="%(_AspireProjectResource.Identity)"> + + + <_AspireProjectResourceIdentity>%(_AspireProjectResource.Identity) + <_AspireProjectResourceTypeName>%(_AspireProjectResource.AspireProjectMetadataTypeName) + <_AspireProjectResourceClassNameSource Condition="'$(_AspireProjectResourceTypeName)' == ''">%(_AspireProjectResource.Filename) + <_AspireProjectResourceClassNameSource Condition="'$(_AspireProjectResourceTypeName)' != ''">$(_AspireProjectResourceTypeName) + + - $([System.Text.RegularExpressions.Regex]::Replace($([System.IO.Path]::GetFileNameWithoutExtension(%(_AspireProjectResource.Identity))), $(_GeneratedClassNameFixupRegex), '_')) - $([System.Text.RegularExpressions.Regex]::Replace(%(_AspireProjectResource.AspireProjectMetadataTypeName), $(_GeneratedClassNameFixupRegex), '_')) - $([System.IO.Path]::GetFullPath(%(_AspireProjectResource.Identity))) + $([System.Text.RegularExpressions.Regex]::Replace('$(_AspireProjectResourceClassNameSource)', $(_GeneratedClassNameFixupRegex), '_')) + $([System.IO.Path]::GetFullPath('$(_AspireProjectResourceIdentity)')) diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 6da37bf953d..a91bab8cc43 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -308,6 +308,30 @@ public async Task ProjectMetadataEscapesAssemblyNameForCSharpSource() Assert.Equal(""" public string? AssemblyName => @"Ünicode ""quoted"" O'Brien";""", GetGeneratedAssemblyNameMember(generatedSource)); } + [Fact] + public async Task ProjectMetadataResolvesAssemblyNameWhenProjectDirectoryContainsAnApostrophe() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // An apostrophe in the project path reaches two different MSBuild property functions: the + // GetFullPath over %(Identity) that normalizes ProjectPath, and the GetFullPath over the + // $(_AspireResolvedProjectFile) property that normalizes the resolved project file before the + // two lists are correlated. Both have to survive it, because a failure here does not degrade + // the assembly name - it fails metadata generation and takes the whole AppHost build with it. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Apostrophe Service + + """, + referencedProjectDirectoryName: "O'Brien"); + + Assert.Equal(""" public string? AssemblyName => @"Apostrophe Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + } + [Fact] public async Task ProjectMetadataOmitsAssemblyNameWhenResolutionIsDisabled() { @@ -1013,7 +1037,8 @@ private static async Task GenerateProjectMetadataSourceAsync( string targetFramework = "net8.0", string configuration = "Debug", string? projectReferenceMetadataXml = null, - string? solutionProjectConfiguration = null) + string? solutionProjectConfiguration = null, + string referencedProjectDirectoryName = "Worker") { var repoRoot = GetRepoRoot(); @@ -1027,7 +1052,7 @@ await File.WriteAllTextAsync( Path.Combine(workspace.Path, "Directory.Packages.props"), "false"); - var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Worker")).FullName; + var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, referencedProjectDirectoryName)).FullName; var workerProjectFile = Path.Combine(workerDirectory, "Worker.csproj"); await File.WriteAllTextAsync(workerProjectFile, $""" @@ -1086,7 +1111,7 @@ await File.WriteAllTextAsync(appHostProjectFile, - Date: Fri, 7 Aug 2026 14:23:26 -0400 Subject: [PATCH 09/90] Move attach debugger config behind debugger extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/CHANGELOG.md | 2 + extension/loc/xlf/aspire-vscode.xlf | 21 + extension/package.json | 19 +- extension/package.nls.json | 7 + extension/src/debugger/debuggerExtensions.ts | 57 ++ extension/src/debugger/languages/dotnet.ts | 141 ++- extension/src/extension.ts | 2 + extension/src/loc/strings.ts | 6 + .../src/test-e2e/packageSurface.e2e.test.ts | 3 + extension/src/test/appHostTreeView.test.ts | 827 +++++++++++++++++- extension/src/test/dotnetDebugger.test.ts | 114 ++- extension/src/test/packageManifest.test.ts | 14 + .../src/views/AspireAppHostTreeProvider.ts | 109 ++- extension/src/views/resourceLookup.ts | 75 ++ 14 files changed, 1346 insertions(+), 51 deletions(-) create mode 100644 extension/src/views/resourceLookup.ts diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 474ee018e09..eb13dde67b6 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -4,11 +4,13 @@ ### Features +- Add an Attach debugger action for running .NET project resources in the Aspire pane when the C# extension is installed ([#18602](https://github.com/microsoft/aspire/pull/18602)). - Flatten single-AppHost group nodes in the AppHosts tree view so a lone running or idle AppHost is surfaced directly at the top level instead of under a redundant `(1)` wrapper ([#18420](https://github.com/microsoft/aspire/issues/18420), [#18523](https://github.com/microsoft/aspire/pull/18523)). - Update the Marketplace page with focused AppHost-view, debug-session, and dashboard screenshots, and add AppHost telemetry signals for discovery, launch, and running-state metrics; all events respect `telemetry.telemetryLevel` ([#17898](https://github.com/microsoft/aspire/pull/17898)). ### Fixes +- Emit VS Code extension and dashboard telemetry with the `aspire/vscode/*` and `aspire/dashboard/*` wire names expected by downstream Aspire telemetry queries ([#18602](https://github.com/microsoft/aspire/pull/18602)). - Fix the Get Started walkthrough's Install Aspire CLI step to use a package-manager picker (WinGet, Homebrew, npm, .NET tool, mise) instead of shell-specific piped scripts, resolving failures on Windows when the default shell is `cmd.exe` ([#18459](https://github.com/microsoft/aspire/issues/18459), [#18522](https://github.com/microsoft/aspire/pull/18522)). - Fix stale global AppHosts appearing in the Aspire pane when switching back to a workspace view; global AppHosts are now cleared and re-filtered immediately on view switch ([#18506](https://github.com/microsoft/aspire/issues/18506), [#18516](https://github.com/microsoft/aspire/pull/18516)). diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index d78f70a8319..c0879b45cef 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -79,6 +79,12 @@ Aspire: Launch default AppHost + + Attach debugger + + + Attach debugger: {0} + Attempted to start unsupported resource type: {0}. @@ -169,6 +175,9 @@ Could not determine the AppHost source file to open. + + Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually. + Create a new project @@ -355,6 +364,9 @@ Install the Aspire CLI + + Install the C# extension to attach the debugger to .NET project resources. + Invalid launch configuration for {0}. @@ -718,6 +730,9 @@ The pipeline step name to execute when command is 'do' + + The selected resource is no longer available. Refresh the Aspire pane and try again. + This command has dynamic inputs that the Aspire extension cannot prompt for yet. Run it from the Aspire Dashboard or Aspire CLI instead. @@ -727,6 +742,9 @@ This field is required. + + This resource is not a running .NET project resource that can be attached with the C# debugger. + This setting has been renamed to aspire.appHostsPollingInterval. @@ -757,6 +775,9 @@ VS Code did not start the Aspire {0} session for {1}. + + VS Code did not start the debugger attach session for {0}. + Value missing diff --git a/extension/package.json b/extension/package.json index 805eb7ddcc5..963a0a8c73b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -359,6 +359,12 @@ "category": "Aspire", "icon": "$(debug-restart)" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "title": "%command.attachDebuggerToResource%", + "category": "Aspire", + "icon": "$(debug-alt)" + }, { "command": "aspire-vscode.viewResourceLogs", "title": "%command.viewResourceLogs%", @@ -578,6 +584,10 @@ "command": "aspire-vscode.restartResource", "when": "false" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "when": "false" + }, { "command": "aspire-vscode.viewResourceLogs", "when": "false" @@ -745,15 +755,20 @@ "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canRestart/", "group": "2_actions@3" }, + { + "command": "aspire-vscode.attachDebuggerToResource", + "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource.*:canAttachDebugger/", + "group": "2_actions@4" + }, { "command": "aspire-vscode.executeResourceCommand", "when": "view == aspire-vscode.appHosts && viewItem =~ /^resource(:|$)/", - "group": "2_actions@4" + "group": "2_actions@5" }, { "command": "aspire-vscode.executeResourceCommandItem", "when": "view == aspire-vscode.appHosts && viewItem == resourceCommand:enabled", - "group": "2_actions@4" + "group": "2_actions@5" }, { "command": "aspire-vscode.viewResourceLogs", diff --git a/extension/package.nls.json b/extension/package.nls.json index 635452825b7..490bb9b2897 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -199,6 +199,7 @@ "command.stopResource": "Stop", "command.startResource": "Start", "command.restartResource": "Restart", + "command.attachDebuggerToResource": "Attach debugger", "command.viewResourceLogs": "View logs", "command.openResourceTerminal": "Open terminal", "command.executeResourceCommand": "Execute resource command", @@ -272,6 +273,12 @@ "aspire-vscode.strings.appHostDebugActionLabel": "Debug AppHost", "aspire-vscode.strings.appHostPathLabel": "Path", "aspire-vscode.strings.appHostStartingDescription": "Starting...", + "aspire-vscode.strings.attachDebuggerConfigurationName": "Attach debugger: {0}", + "aspire-vscode.strings.attachDebuggerUnavailable": "This resource is not a running .NET project resource that can be attached with the C# debugger.", + "aspire-vscode.strings.attachDebuggerResourceNotFound": "The selected resource is no longer available. Refresh the Aspire pane and try again.", + "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", + "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", + "aspire-vscode.strings.attachDebuggerProcessNameUnresolved": "Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", "aspire-vscode.strings.workspaceViewSelectedSingleAppHostWithLanguage": "Workspace view selected because aspire ls found one buildable {0} AppHost.", diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 650e85ab291..6eb7a2d2978 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -1,4 +1,5 @@ import path from "path"; +import * as vscode from "vscode"; import { ExecutableLaunchConfiguration, EnvVar, LaunchOptions, AspireResourceExtendedDebugConfiguration, AspireExtendedDebugConfiguration } from "../dcp/types"; import { debugProject, runProject } from "../loc/strings"; import { getEnvironmentWithoutE2EBridgeVariables, mergeEnvs } from "../utils/environment"; @@ -15,6 +16,23 @@ import { mauiDebuggerExtension } from "./languages/maui"; import { isDirectory } from "../utils/io"; import { waitForRunStartIdle } from "./runStartRegistry"; +export interface DebuggableResourceSnapshot { + name: string; + displayName: string | null; + resourceType: string; + state: string | null; + properties: Record | null; +} + +export type AttachDebuggerConfigurationErrorKind = 'ResourceNotAttachable' | 'ProcessNameUnresolved'; + +export class AttachDebuggerConfigurationError extends Error { + constructor(public readonly errorKind: AttachDebuggerConfigurationErrorKind, message: string) { + super(message); + this.name = 'AttachDebuggerConfigurationError'; + } +} + // Represents a resource-specific debugger extension for when the default session configuration is not sufficient to launch the resource. export interface ResourceDebuggerExtension { resourceType: string; @@ -24,6 +42,8 @@ export interface ResourceDebuggerExtension { getProjectFile: (launchConfig: ExecutableLaunchConfiguration) => string; getSupportedFileTypes: () => string[]; createDebugSessionConfigurationCallback?: (launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration) => Promise; + canAttachToResource?: (resource: DebuggableResourceSnapshot) => boolean; + createAttachDebugSessionConfigurationCallback?: (resource: DebuggableResourceSnapshot) => Promise; } export async function createDebugSessionConfiguration(debugSessionConfig: AspireExtendedDebugConfiguration, launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debuggerExtension: ResourceDebuggerExtension): Promise { @@ -71,6 +91,43 @@ export async function createDebugSessionConfiguration(debugSessionConfig: Aspire return configuration; } +export async function createAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, debuggerExtension: ResourceDebuggerExtension): Promise { + if (!debuggerExtension.createAttachDebugSessionConfigurationCallback) { + throw new AttachDebuggerConfigurationError('ResourceNotAttachable', `Resource type '${resource.resourceType}' does not support debugger attach.`); + } + + return await debuggerExtension.createAttachDebugSessionConfigurationCallback(resource); +} + +export function getAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { + return getResourceDebuggerExtensions().find(extension => extension.canAttachToResource?.(resource) === true); +} + +export function getMissingAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { + if (getAttachDebuggerExtensionForResource(resource)) { + return undefined; + } + + return getKnownAttachDebuggerExtensionForResource(resource); +} + +export function getKnownAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { + return getKnownResourceDebuggerExtensions().find(extension => extension.canAttachToResource?.(resource) === true); +} + +function getKnownResourceDebuggerExtensions(): ResourceDebuggerExtension[] { + return [ + projectDebuggerExtension, + azureFunctionsDebuggerExtension, + pythonDebuggerExtension, + goDebuggerExtension, + nodeDebuggerExtension, + browserDebuggerExtension, + bunDebuggerExtension, + mauiDebuggerExtension, + ]; +} + export function getResourceDebuggerExtensions(): ResourceDebuggerExtension[] { const extensions = []; if (isCsharpInstalled()) { diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 7fc5ce10d85..dacf5405917 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -9,7 +9,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { doesFileExist } from '../../utils/io'; import { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, ProjectLaunchConfiguration } from '../../dcp/types'; -import { ResourceDebuggerExtension } from '../debuggerExtensions'; +import { AttachDebuggerConfigurationError, DebuggableResourceSnapshot, ResourceDebuggerExtension } from '../debuggerExtensions'; import { readLaunchSettings, determineBaseLaunchProfile, @@ -32,17 +32,32 @@ interface IDotNetService { getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } +interface DotNetAttachDebuggerResourceInfo { + projectPath: string; + resourceLabel: string; + reportedAssemblyName: string | undefined; +} + +const executablePidPropertyName = 'executable.pid'; +const executablePathPropertyName = 'executable.path'; +const projectPathPropertyName = 'project.path'; +// Well-known snapshot property added by the AppHost SDK assembly-name contract (microsoft/aspire#19136). +// It carries the MSBuild-evaluated `AssemblyName`, which is the process name the C# debugger attaches to. +const projectAssemblyNamePropertyName = 'project.assemblyName'; +const resourceParentNamePropertyName = 'resource.parentName'; +const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); + class DotNetService implements IDotNetService { - private _debugSession: AspireDebugSession; + private _debugSession: AspireDebugSession | undefined; - constructor(debugSession: AspireDebugSession) { + constructor(debugSession: AspireDebugSession | undefined) { this._debugSession = debugSession; } execFileAsync = util.promisify(execFile); writeToDebugConsole(message: string, category: 'stdout' | 'stderr', addNewLine: boolean = false): void { - this._debugSession.sendMessage(message, addNewLine, category); + this._debugSession?.sendMessage(message, addNewLine, category); } async getAndActivateDevKit(): Promise { @@ -391,7 +406,117 @@ function configureDotNetRunDebugConfiguration( )); } -export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession) => IDotNetService): ResourceDebuggerExtension { +function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { + if (resource.resourceType !== 'Project' || resource.state !== 'Running' || getResourceParentName(resource) !== null) { + return undefined; + } + + if (getAttachDebuggerProcessId(resource) === undefined) { + return undefined; + } + + if (!isDotNetExecutable(resource)) { + return undefined; + } + + const projectPath: unknown = resource.properties?.[projectPathPropertyName]; + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return undefined; + } + + if (!dotNetProjectFileExtensions.has(path.extname(projectPath).toLowerCase())) { + return undefined; + } + + return { + projectPath, + resourceLabel: resource.displayName ?? resource.name, + reportedAssemblyName: getReportedAssemblyName(resource), + }; +} + +function getResourceParentName(resource: DebuggableResourceSnapshot): string | null { + const value: unknown = resource.properties?.[resourceParentNamePropertyName]; + return typeof value === 'string' ? value : null; +} + +function getReportedAssemblyName(resource: DebuggableResourceSnapshot): string | undefined { + const value: unknown = resource.properties?.[projectAssemblyNamePropertyName]; + if (typeof value !== 'string') { + return undefined; + } + + const assemblyName = value.trim(); + return assemblyName.length > 0 ? assemblyName : undefined; +} + +function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): number | undefined { + const value: unknown = resource.properties?.[executablePidPropertyName]; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const processId = Number(value); + if (!Number.isInteger(processId) || processId <= 0) { + return undefined; + } + + return processId; +} + +function isDotNetExecutable(resource: DebuggableResourceSnapshot): boolean { + const executablePath: unknown = resource.properties?.[executablePathPropertyName]; + if (typeof executablePath !== 'string') { + return false; + } + + const executableName = executablePath.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; +} + +async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, dotNetService: IDotNetService): Promise { + const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); + if (!attachInfo) { + throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); + } + + let processName = attachInfo.reportedAssemblyName; + if (processName === undefined) { + processName = await getProcessNameFromTargetPath(attachInfo.projectPath, attachInfo.resourceLabel, dotNetService); + } + + return { + type: 'coreclr', + request: 'attach', + name: attachDebuggerConfigurationName(attachInfo.resourceLabel), + processName, + }; +} + +async function getProcessNameFromTargetPath(projectPath: string, resourceLabel: string, dotNetService: IDotNetService): Promise { + try { + const targetPath = await dotNetService.getDotNetTargetPath(projectPath); + const fileName = targetPath.trim().split(/[\\/]/).pop() ?? ''; + const processName = fileName.replace(/\.(dll|exe)$/i, ''); + if (processName.length === 0) { + throw new Error(noOutputFromMsbuild); + } + + return processName; + } catch (error) { + throw new AttachDebuggerConfigurationError('ProcessNameUnresolved', attachDebuggerProcessNameUnresolved(resourceLabel, getErrorMessage(error))); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession | undefined) => IDotNetService): ResourceDebuggerExtension { return { resourceType: 'project', debugAdapter: 'coreclr', @@ -405,6 +530,10 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); }, + canAttachToResource: (resource) => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, + createAttachDebugSessionConfigurationCallback: async (resource): Promise => { + return await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(undefined)); + }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); diff --git a/extension/src/extension.ts b/extension/src/extension.ts index b4c62127d61..1497c75c66d 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -218,6 +218,7 @@ export async function activate(context: vscode.ExtensionContext) { const stopResourceRegistration = registerInstrumentedCommand('aspire-vscode.stopResource', 'tree', (element) => appHostTreeProvider.stopResource(element)); const startResourceRegistration = registerInstrumentedCommand('aspire-vscode.startResource', 'tree', (element) => appHostTreeProvider.startResource(element)); const restartResourceRegistration = registerInstrumentedCommand('aspire-vscode.restartResource', 'tree', (element) => appHostTreeProvider.restartResource(element)); + const attachDebuggerToResourceRegistration = registerInstrumentedCommand('aspire-vscode.attachDebuggerToResource', 'tree', (element) => appHostTreeProvider.attachDebuggerToResource(element)); const viewResourceLogsRegistration = registerInstrumentedCommand('aspire-vscode.viewResourceLogs', 'tree', (element) => appHostTreeProvider.viewResourceLogs(element)); const openResourceTerminalRegistration = registerInstrumentedCommand('aspire-vscode.openResourceTerminal', 'tree', (element) => appHostTreeProvider.openResourceTerminal(element)); const executeResourceCommandRegistration = registerInstrumentedCommand('aspire-vscode.executeResourceCommand', 'tree', (element) => appHostTreeProvider.executeResourceCommand(element)); @@ -256,6 +257,7 @@ export async function activate(context: vscode.ExtensionContext) { stopResourceRegistration, startResourceRegistration, restartResourceRegistration, + attachDebuggerToResourceRegistration, viewResourceLogsRegistration, openResourceTerminalRegistration, executeResourceCommandRegistration, diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 8bf9e758331..58ebdf77af4 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -131,6 +131,12 @@ export const appHostPathInvalid = vscode.l10n.t('Could not determine the AppHost export const appHostStartingDescription = vscode.l10n.t('Starting...'); export const appHostStoppingDescription = vscode.l10n.t('Stopping...'); export const appHostDiscoveryProgress = vscode.l10n.t('Discovering AppHosts...'); +export const attachDebuggerConfigurationName = (resource: string) => vscode.l10n.t('Attach debugger: {0}', resource); +export const attachDebuggerUnavailable = vscode.l10n.t('This resource is not a running .NET project resource that can be attached with the C# debugger.'); +export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); +export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); +export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); +export const attachDebuggerProcessNameUnresolved = (resource: string, error: string) => vscode.l10n.t('Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.', resource, error); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index f00c844d4b4..ed84eae4d29 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -98,6 +98,7 @@ suite('Aspire package contribution surface E2E', function () { 'aspire-vscode.openInIntegratedBrowser', 'aspire-vscode.copyEndpointUrl', 'aspire-vscode.openResourceTerminal', + 'aspire-vscode.attachDebuggerToResource', ]) { assert.ok(hiddenPaletteCommands.includes(commandId), `${commandId} should stay hidden from the command palette.`); } @@ -444,6 +445,7 @@ const expectedActivationEvents = [ const expectedCommandIds = [ 'aspire-vscode.add', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.codeLensDebugPipelineStep', 'aspire-vscode.codeLensOpenDashboard', 'aspire-vscode.codeLensResourceAction', @@ -535,6 +537,7 @@ const expectedViewItemContextCommands = [ 'aspire-vscode.restartResource', 'aspire-vscode.executeResourceCommand', 'aspire-vscode.executeResourceCommandItem', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.viewResourceLogs', 'aspire-vscode.openResourceTerminal', 'aspire-vscode.openInExternalBrowser', diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 82af7f96338..269782d40b1 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -5,6 +5,7 @@ import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import * as capabilities from '../capabilities'; import * as cliModule from '../debugger/languages/cli'; import * as cliPathModule from '../utils/cliPath'; import * as configInfoProvider from '../utils/configInfoProvider'; @@ -40,6 +41,17 @@ function makeResource(overrides: Partial = {}): ResourceJson { return { ...base, ...overrides } as ResourceJson; } +function makeAttachableProjectProperties(overrides: Record = {}): ResourceJson['properties'] { + return { + 'executable.pid': '4242', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/api.csproj', + 'project.assemblyName': 'api', + ...overrides, + }; +} + function buildPath(...segments: string[]): string { return path.join(...segments); } @@ -1235,6 +1247,49 @@ suite('AspireAppHostTreeProvider', () => { assert.strictEqual(infoStub.calledOnce, true); }); + test('resource command item checks the latest resource snapshot before executing', async () => { + const runResourceCommandCalls: Array<[string, string | undefined, string, readonly string[]]> = []; + const appHost = makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + commands: { + restart: { displayName: 'Restart', description: null }, + }, + }), + ], + }); + const repository = { + viewMode: 'global' as ViewMode, + appHosts: [appHost], + workspaceResources: [], + workspaceAppHostPath: undefined, + workspaceAppHostCandidatePaths: [], + workspaceAppHostName: undefined, + onDidChangeData: (() => ({ dispose: () => { } })) as vscode.Event, + runResourceCommand: async (resourceName: string, appHostPath: string | undefined, commandName: string, additionalArgs: readonly string[] = []) => { + runResourceCommandCalls.push([resourceName, appHostPath, commandName, additionalArgs]); + return { stdout: '', stderr: '' }; + }, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + sandbox.stub(vscode.window, 'showInformationMessage'); + const [commandItem] = getResourceCommandItems(provider); + appHost.resources = [ + makeResource({ + name: 'api', + displayName: 'API v2', + commands: {}, + }), + ]; + + await provider.executeResourceCommandItem(commandItem as any); + + assert.deepStrictEqual(runResourceCommandCalls, []); + provider.dispose(); + }); + test('resource command item returns failed execution outcome after reporting error', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => 'aspire', @@ -1578,27 +1633,27 @@ suite('resolveAppHostSourcePath', () => { suite('getResourceContextValue', () => { test('resource with no commands returns just "resource"', () => { - assert.strictEqual(getResourceContextValue(makeResource()), 'resource'); + assert.strictEqual(getResourceContextValue(makeResource(), true), 'resource'); }); test('resource with start command', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Enabled' } }, - })); + }), true); assert.strictEqual(result, 'resource:canStart'); }); test('resource with resource-start command', () => { const result = getResourceContextValue(makeResource({ commands: { 'resource-start': { displayName: null, description: null, state: 'Enabled' } }, - })); + }), true); assert.strictEqual(result, 'resource:canStart'); }); test('resource with stop command', () => { const result = getResourceContextValue(makeResource({ commands: { 'stop': { displayName: null, description: null, state: 'Enabled' } }, - })); + }), true); assert.strictEqual(result, 'resource:canStop'); }); @@ -1609,7 +1664,7 @@ suite('getResourceContextValue', () => { 'stop': { displayName: null, description: null, state: 'Enabled' }, 'restart': { displayName: null, description: null, state: 'Enabled' }, }, - })); + }), true); assert.strictEqual(result, 'resource:canStart:canStop:canRestart'); }); @@ -1618,14 +1673,14 @@ suite('getResourceContextValue', () => { commands: { 'restart': { displayName: null, description: null }, }, - })); + }), true); assert.strictEqual(result, 'resource:canRestart'); }); test('resource with non-lifecycle commands has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'custom-action': { displayName: null, description: 'do something' } }, - })); + }), true); assert.strictEqual(result, 'resource'); }); @@ -1635,14 +1690,14 @@ suite('getResourceContextValue', () => { 'restart': { displayName: null, description: null, state: 'Enabled' }, 'custom-action': { displayName: null, description: null, state: 'Enabled' }, }, - })); + }), true); assert.strictEqual(result, 'resource:canRestart'); }); test('resource with terminal enabled property includes terminal context', () => { const result = getResourceContextValue(makeResource({ properties: { 'terminal.enabled': 'true' }, - })); + }), true); assert.strictEqual(result, 'resource:canOpenTerminal'); }); @@ -1652,21 +1707,114 @@ suite('getResourceContextValue', () => { 'restart': { displayName: null, description: null, state: 'Enabled' }, }, properties: { 'terminal.enabled': 'true' }, - })); + }), true); assert.strictEqual(result, 'resource:canRestart:canOpenTerminal'); }); + test('running project resource with redacted launch args includes attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running F# project resource includes attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'project.path': '/repo/worker/Worker.fsproj', + }), + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running project resource with process id excludes attach debugger context without C# debugger support', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), false); + assert.strictEqual(result, 'resource'); + }); + + test('project resource without process id does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: { + 'project.path': '/repo/api/api.csproj', + 'executable.path': 'dotnet', + }, + }), true); + assert.strictEqual(result, 'resource'); + }); + + test('non-running project resource with process id does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Finished, + properties: makeAttachableProjectProperties(), + }), true); + assert.strictEqual(result, 'resource'); + }); + + test('running project resource without project path does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'project.path': null, + }), + }), true); + assert.strictEqual(result, 'resource'); + }); + + test('running project resource without dotnet executable does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.path': 'func', + }), + }), true); + assert.strictEqual(result, 'resource'); + }); + + test('running child project resource does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'resource.parentName': 'maui', + }), + }), true); + assert.strictEqual(result, 'resource'); + }); + + test('executable resource with process id does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Executable', + properties: { + 'executable.pid': '4242', + }, + }), true); + assert.strictEqual(result, 'resource'); + }); + test('resource with disabled lifecycle command has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Disabled' } }, - })); + }), true); assert.strictEqual(result, 'resource'); }); test('resource with api-only lifecycle command has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Enabled', visibility: 'Api' } }, - })); + }), true); assert.strictEqual(result, 'resource'); }); @@ -2404,6 +2552,661 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource starts a coreclr attach session for a running project resource', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + await (provider as any).attachDebuggerToResource(resourceItem); + + assert.ok(startDebuggingStub.calledOnce, 'Expected VS Code to start one attach session'); + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.type, 'coreclr'); + assert.strictEqual(configuration.request, 'attach'); + assert.strictEqual(configuration.name, 'Attach debugger: API'); + assert.strictEqual(configuration.processName, 'api'); + assert.strictEqual(configuration.processId, undefined); + assert.strictEqual(configuration.cwd, undefined); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource throws when VS Code declines to start the attach session', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(false); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + await assert.rejects( + (provider as any).attachDebuggerToResource(resourceItem), + (error: unknown) => error instanceof Error && error.name === 'StartDebuggingDeclined'); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource propagates VS Code attach errors', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]); + const attachError = new Error('Adapter failed'); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').rejects(attachError); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + await assert.rejects( + (provider as any).attachDebuggerToResource(resourceItem), + (error: unknown) => error === attachError); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource uses the latest resource snapshot', async () => { + const appHost = makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }); + const provider = makeTreeProvider([appHost]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + appHost.resources = [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.pid': '5252', + 'project.path': '/repo/api-v2/api-v2.csproj', + 'project.assemblyName': 'api-v2', + }), + }), + ]; + + await (provider as any).attachDebuggerToResource(resourceItem); + + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.processName, 'api-v2'); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource resolves latest resource after AppHost process changes', async () => { + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + appHosts[0] = makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 5678, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.pid': '5252', + 'project.path': '/repo/api-next/api-next.csproj', + 'project.assemblyName': 'api-next', + }), + }), + ], + }); + + await (provider as any).attachDebuggerToResource(resourceItem); + + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.processName, 'api-next'); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource uses AppHost process identity when multiple AppHosts share a path', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '4242' }), + }), + ], + }), + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 5678, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.pid': '6262', + 'project.path': '/repo/second-api/second-api.csproj', + 'project.assemblyName': 'second-api', + }), + }), + ], + }), + ]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [, secondAppHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + await (provider as any).attachDebuggerToResource(resourceItem); + + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.processName, 'second-api'); + } + finally { + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource fails closed when AppHost path resolution is ambiguous', async () => { + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '4242' }), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + appHosts.splice( + 0, + appHosts.length, + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 5678, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '5252' }), + }), + ], + }), + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 9012, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '9292' }), + }), + ], + })); + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session when the current AppHost cannot be resolved unambiguously'); + assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); + } + finally { + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource does not use stale resource when AppHost process id is reused', async () => { + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + appHosts[0] = makeAppHost({ + appHostPath: '/repo/OtherAppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'Other API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.pid': '5252', + 'project.path': '/repo/other-api/other-api.csproj', + }), + }), + ], + }); + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for a resource from a different AppHost path'); + assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); + } + finally { + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource does not use stale resource when latest resource is missing', async () => { + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/AppHost/AppHost.csproj', + appHostPid: 1234, + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + appHosts.length = 0; + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for a stale resource item'); + assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); + } + finally { + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource does not use stale workspace resource after AppHost path changes', async () => { + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + workspaceAppHostPath: '/repo/AppHost/AppHost.csproj', + workspaceAppHostCandidatePaths: [], + workspaceAppHostName: 'AppHost.csproj', + workspaceAppHostDescription: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository & { workspaceResources: ResourceJson[]; workspaceAppHostPath: string }; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + + try { + const [workspaceResourcesItem] = provider.getChildren(); + const [resourceItem] = provider.getChildren(workspaceResourcesItem); + repository.workspaceAppHostPath = '/repo/OtherAppHost/AppHost.csproj'; + repository.workspaceResources = [ + makeResource({ + name: 'api', + displayName: 'Other API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ + 'executable.pid': '5252', + 'project.path': '/repo/other-api/other-api.csproj', + }), + }), + ]; + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session after the workspace AppHost path changed'); + assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); + } + finally { + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource shows a warning when C# debugger support is unavailable', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(false); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'CSharpExtensionMissing' }); + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session without C# debugger support'); + assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); + } + finally { + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource guard failures reach command telemetry as error outcomes', async () => { + // These are handled, user-visible guard failures (a warning is shown). The command is + // registered through withCommandTelemetry, so returning a handled-failure object — rather + // than void — is what makes the invocation record as an `error` outcome with a specific + // error_kind instead of a false `success`, while still suppressing VS Code's generic + // "command failed" notification. + const invocations: Array<{ command: string; outcome: string; errorKind?: string; source?: string }> = []; + const invocationSubscription = onDidInvokeCommand(event => invocations.push(event)); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); + let csharpInstalled = true; + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').callsFake(() => csharpInstalled); + + const getResourceItem = (provider: AspireAppHostTreeProvider) => { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + return resourceItem; + }; + const runAttach = (provider: AspireAppHostTreeProvider, item: unknown) => + withCommandTelemetry('aspire-vscode.attachDebuggerToResource', () => (provider as any).attachDebuggerToResource(item), { source: 'tree' }); + + // A resource that exists at selection time but is removed from the model before the command + // runs (exercises the stale/not-found guard). + const staleAppHosts = [ + makeAppHost({ + resources: [ + makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), + ], + }), + ]; + const staleProvider = makeTreeProvider(staleAppHosts); + const unattachableProvider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties({ 'executable.path': 'node' }) }), + ], + }), + ]); + const csharpMissingProvider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), + ], + }), + ]); + + try { + const staleResourceItem = getResourceItem(staleProvider); + const unattachableResourceItem = getResourceItem(unattachableProvider); + const csharpMissingResourceItem = getResourceItem(csharpMissingProvider); + + // 1) The selected resource is gone from the model -> ResourceNotFound. + staleAppHosts.length = 0; + const staleOutcome = await runAttach(staleProvider, staleResourceItem); + assert.deepStrictEqual(staleOutcome, { success: false, errorKind: 'ResourceNotFound' }); + + // 2) The resource is present but no longer attachable -> ResourceNotAttachable. + const unattachableOutcome = await runAttach(unattachableProvider, unattachableResourceItem); + assert.deepStrictEqual(unattachableOutcome, { success: false, errorKind: 'ResourceNotAttachable' }); + + // 3) The C# extension is not installed -> CSharpExtensionMissing. + csharpInstalled = false; + const csharpMissingOutcome = await runAttach(csharpMissingProvider, csharpMissingResourceItem); + assert.deepStrictEqual(csharpMissingOutcome, { success: false, errorKind: 'CSharpExtensionMissing' }); + + assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for any guard failure'); + assert.strictEqual(warningStub.callCount, 3, 'Expected a warning for each guard failure'); + assert.deepStrictEqual( + invocations.map(event => [event.command, event.outcome, event.errorKind, event.source]), + [ + ['aspire-vscode.attachDebuggerToResource', 'error', 'ResourceNotFound', 'tree'], + ['aspire-vscode.attachDebuggerToResource', 'error', 'ResourceNotAttachable', 'tree'], + ['aspire-vscode.attachDebuggerToResource', 'error', 'CSharpExtensionMissing', 'tree'], + ]); + } + finally { + invocationSubscription.dispose(); + warningStub.restore(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + staleProvider.dispose(); + unattachableProvider.dispose(); + csharpMissingProvider.dispose(); + } + }); + + test('attachDebuggerToResource declining to start is recorded as an error command outcome', async () => { + // The genuine "VS Code declined to start the attach session" path still throws, so + // withCommandTelemetry classifies it as an error via the thrown error name. This preserves + // the distinct behavior from the handled guard failures above. + const invocations: Array<{ command: string; outcome: string; errorKind?: string }> = []; + const invocationSubscription = onDidInvokeCommand(event => invocations.push(event)); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), + ], + }), + ]); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(false); + const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + + try { + const [appHostItem] = provider.getChildren(); + const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + const [resourceItem] = provider.getChildren(resourcesGroup); + + await assert.rejects( + withCommandTelemetry('aspire-vscode.attachDebuggerToResource', () => (provider as any).attachDebuggerToResource(resourceItem), { source: 'tree' }), + (error: unknown) => error instanceof Error && error.name === 'StartDebuggingDeclined'); + + assert.deepStrictEqual( + invocations.map(event => [event.command, event.outcome, event.errorKind]), + [['aspire-vscode.attachDebuggerToResource', 'error', 'StartDebuggingDeclined']]); + } + finally { + invocationSubscription.dispose(); + csharpInstalledStub.restore(); + startDebuggingStub.restore(); + provider.dispose(); + } + }); + test('workspace mode renders a running AppHost with no resources', () => { const hostPath = '/repo/AppHost/AppHost.csproj'; const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index a229f1d6b4f..b7eb857cec0 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -11,9 +11,9 @@ import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; class TestDotNetService { - private _getDotNetTargetPathStub: sinon.SinonStub; private _hasDevKit: boolean; + public getDotNetTargetPathStub: sinon.SinonStub; public buildDotNetProjectStub: sinon.SinonStub; // `dotnet run-api` output returned for file-based (.cs) apps. Tests override this with a serialized @@ -22,8 +22,8 @@ class TestDotNetService { public runApiEnvironment: NodeJS.ProcessEnv | undefined; constructor(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean) { - this._getDotNetTargetPathStub = sinon.stub(); - this._getDotNetTargetPathStub.resolves(outputPath); + this.getDotNetTargetPathStub = sinon.stub(); + this.getDotNetTargetPathStub.resolves(outputPath); this.buildDotNetProjectStub = sinon.stub(); if (rejectBuild) { @@ -36,7 +36,7 @@ class TestDotNetService { } getDotNetTargetPath(projectFile: string): Promise { - return this._getDotNetTargetPathStub(projectFile); + return this.getDotNetTargetPathStub(projectFile); } buildDotNetProject(projectFile: string): Promise { @@ -61,6 +61,112 @@ suite('Dotnet Debugger Extension Tests', () => { return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; } + test('attach configuration uses reported assembly name without evaluating TargetPath', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.assemblyName': 'FromReportedProperty', + }, + }); + + assert.strictEqual(configuration.type, 'coreclr'); + assert.strictEqual(configuration.request, 'attach'); + assert.strictEqual(configuration.name, 'Attach debugger: API'); + assert.strictEqual(configuration.processName, 'FromReportedProperty'); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration derives process name from evaluated TargetPath when assembly name is not reported', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/My Attach Service.dll', null, true, true); + + const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'worker', + displayName: 'Worker', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/worker/AttachDemo.Worker.csproj', + }, + }); + + assert.strictEqual(configuration.processName, 'My Attach Service'); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/worker/AttachDemo.Worker.csproj')); + }); + + test('attach configuration treats blank reported assembly name as absent', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.assemblyName': ' ', + }, + }); + + assert.strictEqual(configuration.processName, 'FromTargetPath'); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/api/Api.csproj')); + }); + + test('attach configuration reports process-name failure when TargetPath cannot be evaluated', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/unused.dll', null, true, true); + dotNetService.getDotNetTargetPathStub.rejects(new Error('MSBuild failed')); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ProcessNameUnresolved'); + }); + + test('attach configuration rejects file-based project resources', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.cs', + 'project.assemblyName': 'Api', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + test('failed AppHost start writes error to debug console', async () => { const parentDebugSession = { id: 'aspire-session', diff --git a/extension/src/test/packageManifest.test.ts b/extension/src/test/packageManifest.test.ts index 6aba241e6bb..d35b4d265e1 100644 --- a/extension/src/test/packageManifest.test.ts +++ b/extension/src/test/packageManifest.test.ts @@ -113,6 +113,20 @@ suite('extension/package.json', () => { assertContains(openResourceTerminal?.when, 'viewItem =~ /^resource.*:canOpenTerminal/'); }); + test('attach debugger context action targets debuggable resources', () => { + const manifest = readManifest(); + const commands = manifest.contributes.commands ?? []; + const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; + + const command = commands.find(item => item.command === 'aspire-vscode.attachDebuggerToResource'); + const menuItem = contextMenus.find(item => item.command === 'aspire-vscode.attachDebuggerToResource'); + + assert.ok(command, 'Expected attach debugger command to be contributed'); + assert.strictEqual(command.icon, '$(debug-alt)'); + assertContains(menuItem?.when, 'view == aspire-vscode.appHosts'); + assertContains(menuItem?.when, 'viewItem =~ /^resource.*:canAttachDebugger/'); + }); + test('running apphost context actions only target running apphost contexts', () => { const manifest = readManifest(); const contextMenus = manifest.contributes.menus?.['view/item/context'] ?? []; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 9dddb60717f..2077bb96af1 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -38,6 +38,10 @@ import { resourceCommandDisabledDescription, appHostStartingDescription, appHostStoppingDescription, + attachDebuggerUnavailable, + attachDebuggerResourceNotFound, + attachDebuggerCsharpExtensionRequired, + attachDebuggerDeclined, dashboardUrlNotFound, dashboardUrlUnsupported, errorMessage, @@ -59,6 +63,8 @@ import { createResourceCommandArgumentLoader } from './ResourceCommandArgumentsL import { executeResourceCommand as executeResourceCommandWithUi, type ResourceCommandExecutionOutcome } from './resourceCommandExecution'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { isCommandCancellation } from '../utils/telemetry'; +import * as debuggerExtensions from '../debugger/debuggerExtensions'; +import { findAppHostForResource, findLatestResourceForElement, getAppHostPathForResource } from './resourceLookup'; type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | RunningAppHostsGroupItem | WorkspaceAppHostActionItem | WorkspaceAppHostPathItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem | CommandsGroupItem | ResourceCommandItem; @@ -316,7 +322,7 @@ class LogFileItem extends vscode.TreeItem { } class ResourcesGroupItem extends vscode.TreeItem { - constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number) { + constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number, public readonly appHostPath: string) { super(resourcesGroupLabel, vscode.TreeItemCollapsibleState.Expanded); this.id = `resources:${appHostPid}`; this.iconPath = new vscode.ThemeIcon('layers', new vscode.ThemeColor('aspire.brandPurple')); @@ -420,11 +426,11 @@ class ResourceItem extends vscode.TreeItem { this.iconPath = getResourceIcon(resource); this.description = buildResourceDescription(resource); this.tooltip = buildResourceTooltip(resource); - this.contextValue = getResourceContextValue(resource); + this.contextValue = getResourceContextValue(resource, debuggerExtensions.getAttachDebuggerExtensionForResource(resource) !== undefined); } } -export function getResourceContextValue(resource: ResourceJson): string { +export function getResourceContextValue(resource: ResourceJson, canAttachDebugger: boolean): string { const commands = resource.commands; const parts = ['resource']; if (hasEnabledCommand(commands, 'start') || hasEnabledCommand(commands, 'resource-start')) { @@ -439,6 +445,9 @@ export function getResourceContextValue(resource: ResourceJson): string { if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } + if (canAttachDebugger && debuggerExtensions.getKnownAttachDebuggerExtensionForResource(resource) !== undefined) { + parts.push('canAttachDebugger'); + } return parts.join(':'); } @@ -458,6 +467,11 @@ function getTerminalReplicaIndex(resource: ResourceJson): string | undefined { return trimmedValue && trimmedValue.length > 0 ? trimmedValue : undefined; } +interface AttachDebuggerHandledFailure { + success: false; + errorKind: 'ResourceNotFound' | 'ResourceNotAttachable' | 'CSharpExtensionMissing' | 'ProcessNameUnresolved'; +} + export function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { const state = resource.state; const health = resource.healthStatus; @@ -1210,7 +1224,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0) { - items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid)); + items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid, appHost.appHostPath)); } return items; @@ -1220,7 +1234,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); return sortResources(topLevel).map(r => { const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name); - return new ResourceItem(r, element.appHostPid, hasChildren, element.resources); + return new ResourceItem(r, element.appHostPid, hasChildren, element.resources, element.appHostPath); }); } @@ -1479,18 +1493,59 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + const resource = findLatestResourceForElement(this._repository, element); + if (!resource) { + vscode.window.showWarningMessage(attachDebuggerResourceNotFound); + return { success: false, errorKind: 'ResourceNotFound' }; + } + + const debuggerExtension = debuggerExtensions.getAttachDebuggerExtensionForResource(resource); + if (!debuggerExtension) { + const missingDebuggerExtension = debuggerExtensions.getMissingAttachDebuggerExtensionForResource(resource); + if (missingDebuggerExtension?.extensionId === 'ms-dotnettools.csharp') { + vscode.window.showWarningMessage(attachDebuggerCsharpExtensionRequired); + return { success: false, errorKind: 'CSharpExtensionMissing' }; + } + + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; + } + + let configuration: vscode.DebugConfiguration; + try { + configuration = await debuggerExtensions.createAttachDebugSessionConfiguration(resource, debuggerExtension); + } catch (error) { + if (error instanceof debuggerExtensions.AttachDebuggerConfigurationError) { + vscode.window.showWarningMessage(error.message); + return { success: false, errorKind: error.errorKind }; + } + + throw error; + } + + const resourceLabel = resource.displayName ?? resource.name; + const started = await vscode.debug.startDebugging(undefined, configuration); + if (!started) { + const error = new Error(attachDebuggerDeclined(resourceLabel)); + error.name = 'StartDebuggingDeclined'; + throw error; + } + } + async viewResourceLogs(element: ResourceItem): Promise { // aspire logs accepts the resource display name, not the internal name - const resourceName = element.resource.displayName ?? element.resource.name; + const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; + const resourceName = resource.displayName ?? resource.name; if (this._repository.viewMode === 'workspace') { - const appHostPath = this._getAppHostPathForResource(element); + const appHostPath = getAppHostPathForResource(this._repository, element); const command = appHostPath ? ['logs', shellArg(resourceName), '--apphost', shellArg(appHostPath)] : ['logs', shellArg(resourceName)]; await this._terminalProvider.sendAspireCommandToAspireTerminal(command); return; } - const appHost = this._findAppHostForResource(element); + const appHost = findAppHostForResource(this._repository, element); if (!appHost) { return; } @@ -1498,13 +1553,14 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const command: Array = ['terminal', 'attach', shellArg(element.resource.name)]; - const appHostPath = this._getAppHostPathForResource(element); + const latestResource = findLatestResourceForElement(this._repository, element) ?? element.resource; + const command: Array = ['terminal', 'attach', shellArg(latestResource.name)]; + const appHostPath = getAppHostPathForResource(this._repository, element); if (appHostPath) { command.push('--apphost', shellArg(appHostPath)); } - const replicaIndex = getTerminalReplicaIndex(element.resource); + const replicaIndex = getTerminalReplicaIndex(latestResource); if (replicaIndex) { command.push('--replica', shellArg(replicaIndex)); } @@ -1513,7 +1569,8 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const commands = element.resource.commands; + const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; + const commands = resource.commands; if (!commands || Object.keys(commands).length === 0) { vscode.window.showInformationMessage(noCommandsAvailable); return; @@ -1554,7 +1611,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const commandName = element.commandName; - const command = element.commandJson; + const latestResource = findLatestResourceForElement(this._repository, element.resourceItem); + const command = latestResource === undefined + ? element.commandJson + : latestResource.commands?.[commandName]; const resourceItem = element.resourceItem; if (!isEnabledCommand(command)) { @@ -1647,19 +1707,20 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider this.showResourceCommandOutput(resourceName, command, content, outputAppHostPath), { - resourceName: element.resource.name, - displayName: element.resource.displayName ?? element.resource.name, + resourceName: resource.name, + displayName: resource.displayName ?? resource.name, commandName, appHostPath: appHostPath ?? undefined, additionalArgs, @@ -1684,12 +1745,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const appHostPath = this._repository.viewMode === 'workspace' - ? this._getAppHostPathForResource(element) - : this._findAppHostForResource(element)?.appHostPath; + ? getAppHostPathForResource(this._repository, element) + : findAppHostForResource(this._repository, element)?.appHostPath; + const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; const loader = createResourceCommandArgumentLoader({ cliExecutionProvider: this._terminalProvider, - resourceName: element.resource.name, + resourceName: resource.name, commandName, appHostPath: appHostPath ?? undefined, }); @@ -1697,13 +1759,6 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider a.appHostPid === element.appHostPid); - } - - private _getAppHostPathForResource(element: ResourceItem): string | undefined { - return element.appHostPath ?? this._findAppHostForResource(element)?.appHostPath ?? this._repository.workspaceAppHostPath; - } } /** diff --git a/extension/src/views/resourceLookup.ts b/extension/src/views/resourceLookup.ts new file mode 100644 index 00000000000..aa112c807ae --- /dev/null +++ b/extension/src/views/resourceLookup.ts @@ -0,0 +1,75 @@ +import { + AppHostDataRepository, + AppHostDisplayInfo, + ResourceJson, + isMatchingAppHostPath, +} from './AppHostDataRepository'; + +export interface ResourceElementRef { + resource: ResourceJson; + appHostPid: number | null; + appHostPath?: string; +} + +export function findLatestResourceForElement(repository: AppHostDataRepository, element: ResourceElementRef): ResourceJson | undefined { + const resources = findLatestResourcesForElement(repository, element); + return resources?.find(resource => resource.name === element.resource.name); +} + +export function findLatestResourcesForElement(repository: AppHostDataRepository, element: ResourceElementRef): readonly ResourceJson[] | undefined { + const workspaceResources = [...repository.workspaceResources]; + const selectedAppHostPath = repository.workspaceAppHost?.appHostPath ?? repository.workspaceAppHostPath; + + if (element.appHostPath) { + const matchingAppHosts = repository.appHosts.filter(appHost => isMatchingAppHostPath(appHost.appHostPath, element.appHostPath!)); + const appHostByPid = element.appHostPid !== null + ? matchingAppHosts.find(appHost => appHost.appHostPid === element.appHostPid) + : undefined; + const appHost = appHostByPid ?? (matchingAppHosts.length === 1 ? matchingAppHosts[0] : undefined); + if (appHost) { + if (workspaceResources.length > 0 && selectedAppHostPath && isMatchingAppHostPath(appHost.appHostPath, selectedAppHostPath) && hasNoResources(appHost.resources)) { + return workspaceResources; + } + + return appHost.resources ?? []; + } + + if (matchingAppHosts.length > 1) { + return undefined; + } + + if (!selectedAppHostPath || !isMatchingAppHostPath(element.appHostPath, selectedAppHostPath)) { + return undefined; + } + + return workspaceResources.length > 0 + ? workspaceResources + : repository.workspaceAppHost?.resources ?? []; + } + + const appHost = findAppHostForResource(repository, element); + + if (appHost && workspaceResources.length > 0 && selectedAppHostPath && isMatchingAppHostPath(appHost.appHostPath, selectedAppHostPath) && hasNoResources(appHost.resources)) { + return workspaceResources; + } + + if (appHost) { + return appHost.resources ?? []; + } + + return element.appHostPid === null ? workspaceResources : undefined; +} + +export function findAppHostForResource(repository: AppHostDataRepository, element: ResourceElementRef): AppHostDisplayInfo | undefined { + return element.appHostPid !== null + ? repository.appHosts.find(appHost => appHost.appHostPid === element.appHostPid) + : undefined; +} + +export function getAppHostPathForResource(repository: AppHostDataRepository, element: ResourceElementRef): string | undefined { + return element.appHostPath ?? findAppHostForResource(repository, element)?.appHostPath ?? repository.workspaceAppHostPath; +} + +function hasNoResources(resources: readonly ResourceJson[] | null | undefined): boolean { + return resources === undefined || resources === null || resources.length === 0; +} From b41c378a16b1769e779b90511663f8183f7b7377 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 13:23:10 -0400 Subject: [PATCH 10/90] Rename project metadata assembly name to target name Port the closed PR's AppHost SDK target-name hardening and keep stale target-name snapshot values from being carried forward. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/languages/dotnet.ts | 20 +-- extension/src/test/appHostTreeView.test.ts | 8 +- extension/src/test/dotnetDebugger.test.ts | 14 +- .../DotNetBasedAppHostServerProject.cs | 4 +- .../build/Aspire.Hosting.AppHost.in.targets | 69 ++++---- .../ResourcePropertySnapshotMetadata.cs | 2 +- .../Dcp/ResourceSnapshotBuilder.cs | 18 +- src/Aspire.Hosting/IProjectMetadata.cs | 11 +- .../Resources/MessageStrings.Designer.cs | 6 +- .../Resources/MessageStrings.resx | 4 +- .../Resources/xlf/MessageStrings.cs.xlf | 10 +- .../Resources/xlf/MessageStrings.de.xlf | 10 +- .../Resources/xlf/MessageStrings.es.xlf | 10 +- .../Resources/xlf/MessageStrings.fr.xlf | 10 +- .../Resources/xlf/MessageStrings.it.xlf | 10 +- .../Resources/xlf/MessageStrings.ja.xlf | 10 +- .../Resources/xlf/MessageStrings.ko.xlf | 10 +- .../Resources/xlf/MessageStrings.pl.xlf | 10 +- .../Resources/xlf/MessageStrings.pt-BR.xlf | 10 +- .../Resources/xlf/MessageStrings.ru.xlf | 10 +- .../Resources/xlf/MessageStrings.tr.xlf | 10 +- .../Resources/xlf/MessageStrings.zh-Hans.xlf | 10 +- .../Resources/xlf/MessageStrings.zh-Hant.xlf | 10 +- src/Shared/Model/KnownProperties.cs | 4 +- .../ResourceSnapshotMapperTests.cs | 10 +- .../Model/KnownPropertyLookupTests.cs | 2 +- .../AppHostSdkTargetsTests.cs | 160 +++++++++++++++--- .../Dcp/ResourceSnapshotBuilderTests.cs | 34 ++-- .../ProjectResourceBuilderExtensionTests.cs | 10 +- ...Tests.ValidateMetadataSources.verified.txt | 6 +- 30 files changed, 314 insertions(+), 198 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index a679f5fcda0..84a5a97de36 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -35,15 +35,15 @@ interface IDotNetService { interface DotNetAttachDebuggerResourceInfo { projectPath: string; resourceLabel: string; - reportedAssemblyName: string | undefined; + reportedTargetName: string | undefined; } const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; -// Well-known snapshot property added by the AppHost SDK assembly-name contract (microsoft/aspire#19136). -// It carries the MSBuild-evaluated `AssemblyName`, which is the process name the C# debugger attaches to. -const projectAssemblyNamePropertyName = 'project.assemblyName'; +// Well-known snapshot property added by the AppHost SDK target-name contract. +// It carries the MSBuild-evaluated `TargetName`, which is the process name the C# debugger attaches to. +const projectTargetNamePropertyName = 'project.targetName'; const resourceParentNamePropertyName = 'resource.parentName'; const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); @@ -431,7 +431,7 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho return { projectPath, resourceLabel: resource.displayName ?? resource.name, - reportedAssemblyName: getReportedAssemblyName(resource), + reportedTargetName: getReportedTargetName(resource), }; } @@ -440,14 +440,14 @@ function getResourceParentName(resource: DebuggableResourceSnapshot): string | n return typeof value === 'string' ? value : null; } -function getReportedAssemblyName(resource: DebuggableResourceSnapshot): string | undefined { - const value: unknown = resource.properties?.[projectAssemblyNamePropertyName]; +function getReportedTargetName(resource: DebuggableResourceSnapshot): string | undefined { + const value: unknown = resource.properties?.[projectTargetNamePropertyName]; if (typeof value !== 'string') { return undefined; } - const assemblyName = value.trim(); - return assemblyName.length > 0 ? assemblyName : undefined; + const targetName = value.trim(); + return targetName.length > 0 ? targetName : undefined; } function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): number | undefined { @@ -484,7 +484,7 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } - let processName = attachInfo.reportedAssemblyName; + let processName = attachInfo.reportedTargetName; if (processName === undefined) { processName = await getProcessNameFromTargetPath(attachInfo.projectPath, attachInfo.resourceLabel, dotNetService); } diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 269782d40b1..60e87ef6484 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -47,7 +47,7 @@ function makeAttachableProjectProperties(overrides: Record { properties: makeAttachableProjectProperties({ 'executable.pid': '5252', 'project.path': '/repo/api-v2/api-v2.csproj', - 'project.assemblyName': 'api-v2', + 'project.targetName': 'api-v2', }), }), ]; @@ -2746,7 +2746,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { properties: makeAttachableProjectProperties({ 'executable.pid': '5252', 'project.path': '/repo/api-next/api-next.csproj', - 'project.assemblyName': 'api-next', + 'project.targetName': 'api-next', }), }), ], @@ -2791,7 +2791,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { properties: makeAttachableProjectProperties({ 'executable.pid': '6262', 'project.path': '/repo/second-api/second-api.csproj', - 'project.assemblyName': 'second-api', + 'project.targetName': 'second-api', }), }), ], diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index b7eb857cec0..485c5c78d9a 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -61,7 +61,7 @@ suite('Dotnet Debugger Extension Tests', () => { return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; } - test('attach configuration uses reported assembly name without evaluating TargetPath', async () => { + test('attach configuration uses reported target name without evaluating TargetPath', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ @@ -73,7 +73,7 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.csproj', - 'project.assemblyName': 'FromReportedProperty', + 'project.targetName': 'FromReportedProperty', }, }); @@ -84,7 +84,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('attach configuration derives process name from evaluated TargetPath when assembly name is not reported', async () => { + test('attach configuration derives process name from evaluated TargetPath when target name is not reported', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/My Attach Service.dll', null, true, true); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ @@ -103,7 +103,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/worker/AttachDemo.Worker.csproj')); }); - test('attach configuration treats blank reported assembly name as absent', async () => { + test('attach configuration treats blank reported target name as absent', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ @@ -115,7 +115,7 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.csproj', - 'project.assemblyName': ' ', + 'project.targetName': ' ', }, }); @@ -157,7 +157,7 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.cs', - 'project.assemblyName': 'Api', + 'project.targetName': 'Api', }, }), (error: unknown) => error instanceof Error @@ -908,7 +908,7 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('file-based dotnet.cs apphost named dotnet is not mistaken for the launcher', async () => { - // A file-based app whose entry file is `dotnet.cs` builds an apphost whose AssemblyName — and therefore + // A file-based app whose entry file is `dotnet.cs` builds an apphost whose TargetName — and therefore // executable file name — is `dotnet`/`dotnet.exe`, the same name as the launcher but at a full build-output // path. run-api returns that full path as ExecutablePath and echoes the SDK default profile's arguments // in CommandLineArguments. Because the program is an apphost (a rooted path), not the launcher (a bare diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index a4e59236096..f954f384f43 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -149,10 +149,10 @@ private XDocument CreateProjectFile(IEnumerable integratio {_repoRoot} true - true + true true true 42.42.42 diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index e90294afa00..dfba26231d9 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -51,7 +51,7 @@ back as a literal string. That produced a ProjectPath of "$([System.IO.Path]::GetFullPath(../O'Brien/Worker.csproj))" and a source file named after the unparsed ClassName expression, so the AppHost failed to compile. $(...) is expanded only after the arguments have been parsed, which is why the indirection fixes it. The same - hazard is called out on the assembly name in _SetAspireProjectMetadataAssemblyNames below. + hazard is called out on the target name in _SetAspireProjectMetadataTargetNames below. --> <_AspireProjectResourceIdentity>%(_AspireProjectResource.Identity) @@ -69,14 +69,14 @@ - + Condition="'$(SkipAspireProjectResourceTargetName)' != 'true'"> - <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true'))" /> + + <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" /> @@ -162,7 +171,7 @@ - <_AspireResolvedProjectFile>%(_AspireProjectResourceTargetPath.MSBuildSourceProjectFile) <_AspireResolvedProjectFile Condition="'$(_AspireResolvedProjectFile)' != ''">$([System.IO.Path]::GetFullPath('$(_AspireResolvedProjectFile)')) - <_AspireResolvedAssemblyName>%(_AspireProjectResourceTargetPath.Filename) + <_AspireResolvedTargetName>%(_AspireProjectResourceTargetPath.Filename) - <_AspireResolvedAssemblyNameLiteral>$(_AspireResolvedAssemblyName.Replace('"', '""')) + <_AspireResolvedTargetNameLiteral>$(_AspireResolvedTargetName.Replace('"', '""')) - $(_AspireResolvedAssemblyName) - $(_AspireResolvedAssemblyNameLiteral) + Condition="'$(_AspireResolvedTargetName)' != '' and '%(AspireProjectMetadataSource.ProjectPath)' == '$(_AspireResolvedProjectFile)'"> + $(_AspireResolvedTargetName) + $(_AspireResolvedTargetNameLiteral) - + - - /// The assembly name that the ]]>%(ClassName)%(ClassName) /// - /// Evaluated by MSBuild when this AppHost was built, so it reflects any AssemblyName set by the project or + /// Evaluated by MSBuild when this AppHost was built, so it reflects any TargetName set by the project or /// imported into it rather than the project file name. /// #nullable enable - public string? AssemblyName => @"]]>%(AspireProjectMetadataSource.ProjectAssemblyNameLiteral) @"]]>%(AspireProjectMetadataSource.ProjectTargetNameLiteral) +]]> - + @@ -248,7 +257,7 @@ namespace Projects%3B /// The path to the ]]>%(ClassName) public string ProjectPath => """]]>%(ProjectPath)%(AspireProjectMetadataSource.AssemblyNameMember)%(AspireProjectMetadataSource.TargetNameMember) /// Gets a value indicating whether building the project before running it should be suppressed. /// diff --git a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs index eecb22b4af3..0585257bb5c 100644 --- a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs @@ -50,7 +50,7 @@ internal static (string? DisplayName, bool IsHighlighted, int? SortOrder) Get(st (KnownResourceTypes.Project, KnownProperties.Project.Path) => (ResourcePropertyProjectPathDisplayName, true, 0), (KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile) => (ResourcePropertyProjectLaunchProfileDisplayName, true, 1), (KnownResourceTypes.Project, KnownProperties.Executable.Pid) => (ResourcePropertyExecutableProcessIdDisplayName, true, 2), - (KnownResourceTypes.Project, KnownProperties.Project.AssemblyName) => (ResourcePropertyProjectAssemblyNameDisplayName, true, 3), + (KnownResourceTypes.Project, KnownProperties.Project.TargetName) => (ResourcePropertyProjectTargetNameDisplayName, true, 3), _ => (null, false, null) }; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index b4a4a7c77b9..95cfd8aabf4 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -131,7 +131,7 @@ public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourc public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSnapshot previous) { string? projectPath = null; - string? projectAssemblyName = null; + string? projectTargetName = null; string? launchProfileName = null; IResource? appModelResource = null; @@ -142,14 +142,14 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn { var metadata = projectResource.GetProjectMetadata(); projectPath = metadata.ProjectPath; - projectAssemblyName = metadata.AssemblyName; + projectTargetName = metadata.TargetName; launchProfileName = projectResource.GetEffectiveLaunchProfile()?.Name; } else if (appModelResource.TryGetProjectMetadata(out var projectMetadata)) { // New-style, annotation-based C# service (DotnetProjectResource) projectPath = projectMetadata.ProjectPath; - projectAssemblyName = projectMetadata.AssemblyName; + projectTargetName = projectMetadata.TargetName; launchProfileName = appModelResource.GetEffectiveLaunchProfile()?.Name; } } @@ -186,21 +186,21 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, ]; - // The assembly name is only known when the AppHost build baked it into the generated project metadata. + // The target name is only known when the AppHost build baked it into the generated project metadata. // Its absence - not a null or empty value - is the capability signal consumers use to decide whether the - // evaluated assembly name can be relied on, so nothing is written when it could not be resolved. + // evaluated target name can be relied on, so nothing is written when it could not be resolved. var previousProperties = previous.Properties; - if (!string.IsNullOrWhiteSpace(projectAssemblyName)) + if (!string.IsNullOrWhiteSpace(projectTargetName)) { - projectProperties.Add(ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.AssemblyName, projectAssemblyName)); + projectProperties.Add(ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.TargetName, projectTargetName)); } else { // Snapshots are merged into the previously published one and SetResourcePropertyRange only adds or // replaces, so simply omitting the property would leave an earlier value in place. That stale value - // would read as "the assembly name is available" and defeat the absence-is-the-signal contract, so + // would read as "the target name is available" and defeat the absence-is-the-signal contract, so // the property has to be removed explicitly. - previousProperties = previousProperties.RemoveResourceProperty(KnownProperties.Project.AssemblyName); + previousProperties = previousProperties.RemoveResourceProperty(KnownProperties.Project.TargetName); } return previous with diff --git a/src/Aspire.Hosting/IProjectMetadata.cs b/src/Aspire.Hosting/IProjectMetadata.cs index 9aee358f6f5..6653de572ad 100644 --- a/src/Aspire.Hosting/IProjectMetadata.cs +++ b/src/Aspire.Hosting/IProjectMetadata.cs @@ -32,15 +32,14 @@ public interface IProjectMetadata : IResourceAnnotation public bool SuppressBuild => false; /// - /// Gets the assembly name that the project evaluates to, or when it is unknown. + /// Gets the target name that the project evaluates to, or when it is unknown. /// /// /// /// This value is baked into the generated project metadata when the AppHost is built. It is the MSBuild-evaluated - /// name of the built output (TargetName, which defaults to AssemblyName) rather than the project file - /// name. That distinction matters when a project sets AssemblyName - often from an imported - /// Directory.Build.props - because the launched assembly is then named after the assembly and not after the - /// project. + /// TargetName, which defaults to AssemblyName, rather than the project file name. That distinction + /// matters when a project sets TargetName because the launched assembly is then named after the target + /// instead of the assembly or project file. /// /// /// Implementations that are not produced by the AppHost build - for example metadata created from a project @@ -48,7 +47,7 @@ public interface IProjectMetadata : IResourceAnnotation /// must therefore treat the value as an optional hint and fall back to their existing behavior when it is absent. /// /// - public string? AssemblyName => null; + public string? TargetName => null; /// /// Gets a value indicating whether the project is a file-based app (a .cs file) rather than a full project (.csproj). diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 5d048cd85c9..5ff8f24c545 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -223,11 +223,11 @@ internal static string ResourcePropertyParameterValueDisplayName { } /// - /// Looks up a localized string similar to Assembly name. + /// Looks up a localized string similar to Target name. /// - internal static string ResourcePropertyProjectAssemblyNameDisplayName { + internal static string ResourcePropertyProjectTargetNameDisplayName { get { - return ResourceManager.GetString("ResourcePropertyProjectAssemblyNameDisplayName", resourceCulture); + return ResourceManager.GetString("ResourcePropertyProjectTargetNameDisplayName", resourceCulture); } } diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index 70e0695a89e..0e636cb8184 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -171,8 +171,8 @@ Value - - Assembly name + + Target name Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index 7802f6f0491..d129ef57d4c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index ced4c9987e6..53332b121cd 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index d9fe111c316..25085c4cc9c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index fbc6d104588..24d1bf75b4b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index c545d39d96d..32d46f19bf2 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index 31ebe7b40c9..1c104f4d6d3 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index 4568536864d..d5886a5b722 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index 07d59506b91..6564dcb3d70 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 8946af49a06..07283f7a1dc 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index 5a62134f3ff..1ee72f1e5eb 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 4a53abb469b..6abf92baf4e 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index 7cd42b5581c..4d7b3980cc2 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 31e37366f30..d8ea34aab47 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -122,11 +122,6 @@ Value - - Assembly name - Assembly name - - Launch profile Launch profile @@ -137,6 +132,11 @@ Project path + + Target name + Target name + + Tool package Tool package diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 914c54969d4..605b1d43687 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -57,11 +57,11 @@ public static class Project public const string LaunchProfile = "project.launchProfile"; /// - /// The MSBuild-evaluated assembly name of the project, baked into the generated project metadata at + /// The MSBuild-evaluated target name of the project, baked into the generated project metadata at /// AppHost build time. Only present for project resources added through a ProjectReference; the absence /// of the property is the signal that the producer could not determine the name. /// - public const string AssemblyName = "project.assemblyName"; + public const string TargetName = "project.targetName"; } public static class Terminal diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index b0cce81cf55..789b4ea5a1e 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -331,9 +331,9 @@ public void MapToResourceJson_ResolvesWaitingForDependencies() } [Fact] - public void MapToResourceJson_PreservesProjectAssemblyNameProperty() + public void MapToResourceJson_PreservesProjectTargetNameProperty() { - // The CLI/backchannel property bag is a pass-through, so the build-time project.assemblyName + // The CLI/backchannel property bag is a pass-through, so the build-time project.targetName // contract reaches `aspire describe` without any mapper-specific handling. var resource = new ResourceSnapshot { @@ -344,18 +344,18 @@ public void MapToResourceJson_PreservesProjectAssemblyNameProperty() Properties = new Dictionary { ["project.path"] = JsonValue.Create("/repo/Worker/Worker.csproj"), - ["project.assemblyName"] = JsonValue.Create("My Attach Service") + ["project.targetName"] = JsonValue.Create("My Attach Service") } }; var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource]); Assert.NotNull(result.Properties); - Assert.Equal("My Attach Service", result.Properties["project.assemblyName"]?.GetValue()); + Assert.Equal("My Attach Service", result.Properties["project.targetName"]?.GetValue()); var json = JsonSerializer.Serialize(result, ResourcesCommandJsonContext.RelaxedEscaping.ResourceJson); using var document = JsonDocument.Parse(json); - Assert.Equal("My Attach Service", document.RootElement.GetProperty("properties").GetProperty("project.assemblyName").GetString()); + Assert.Equal("My Attach Service", document.RootElement.GetProperty("properties").GetProperty("project.targetName").GetString()); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs index a3ebeaeabf8..c245395d724 100644 --- a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs @@ -23,7 +23,7 @@ public void FindProperty_GenericResourceProperty_ReturnsKnownProperty() [Theory] [InlineData(KnownProperties.Project.Path)] [InlineData(KnownProperties.Project.LaunchProfile)] - [InlineData(KnownProperties.Project.AssemblyName)] + [InlineData(KnownProperties.Project.TargetName)] [InlineData(KnownProperties.Executable.Path)] [InlineData(KnownProperties.Executable.WorkDir)] [InlineData(KnownProperties.Executable.Args)] diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index a91bab8cc43..1146d24a6fe 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -93,7 +93,7 @@ public async Task AddReferenceToDashboardAndDcpFallsBackToRuntimeIdentifierToolF } [Fact] - public async Task ProjectMetadataUsesAssemblyNameImportedFromDirectoryBuildProps() + public async Task ProjectMetadataUsesTargetNameImportedFromDirectoryBuildProps() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -113,11 +113,77 @@ public async Task ProjectMetadataUsesAssemblyNameImportedFromDirectoryBuildProps """); - Assert.Equal(""" public string? AssemblyName => @"My Attach Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"My Attach Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataUsesConfigurationConditionedAssemblyName() + public async Task ProjectMetadataUsesProjectFileNameWhenTargetNameIsNotSet() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // The overwhelmingly common case: no AssemblyName anywhere, so the evaluated name falls back to the project + // file name. Consumers rely on the property being present and correct here, not just in the exotic cases. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + + """); + + Assert.Equal(""" public string? TargetName => @"Worker";""", GetGeneratedTargetNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesTargetNameInheritedFromAnAncestorDirectoryBuildProps() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // The AssemblyName is several directory levels above the project, which is where a repo-wide convention + // usually lives. Only an MSBuild evaluation can see it. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + + """, + referencedProjectDirectoryName: "src/services/Worker", + ancestorDirectoryBuildPropsXml: """ + + Inherited Attach Service + + """); + + Assert.Equal(""" public string? TargetName => @"Inherited Attach Service";""", GetGeneratedTargetNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesTargetNameWhenItDivergesFromAssemblyName() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // TargetName is what the built output is actually named, and an SDK is free to set it to something other + // than AssemblyName. The debugger attaches to the built assembly, so TargetName is the value that has to + // win; asserting the divergence keeps a future refactor from quietly switching to AssemblyName. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Declared Name + Rewritten By Sdk + + """); + + Assert.Equal(""" public string? TargetName => @"Rewritten By Sdk";""", GetGeneratedTargetNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataUsesConfigurationConditionedTargetName() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -134,7 +200,7 @@ public async Task ProjectMetadataUsesConfigurationConditionedAssemblyName() extraArguments: ["-p:Configuration=Release"], configuration: "Release"); - Assert.Equal(""" public string? AssemblyName => @"Released Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Released Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -156,7 +222,7 @@ public async Task ProjectMetadataUsesProjectReferenceConfiguration() Configuration=Release """); - Assert.Equal(""" public string? AssemblyName => @"Released Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Released Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -178,7 +244,7 @@ public async Task ProjectMetadataUsesProjectReferencePlatform() Platform=x64 """); - Assert.Equal(""" public string? AssemblyName => @"64-bit Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"64-bit Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -197,7 +263,31 @@ public async Task ProjectMetadataUsesSolutionPreparedProjectReferenceConfigurati """, solutionProjectConfiguration: "Release|x64"); - Assert.Equal(""" public string? AssemblyName => @"Worker_Release_x64_net8.0";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Worker_Release_x64_net8.0";""", GetGeneratedTargetNameMember(generatedSource)); + } + + [Fact] + public async Task ProjectMetadataSkipsTargetNameForReferenceDisabledInSolutionConfiguration() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // A reference the solution excludes from the build carries BuildReference=false, and ResolveProjectReferences + // skips it. The probe has to skip it too: nothing caches GetTargetPath for it, so probing costs a fresh + // evaluation of a project the build was told not to touch. + var generatedSource = await GenerateProjectMetadataSourceAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + Disabled Service + + """, + solutionProjectConfiguration: "Debug|AnyCPU", + buildProjectInSolution: false); + + // Absence is the capability signal, so an unprobed reference must omit the member rather than guess a name. + Assert.Null(GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -220,11 +310,11 @@ public async Task ProjectMetadataRemovesProjectReferenceGlobalProperties() """, extraArguments: ["-p:Flavor=Chocolate"]); - Assert.Equal(""" public string? AssemblyName => @"Unflavored Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Unflavored Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataUsesTargetFrameworkConditionedAssemblyNameForMultiTargetedReference() + public async Task ProjectMetadataUsesTargetFrameworkConditionedTargetNameForMultiTargetedReference() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -239,7 +329,7 @@ public async Task ProjectMetadataUsesTargetFrameworkConditionedAssemblyNameForMu """); - Assert.Equal(""" public string? AssemblyName => @"Eight Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Eight Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -261,7 +351,7 @@ public async Task ProjectMetadataRespectsProjectReferenceTargetFramework() TargetFramework=net9.0 """); - Assert.Equal(""" public string? AssemblyName => @"Nine Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Nine Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -287,11 +377,11 @@ public async Task ProjectMetadataPreservesExplicitTargetFrameworkWhenItIsAlsoRem """, extraArguments: ["-p:Flavor=Chocolate"]); - Assert.Equal(""" public string? AssemblyName => @"Nine Clean Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Nine Clean Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataEscapesAssemblyNameForCSharpSource() + public async Task ProjectMetadataEscapesTargetNameForCSharpSource() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -305,11 +395,11 @@ public async Task ProjectMetadataEscapesAssemblyNameForCSharpSource() """); - Assert.Equal(""" public string? AssemblyName => @"Ünicode ""quoted"" O'Brien";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Ünicode ""quoted"" O'Brien";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataResolvesAssemblyNameWhenProjectDirectoryContainsAnApostrophe() + public async Task ProjectMetadataResolvesTargetNameWhenProjectDirectoryContainsAnApostrophe() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -317,7 +407,7 @@ public async Task ProjectMetadataResolvesAssemblyNameWhenProjectDirectoryContain // GetFullPath over %(Identity) that normalizes ProjectPath, and the GetFullPath over the // $(_AspireResolvedProjectFile) property that normalizes the resolved project file before the // two lists are correlated. Both have to survive it, because a failure here does not degrade - // the assembly name - it fails metadata generation and takes the whole AppHost build with it. + // the target name - it fails metadata generation and takes the whole AppHost build with it. var generatedSource = await GenerateProjectMetadataSourceAsync( workspace, referencedProjectXml: """ @@ -329,11 +419,11 @@ public async Task ProjectMetadataResolvesAssemblyNameWhenProjectDirectoryContain """, referencedProjectDirectoryName: "O'Brien"); - Assert.Equal(""" public string? AssemblyName => @"Apostrophe Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"Apostrophe Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataOmitsAssemblyNameWhenResolutionIsDisabled() + public async Task ProjectMetadataOmitsTargetNameWhenResolutionIsDisabled() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -346,13 +436,13 @@ public async Task ProjectMetadataOmitsAssemblyNameWhenResolutionIsDisabled() My Attach Service """, - extraArguments: ["-p:SkipAspireProjectResourceAssemblyName=true"]); + extraArguments: ["-p:SkipAspireProjectResourceTargetName=true"]); - Assert.Null(GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Null(GetGeneratedTargetNameMember(generatedSource)); } [Fact] - public async Task ProjectMetadataResolvesAssemblyNameWhenProbeFailuresAreFatal() + public async Task ProjectMetadataResolvesTargetNameWhenProbeFailuresAreFatal() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -371,7 +461,7 @@ public async Task ProjectMetadataResolvesAssemblyNameWhenProbeFailuresAreFatal() """, extraArguments: ["-p:BuildingProject=true"]); - Assert.Equal(""" public string? AssemblyName => @"My Attach Service";""", GetGeneratedAssemblyNameMember(generatedSource)); + Assert.Equal(""" public string? TargetName => @"My Attach Service";""", GetGeneratedTargetNameMember(generatedSource)); } [Fact] @@ -1038,7 +1128,9 @@ private static async Task GenerateProjectMetadataSourceAsync( string configuration = "Debug", string? projectReferenceMetadataXml = null, string? solutionProjectConfiguration = null, - string referencedProjectDirectoryName = "Worker") + string referencedProjectDirectoryName = "Worker", + string? ancestorDirectoryBuildPropsXml = null, + bool buildProjectInSolution = true) { var repoRoot = GetRepoRoot(); @@ -1078,6 +1170,22 @@ await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Directory.Build.prop """); } + if (ancestorDirectoryBuildPropsXml is not null) + { + // Written to the top-most segment of the referenced project's path rather than next to the project, so + // the value is only visible after MSBuild's upward Directory.Build.props probe has climbed several + // levels. A consumer that reads the project XML cannot see it at all. + var ancestorDirectory = Path.Combine(workspace.Path, referencedProjectDirectoryName.Split('/')[0]); + await File.WriteAllTextAsync(Path.Combine(ancestorDirectory, "Directory.Build.props"), + $""" + + + {ancestorDirectoryBuildPropsXml} + + + """); + } + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "AppHost")).FullName; var appHostTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets")); var appHostProjectFile = Path.Combine(appHostDirectory, "AppHost.csproj"); @@ -1085,7 +1193,7 @@ await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Directory.Build.prop ? null : $$""" - <SolutionConfiguration><ProjectConfiguration Project="{C42D47BF-C684-40EB-B438-FC98C4DC6F5D}" AbsolutePath="{{SecurityElement.Escape(workerProjectFile)}}" BuildProjectInSolution="True">{{solutionProjectConfiguration}}</ProjectConfiguration></SolutionConfiguration> + <SolutionConfiguration><ProjectConfiguration Project="{C42D47BF-C684-40EB-B438-FC98C4DC6F5D}" AbsolutePath="{{SecurityElement.Escape(workerProjectFile)}}" BuildProjectInSolution="{{(buildProjectInSolution ? "True" : "False")}}">{{solutionProjectConfiguration}}</ProjectConfiguration></SolutionConfiguration> """; @@ -1157,12 +1265,12 @@ await File.WriteAllTextAsync(Path.Combine(appHostDirectory, "Program.cs"), """ return await File.ReadAllTextAsync(generatedPath); } - private static string? GetGeneratedAssemblyNameMember(string generatedSource) + private static string? GetGeneratedTargetNameMember(string generatedSource) { return generatedSource .Split('\n') .Select(line => line.TrimEnd('\r')) - .SingleOrDefault(line => line.Contains("AssemblyName =>", StringComparison.Ordinal)); + .SingleOrDefault(line => line.Contains("TargetName =>", StringComparison.Ordinal)); } private static async Task CreateRunHookProjectAsync( diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index cbe58172744..8ef5e880df2 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -86,10 +86,10 @@ public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties() } [Fact] - public void ProjectSnapshotAddsAssemblyNameWhenProjectMetadataSuppliesIt() + public void ProjectSnapshotAddsTargetNameWhenProjectMetadataSuppliesIt() { var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { AssemblyName = "My Attach Service" }); + project.Annotations.Add(new TestProjectMetadata { TargetName = "My Attach Service" }); project.Annotations.Add(new LaunchProfileAnnotation("https")); var executable = Executable.Create("project", "dotnet"); @@ -105,18 +105,18 @@ public void ProjectSnapshotAddsAssemblyNameWhenProjectMetadataSuppliesIt() [project.Name] = project }).ToSnapshot(executable, CreatePreviousSnapshot()); - AssertHighlightedProperty(snapshot, KnownProperties.Project.AssemblyName, "Assembly name", isSensitive: false, sortOrder: 3); - Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.AssemblyName).Value); + AssertHighlightedProperty(snapshot, KnownProperties.Project.TargetName, "Target name", isSensitive: false, sortOrder: 3); + Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.TargetName).Value); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ProjectSnapshotOmitsAssemblyNameWhenProjectMetadataDoesNotSupplyIt(string? assemblyName) + public void ProjectSnapshotOmitsTargetNameWhenProjectMetadataDoesNotSupplyIt(string? targetName) { var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { AssemblyName = assemblyName }); + project.Annotations.Add(new TestProjectMetadata { TargetName = targetName }); project.Annotations.Add(new LaunchProfileAnnotation("https")); var executable = Executable.Create("project", "dotnet"); @@ -152,10 +152,10 @@ public void ProjectSnapshotOmitsAssemblyNameWhenProjectMetadataDoesNotSupplyIt(s [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ProjectSnapshotRemovesStaleAssemblyNameWhenProjectMetadataNoLongerSuppliesIt(string? assemblyName) + public void ProjectSnapshotRemovesStaleTargetNameWhenProjectMetadataNoLongerSuppliesIt(string? targetName) { var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { AssemblyName = assemblyName }); + project.Annotations.Add(new TestProjectMetadata { TargetName = targetName }); project.Annotations.Add(new LaunchProfileAnnotation("https")); var executable = Executable.Create("project", "dotnet"); @@ -166,10 +166,10 @@ public void ProjectSnapshotRemovesStaleAssemblyNameWhenProjectMetadataNoLongerSu ProcessId = 1234 }; - // Snapshots are merged into the previously published one, so a carried-forward assembly name has to be + // Snapshots are merged into the previously published one, so a carried-forward target name has to be // removed rather than just omitted. Absence is the capability signal, and a surviving stale value would - // tell consumers the evaluated assembly name is still available. - var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.AssemblyName, "Stale.Assembly.Name")]); + // tell consumers the evaluated target name is still available. + var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.TargetName, "Stale.Target.Name")]); var snapshot = CreateSnapshotBuilder(new Dictionary { @@ -191,10 +191,10 @@ public void ProjectSnapshotRemovesStaleAssemblyNameWhenProjectMetadataNoLongerSu } [Fact] - public void ProjectSnapshotReplacesStaleAssemblyNameWhenProjectMetadataStillSuppliesIt() + public void ProjectSnapshotReplacesStaleTargetNameWhenProjectMetadataStillSuppliesIt() { var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { AssemblyName = "My Attach Service" }); + project.Annotations.Add(new TestProjectMetadata { TargetName = "My Attach Service" }); project.Annotations.Add(new LaunchProfileAnnotation("https")); var executable = Executable.Create("project", "dotnet"); @@ -205,18 +205,18 @@ public void ProjectSnapshotReplacesStaleAssemblyNameWhenProjectMetadataStillSupp ProcessId = 1234 }; - var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.AssemblyName, "Stale.Assembly.Name")]); + var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.TargetName, "Stale.Target.Name")]); var snapshot = CreateSnapshotBuilder(new Dictionary { [project.Name] = project }).ToSnapshot(executable, previous); - Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.AssemblyName).Value); + Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.TargetName).Value); } [Fact] - public void ExecutableSnapshotWithoutProjectMetadataOmitsAssemblyName() + public void ExecutableSnapshotWithoutProjectMetadataOmitsTargetName() { var executable = Executable.Create("exe", "dotnet"); executable.Spec.WorkingDirectory = "/app"; @@ -452,7 +452,7 @@ private sealed class TestProjectMetadata : IProjectMetadata { public string ProjectPath => "/app/project.csproj"; - public string? AssemblyName { get; init; } + public string? TargetName { get; init; } public LaunchSettings LaunchSettings { get; } = new() { diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs index ec7fd91d0d1..c18739e09e5 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs @@ -107,24 +107,24 @@ public void WithProjectDefaultsAppliesToAProjectResourceThatWasAddedDirectly() } [Fact] - public void ProjectMetadataAssemblyNameDefaultsToNullForImplementationsThatDoNotSupplyIt() + public void ProjectMetadataTargetNameDefaultsToNullForImplementationsThatDoNotSupplyIt() { - // AssemblyName is a default interface member so metadata types that shipped before the + // TargetName is a default interface member so metadata types that shipped before the // build-time contract existed (external implementations, path-based and file-based apps) // stay source and binary compatible. IProjectMetadata metadata = new TestProject(); - Assert.Null(metadata.AssemblyName); + Assert.Null(metadata.TargetName); } [Fact] - public void ProjectMetadataAssemblyNameIsNullForPathBasedProjects() + public void ProjectMetadataTargetNameIsNullForPathBasedProjects() { using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("project", Path.Combine(AppContext.BaseDirectory, "project.csproj"), options => options.ExcludeLaunchProfile = true); - Assert.Null(project.Resource.GetProjectMetadata().AssemblyName); + Assert.Null(project.Resource.GetProjectMetadata().TargetName); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt index 73bce7e424c..7dfa1bec958 100644 --- a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt +++ b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt @@ -43,14 +43,14 @@ public class App : global::Aspire.Hosting.IProjectMetadata public string ProjectPath => """{AspirePath}/App/App.csproj"""; /// - /// The assembly name that the App project builds to. + /// The target name that the App project builds to. /// /// - /// Evaluated by MSBuild when this AppHost was built, so it reflects any AssemblyName set by the project or + /// Evaluated by MSBuild when this AppHost was built, so it reflects any TargetName set by the project or /// imported into it rather than the project file name. /// #nullable enable - public string? AssemblyName => @"App"; + public string? TargetName => @"App"; #nullable restore /// From 0ecf63b40b37709c63efa31a469c5b939bf9f97f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 14:27:34 -0400 Subject: [PATCH 11/90] Pin Azure Functions Core Tools in test workflow Avoid the broken azure-functions-core-tools@4 latest package, which currently downloads missing native CLI zip assets and fails Playground/Azure CI setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8e4c4f3ebe3..a20b025babd 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -258,7 +258,10 @@ jobs: - name: Install Azure Functions Core Tools if: runner.os == 'Linux' && (inputs.testShortName == 'Playground' || inputs.testShortName == 'Azure') run: | - npm i -g azure-functions-core-tools@4 --unsafe-perm true + # Keep this pinned instead of using @4/latest. The npm package downloads + # the native CLI from cdn.functions.azure.com during install, and 4.13.x + # currently points at missing zip assets. + npm i -g azure-functions-core-tools@4.12.1 --unsafe-perm true - name: Compute test project path id: compute_project_path From 3146493957eaceb1b17ee6d3723e867cd7ffbb97 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 15:09:32 -0400 Subject: [PATCH 12/90] Revert Azure Functions Core Tools workflow pin Restores .github/workflows/run-tests.yml to main for this PR, reverting the out-of-scope 0ecf63b40b workflow change. The Azure Functions Core Tools fix now lives on adamint/fix-azfunc-core-tools-ci. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-tests.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a20b025babd..8e4c4f3ebe3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -258,10 +258,7 @@ jobs: - name: Install Azure Functions Core Tools if: runner.os == 'Linux' && (inputs.testShortName == 'Playground' || inputs.testShortName == 'Azure') run: | - # Keep this pinned instead of using @4/latest. The npm package downloads - # the native CLI from cdn.functions.azure.com during install, and 4.13.x - # currently points at missing zip assets. - npm i -g azure-functions-core-tools@4.12.1 --unsafe-perm true + npm i -g azure-functions-core-tools@4 --unsafe-perm true - name: Compute test project path id: compute_project_path From 96873cd4e55c41ad612962e6ffd61233b07b65b0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 17:50:40 -0400 Subject: [PATCH 13/90] Fail closed for stale workspace resource commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/test/appHostTreeView.test.ts | 49 +++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 22 +++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 60e87ef6484..bc8f5db433f 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -1290,6 +1290,55 @@ suite('AspireAppHostTreeProvider', () => { provider.dispose(); }); + test('workspace resource command item does not execute when the AppHost path changes', async () => { + const runResourceCommandCalls: Array<[string, string | undefined, string, readonly string[]]> = []; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [ + makeResource({ + name: 'api', + displayName: 'API', + commands: { + restart: { displayName: 'Restart', description: null }, + }, + }), + ], + workspaceAppHostPath: '/repo/AppHost/AppHost.csproj', + workspaceAppHostCandidatePaths: [], + workspaceAppHostName: 'AppHost.csproj', + workspaceAppHostDescription: undefined, + onDidChangeData, + runResourceCommand: async (resourceName: string, appHostPath: string | undefined, commandName: string, additionalArgs: readonly string[] = []) => { + runResourceCommandCalls.push([resourceName, appHostPath, commandName, additionalArgs]); + return { stdout: '', stderr: '' }; + }, + } as unknown as AppHostDataRepository & { workspaceResources: ResourceJson[]; workspaceAppHostPath: string }; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + sandbox.stub(vscode.window, 'showInformationMessage'); + const [workspaceResourcesItem] = provider.getChildren(); + const [resourceItem] = provider.getChildren(workspaceResourcesItem); + const commandsGroup = provider.getChildren(resourceItem).find(item => item.contextValue === 'commandsGroup'); + assert.ok(commandsGroup, 'Expected commands group'); + const [commandItem] = provider.getChildren(commandsGroup); + repository.workspaceAppHostPath = '/repo/OtherAppHost/AppHost.csproj'; + repository.workspaceResources = [ + makeResource({ + name: 'api', + displayName: 'Other API', + commands: { + restart: { displayName: 'Restart', description: null }, + }, + }), + ]; + + await provider.executeResourceCommandItem(commandItem as any); + + assert.deepStrictEqual(runResourceCommandCalls, []); + provider.dispose(); + }); + test('resource command item returns failed execution outcome after reporting error', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => 'aspire', diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 2077bb96af1..ea3ca2014e7 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1569,7 +1569,12 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; + const resource = findLatestResourceForElement(this._repository, element); + if (!resource) { + vscode.window.showInformationMessage(noCommandsAvailable); + return; + } + const commands = resource.commands; if (!commands || Object.keys(commands).length === 0) { vscode.window.showInformationMessage(noCommandsAvailable); @@ -1612,9 +1617,12 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const commandName = element.commandName; const latestResource = findLatestResourceForElement(this._repository, element.resourceItem); - const command = latestResource === undefined - ? element.commandJson - : latestResource.commands?.[commandName]; + if (!latestResource) { + vscode.window.showInformationMessage(noCommandsAvailable); + return; + } + + const command = latestResource.commands?.[commandName]; const resourceItem = element.resourceItem; if (!isEnabledCommand(command)) { @@ -1714,7 +1722,11 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider this.showResourceCommandOutput(resourceName, command, content, outputAppHostPath), From 80cd3d5152d7f87255edd851cdba1914513f416c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 18:59:09 -0400 Subject: [PATCH 14/90] Avoid guessed target names for multitargeted refs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../build/Aspire.Hosting.AppHost.in.targets | 25 +++++++++---------- .../AppHostSdkTargetsTests.cs | 4 +-- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index dfba26231d9..2b94628957b 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -90,9 +90,10 @@ When a single-targeted reference does not specify SetTargetFramework, it is deliberately asked without an explicit TargetFramework: that matches the global properties ResolveProjectReferences already uses, so MSBuild serves the - result from its project cache instead of evaluating the reference a second time. For those references - TargetFramework is also undefined so a multi-targeted AppHost's inner build cannot force its own TFM onto a - reference that does not build for it. + result from its project cache instead of evaluating the reference a second time. A multi-targeted reference with no + selected TargetFramework is deliberately not asked for a target name. Publishing a guessed name would be worse than + omitting the metadata, because attach consumers trust every nonblank TargetName and only fall back to TargetPath + when the metadata is absent. Measured cost on a single-reference AppHost build: this adds one GetTargetFrameworks evaluation per reference and no extra GetTargetPath evaluation, because ResolveProjectReferences already asks for GetTargetPath with the same @@ -133,16 +134,12 @@ batch per item - the last batch's values are applied to every item - so the self-update is what keeps each reference's TargetFrameworks metadata paired with its own project. - %(TargetFrameworks) is a single semicolon-joined value such as "net8.0;net9.0"; the first entry is used because - it is the one a cross-targeting reference reports first and Aspire never negotiates a TFM for these references, - so there is no better signal about which inner build the AppHost will end up launching. Guessing is worth it - here: a name resolved from the wrong inner build leaves a consumer exactly where omitting the property would - have - falling back to its own guess - while the common case, where every TFM produces the same target name, - resolves correctly. + %(TargetFrameworks) is a single semicolon-joined value such as "net8.0;net9.0". If no TargetFramework is selected + for that reference, do not manufacture one here. A name from the wrong inner build suppresses the debugger's + TargetPath fallback and can make attach search for a process that never exists. --> <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> - TargetFramework=$([System.Text.RegularExpressions.Regex]::Match('%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)', '^[^;]*')) %(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove) @@ -165,7 +172,7 @@ Targets="GetTargetPath" BuildInParallel="$(BuildInParallel)" Properties="%(_AspireProjectResourceTargetPathProbe.SetConfiguration); %(_AspireProjectResourceTargetPathProbe.SetPlatform); %(_AspireProjectResourceTargetPathProbe.SetTargetFramework)" - ContinueOnError="$(_AspireProjectResourceTargetNameProbeContinueOnError)" + ContinueOnError="WarnAndContinue" RemoveProperties="%(_AspireProjectResourceTargetPathProbe.EffectiveGlobalPropertiesToRemove);%(_AspireProjectResourceTargetPathProbe.RemoveGlobalProperties);$(_GlobalPropertiesToRemoveFromProjectReferences)" SkipNonexistentProjects="true" SkipNonexistentTargets="true"> @@ -183,8 +190,13 @@ GetTargetPath returns a single primary output for an SDK project, so each batch holds one target path. If a reference ever returns more than one, the last wins - acceptable because the result is only ever a hint and the - property is dropped entirely when nothing resolves. Note the path comparison is MSBuild's, which is case - insensitive, so two references differing only in path casing would collide on a case-sensitive file system. + property is dropped entirely when nothing resolves. + + The path comparison below is MSBuild's, which is case insensitive, but two references differing only in path + casing cannot get each other's name: MSBuild's target batching is case insensitive too, so such references share + a single batch in CreateAspireProjectMetadataSources above and are already collapsed into one generated resource + before this target runs. Comparing ordinally here would change nothing except to risk dropping the hint if the + two item lists ever spelled the same path differently. --> Exe net8.0 - - - - false; - } - ]]> - - - - - + + """, extraArguments: ["-p:BuildingProject=true"]); - Assert.NotEqual(0, result.DotNetResult.ExitCode); - Assert.Contains("MSB4181", result.DotNetResult.Output); - } + // Without this the assertions below would also pass on a build where the hook never ran and the probe + // simply succeeded with no name to report. + Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); - [Fact] - public void ProjectMetadataTargetNameProbesStayFatalDuringBuild() - { - var targets = File.ReadAllText(Path.Combine(GetRepoRoot(), "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets")); + // TargetName is a debugger hint, not a build input. A probe that cannot answer has to degrade to omitting + // the member - attach consumers fall back to TargetPath from there - rather than stopping the target and + // leaving the AppHost with no reference metadata at all. + Assert.True(File.Exists(result.GeneratedPath), $"Generated project metadata was not found at '{result.GeneratedPath}'.{Environment.NewLine}{result.DotNetResult.Output}"); - Assert.Contains("<_AspireProjectResourceTargetNameProbeContinueOnError Condition=\"'$(BuildingProject)' == 'true'\">ErrorAndStop", targets); - Assert.Equal( - 2, - Regex.Matches(targets, "ContinueOnError=\"\\$\\(_AspireProjectResourceTargetNameProbeContinueOnError\\)\"").Count); + var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); + Assert.Contains("Worker.csproj", generatedSource); + Assert.Null(GetGeneratedTargetNameMember(generatedSource)); } [Fact] From 0a1dc3ddb9a2ff7918b06571697f381cf89b2405 Mon Sep 17 00:00:00 2001 From: adamint Date: Sun, 9 Aug 2026 18:59:57 -0400 Subject: [PATCH 18/90] Pin the fatal probe contract and record what the attach fallback cannot know The PR description promises that a failing GetTargetPath/GetTargetFrameworks probe still fails the build, but nothing pinned that behaviour, so a future ContinueOnError tweak could silently downgrade it. Add a theory that builds a project whose referenced AppHost errors from each probed target, asserts a non-zero exit code, and then re-runs the same project with SkipAspireProjectResourceTargetName=true to prove the failure is attributable to the probe rather than to an ordinary build of the reference. The attach fallback derives the process name by evaluating TargetPath with default global properties. It cannot do better: the older resource contract it exists to serve carries no configuration, and executable.args (the only property naming the running assembly) is published as sensitive and redacted to null before the extension sees it. Failing closed would remove attach support from exactly that population, so the fallback stays and now warns, with a comment recording both impossibility proofs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/loc/xlf/aspire-vscode.xlf | 3 ++ extension/package.nls.json | 1 + extension/src/debugger/languages/dotnet.ts | 12 +++++- extension/src/loc/strings.ts | 1 + extension/src/test/dotnetDebugger.test.ts | 10 +++++ .../AppHostSdkTargetsTests.cs | 41 +++++++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 85bff9242c6..71888054f24 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -859,6 +859,9 @@ timed out after {0}ms + + {0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name. + {0} exited with code {1}{2} diff --git a/extension/package.nls.json b/extension/package.nls.json index 2202b0362d5..e92f1ada8dc 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -282,6 +282,7 @@ "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", "aspire-vscode.strings.attachDebuggerProcessNameUnresolved": "Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.", + "aspire-vscode.strings.attachDebuggerTargetNameProbeAssumesDefaultConfiguration": "{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", "aspire-vscode.strings.workspaceViewSelectedSingleAppHostWithLanguage": "Workspace view selected because aspire ls found one buildable {0} AppHost.", diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 84a5a97de36..50c8a8d563d 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -498,6 +498,16 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR } async function getProcessNameFromTargetPath(projectPath: string, resourceLabel: string, dotNetService: IDotNetService): Promise { + // This probe evaluates the project with MSBuild's default global properties, so it answers for the + // project's default configuration rather than the one the AppHost is running. That is only wrong for + // a project whose assembly name is conditioned on Configuration, and it cannot be made right here: + // the resource contract of an AppHost old enough to omit project.targetName carries no configuration, + // and executable.args - the one property that would name the running assembly outright - is published + // as sensitive and redacted to null before the extension ever sees it (AuxiliaryBackchannelRpcTarget + // replaces every IsSensitive value with null). Failing closed instead would remove attach support from + // exactly the AppHosts this fallback exists to serve, so the probe stays best effort and says so. + extensionLogOutputChannel.warn(attachDebuggerTargetNameProbeAssumesDefaultConfiguration(resourceLabel, projectPath)); + try { const targetPath = await dotNetService.getDotNetTargetPath(projectPath); const fileName = targetPath.trim().split(/[\\/]/).pop() ?? ''; diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 76993600cb4..d9dc530bb89 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -140,6 +140,7 @@ export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resour export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); export const attachDebuggerProcessNameUnresolved = (resource: string, error: string) => vscode.l10n.t('Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.', resource, error); +export const attachDebuggerTargetNameProbeAssumesDefaultConfiguration = (resource: string, projectPath: string) => vscode.l10n.t('{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.', resource, projectPath); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 485c5c78d9a..ec74b1a5c7d 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -9,6 +9,8 @@ import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration import * as io from '../utils/io'; import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration } from '../loc/strings'; class TestDotNetService { private _hasDevKit: boolean; @@ -86,6 +88,7 @@ suite('Dotnet Debugger Extension Tests', () => { test('attach configuration derives process name from evaluated TargetPath when target name is not reported', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/My Attach Service.dll', null, true, true); + const warn = sinon.stub(extensionLogOutputChannel, 'warn'); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ name: 'worker', @@ -101,6 +104,13 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(configuration.processName, 'My Attach Service'); assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/worker/AttachDemo.Worker.csproj')); + + // The probe answers for the project's default configuration, which is not necessarily the one the + // AppHost is running. Recording that is the only remedy available: the configuration is not in the + // resource contract of an AppHost old enough to need this fallback. + assert.deepStrictEqual(warn.getCalls().map(call => call.args[0]), [ + attachDebuggerTargetNameProbeAssumesDefaultConfiguration('Worker', '/repo/worker/AttachDemo.Worker.csproj') + ]); }); test('attach configuration treats blank reported target name as absent', async () => { diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index fea8d9824df..518e15a9538 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -479,6 +479,47 @@ public async Task ProjectMetadataIsStillGeneratedWhenTheTargetNameProbeFails(str Assert.Null(GetGeneratedTargetNameMember(generatedSource)); } + [Theory] + [InlineData("GetTargetFrameworks")] + [InlineData("GetTargetPath")] + public async Task ProjectMetadataTargetNameProbeFailureStillFailsTheBuild(string probedTarget) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var referencedProjectXml = $""" + + Exe + net8.0 + + + + + """; + + var result = await RunProjectMetadataSourceGenerationAsync( + workspace, + referencedProjectXml, + extraArguments: ["-p:BuildingProject=true"]); + + // ContinueOnError keeps the codegen target running, but it does not demote the error the referenced + // project logged: the build still fails. Letting the AppHost build succeed on a reference that cannot + // be evaluated would hide a broken project behind a debugger-only convenience. + Assert.NotEqual(0, result.DotNetResult.ExitCode); + Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); + + using var controlWorkspace = TemporaryWorkspace.Create(outputHelper); + + // The same project, with only the probe turned off, builds cleanly. That is what attributes the failure + // above to the probe rather than to a project that was broken to begin with, and it also shows neither + // probed target runs on an Aspire project reference during an ordinary build. + var controlResult = await RunProjectMetadataSourceGenerationAsync( + controlWorkspace, + referencedProjectXml, + extraArguments: ["-p:BuildingProject=true", "-p:SkipAspireProjectResourceTargetName=true"]); + + Assert.True(controlResult.DotNetResult.ExitCode == 0, controlResult.DotNetResult.Output); + } + [Fact] public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled() { From 2a757e75f280c80533eaaad6fe599f04e289602b Mon Sep 17 00:00:00 2001 From: adamint Date: Sun, 9 Aug 2026 19:48:49 -0400 Subject: [PATCH 19/90] Explain an AppHost build failure caused by the target name probe A reference that errors while answering GetTargetFrameworks or GetTargetPath fails the AppHost build, and the probe cannot prevent that: the error belongs to the reference's own build, so ContinueOnError lets this target keep running but does not demote it. Verified minimally outside Aspire - a parent whose only probe-time error comes from a child invoked with ContinueOnError=WarnAndContinue still exits nonzero while the calling target continues. What is left is the diagnostic. Until now the build failed inside a target the user never ran, with an error from a referenced project and nothing tying it to Aspire or naming the way out, even though the value being collected is only a debugger hint that consumers already fall back from when it is absent. Warn from the probe itself, immediately after each MSBuild task so MSBuildLastTaskResult still describes it, and name SkipAspireProjectResourceTargetName. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../build/Aspire.Hosting.AppHost.in.targets | 16 +++++++++++++++- .../AppHostSdkTargetsTests.cs | 7 +++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 511973b34ce..5bb118161ed 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -128,7 +128,11 @@ ContinueOnError only governs this target. MSBuild still reports whatever error the referenced project itself logged, so the overall build result is not fully insulated from a reference that cannot answer the probe; - SkipAspireProjectResourceTargetName=true opts out of probing entirely for those. + SkipAspireProjectResourceTargetName=true opts out of probing entirely for those. That is a limitation of the + MSBuild task rather than a choice: an error logged by the referenced project belongs to that project's build, + so ContinueOnError lets this target keep running but cannot demote it, and the build still fails. The warnings + after each probe exist for that case, so a failure inside a target the user never asked for says where it came + from and how to turn it off. --> + + + + _AspireProjectResourceBuildOutput diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 5bb118161ed..2e70b381423 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -95,17 +95,27 @@ omitting the metadata, because attach consumers trust every nonblank TargetName and only fall back to TargetPath when the metadata is absent. - Measured cost on a single-reference AppHost build: this adds one GetTargetFrameworks evaluation per reference and - no extra GetTargetPath evaluation, because ResolveProjectReferences already asks for GetTargetPath with the same - effective global properties and MSBuild serves the second request from its result cache. A reference whose TFM - differs from the AppHost's is a genuine second evaluation, which is the price of reading the name it actually - builds under. + Measured cost on a single-reference AppHost build: nothing, for any reference this build built. Those names come + from @(_AspireProjectResourceBuildOutput), which ResolveProjectReferences already populated. The probes below only + run for references this build did not build, where they add one GetTargetFrameworks evaluation per reference and + no extra GetTargetPath evaluation, because ResolveProjectReferences asks for GetTargetPath with the same effective + global properties and MSBuild serves the second request from its result cache. A reference whose TFM differs from + the AppHost's is a genuine second evaluation, which is the price of reading the name it actually builds under. --> + + <_AspireProjectResourceTargetPath Include="@(_AspireProjectResourceBuildOutput)" /> + - <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" /> + <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" + Condition="'@(_AspireProjectResourceBuildOutput)' == ''" /> + Exe + net8.0 + RenamedWorker + + + + + """, + // ResolveReferences rather than Build: it runs ResolveProjectReferences, which is the step a real build + // performs before CoreCompile and the step that captures the reference's output. Compiling the AppHost + // itself would need the Aspire.Hosting reference the generated source derives from, which this + // workspace deliberately does not have. + msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); + + Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); + + var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); + Assert.Equal(""" public string? TargetName => @"RenamedWorker";""", GetGeneratedTargetNameMember(generatedSource)); + } + + [Fact] + public async Task AspireProjectResourcesCaptureTheirBuildOutputForTheTargetNameHint() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var sdkTargetsPath = SecurityElement.Escape(Path.Combine(GetRepoRoot(), "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets")); + var projectFile = Path.Combine(workspace.Path, "Host.csproj"); + + // The AppHost targets read @(_AspireProjectResourceBuildOutput), which only exists because the SDK defaults + // OutputItemType on every Aspire project resource. Nothing else in these tests evaluates the SDK targets - + // the AppHost harness spells the metadata out - so this is what keeps the two from drifting apart. + await File.WriteAllTextAsync(projectFile, + $$""" + + + + + + net8.0 + true + + + + + + + + + + + + + + + + + + """); + + var result = await RunDotNetWithArgumentsAsync(workspace.Path, ["msbuild", "-nologo", "-t:ReportOutputItemType", projectFile]); + + Assert.True(result.ExitCode == 0, result.Output); + Assert.Contains("OUTPUTITEMTYPE Resource: [_AspireProjectResourceBuildOutput]", result.Output); + + // A reference opted out of being a resource is an ordinary reference, and one that already routes its + // outputs somewhere keeps doing so: capturing the target name must not take an item type from its owner. + Assert.Contains("OUTPUTITEMTYPE Library: []", result.Output); + Assert.Contains("OUTPUTITEMTYPE Claimed: [SomeoneElsesItem]", result.Output); + } + + [Fact] + public async Task GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // The target name probe is switched off entirely, so nothing Aspire contributes asks the reference for + // GetTargetPath. Resolving project references still reaches it, which is what separates the two probed + // targets: a reference that cannot answer GetTargetPath cannot be referenced by any project, Aspire or + // not, so that half of the probe adds no failure mode of its own. GetTargetFrameworks is the half that + // did, and the test above covers it. + var result = await RunProjectMetadataSourceGenerationAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + + + + + """, + extraArguments: ["-p:SkipAspireProjectResourceTargetName=true"], + msbuildTarget: "ResolveReferences"); + + Assert.NotEqual(0, result.DotNetResult.ExitCode); + Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); + } + [Fact] public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled() { @@ -1221,7 +1332,8 @@ private static async Task RunProjectMetad string? solutionProjectConfiguration = null, string referencedProjectDirectoryName = "Worker", string? ancestorDirectoryBuildPropsXml = null, - bool buildProjectInSolution = true) + bool buildProjectInSolution = true, + string msbuildTarget = "WriteAspireProjectMetadataSources") { var repoRoot = GetRepoRoot(); @@ -1304,6 +1416,9 @@ await File.WriteAllTextAsync(appHostProjectFile, Exe {{targetFramework}} true + + 9.0.0 <_AspireTasksAssembly>{{SecurityElement.Escape(GetAspireHostingTasksAssemblyPath())}} true true @@ -1315,6 +1430,7 @@ await File.WriteAllTextAsync(appHostProjectFile, ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" ExcludeAssets="all" + OutputItemType="_AspireProjectResourceBuildOutput" Private="false"> {C42D47BF-C684-40EB-B438-FC98C4DC6F5D} {{projectReferenceMetadataXml}} @@ -1338,7 +1454,7 @@ await File.WriteAllTextAsync(Path.Combine(appHostDirectory, "Program.cs"), """ "msbuild", "-nologo", "-restore", - "-t:WriteAspireProjectMetadataSources", + $"-t:{msbuildTarget}", appHostProjectFile }; From 6488463c4d0f905692180d2df2f2e704eb9d30fc Mon Sep 17 00:00:00 2001 From: adamint Date: Sun, 9 Aug 2026 21:15:03 -0400 Subject: [PATCH 22/90] Probe per-reference when project resources mix output routing The target-name hint is seeded from _AspireProjectResourceBuildOutput, but the probe was gated on that item list being empty overall. With two project resources where one carries a caller-supplied OutputItemType, the sibling's captured output suppressed the probe for both, so the reference routed elsewhere silently lost its target name. Exclude only the references that actually captured into our item type, using metadata rather than a path comparison (MSBuild canonicalizes %(MSBuildSourceProjectFile) through symlinks but not %(FullPath), so path correlation is unsafe on macOS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../build/Aspire.Hosting.AppHost.in.targets | 21 ++++-- .../AppHostSdkTargetsTests.cs | 75 ++++++++++++++++++- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 2e70b381423..5be68af08ac 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -125,16 +125,23 @@ was never meant to participate in it. AssignProjectConfiguration, which PrepareProjectReferences runs, defaults the metadata to true, so it is always populated by the time this target executes. - The probe is then only for a build that resolved no references at all - the codegen target invoked on its - own, or a host whose references were resolved some other way. The condition is on the set rather than on - each reference because the two sides cannot be correlated by path here: MSBuild canonicalizes - %(MSBuildSourceProjectFile) through symlinks while %(FullPath) does not, so on a machine where the sources - live under a symlinked directory the same project appears under two spellings. The sets agree anyway, - because ResolveProjectReferences and the Include below both skip exactly the BuildReference=false - references. + What is left to probe is whatever the seeding above could not answer. An empty capture means no reference + was resolved at all - the codegen target invoked on its own, or a host whose references were resolved some + other way - so everything is probed. Otherwise only the references whose output went somewhere else are, + which is any reference carrying a caller-supplied OutputItemType: the SDK only defaults ours onto references + that do not already have one, so those references build into the caller's item type and never appear in the + capture. + + Which reference to skip is decided by metadata rather than by comparing project paths, because the two + sides cannot be correlated that way here: MSBuild canonicalizes %(MSBuildSourceProjectFile) through symlinks + while %(FullPath) does not, so under a symlinked source directory the same project appears under two + spellings. Both sides of the Exclude below come from the same item list, so they are the same strings. --> <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" Condition="'@(_AspireProjectResourceBuildOutput)' == ''" /> + <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" + Exclude="@(_MSBuildProjectReferenceExistent->WithMetadataValue('OutputItemType', '_AspireProjectResourceBuildOutput'))" + Condition="'@(_AspireProjectResourceBuildOutput)' != ''" /> + + + <_AspireProjectReferencesResolved>true + + + <_AspireProjectResourceTargetPath Include="@(_AspireProjectResourceBuildOutput)" /> @@ -125,39 +143,75 @@ was never meant to participate in it. AssignProjectConfiguration, which PrepareProjectReferences runs, defaults the metadata to true, so it is always populated by the time this target executes. - What is left to probe is whatever the seeding above could not answer. An empty capture means no reference - was resolved at all - the codegen target invoked on its own, or a host whose references were resolved some - other way - so everything is probed. Otherwise only the references whose output went somewhere else are, - which is any reference carrying a caller-supplied OutputItemType: the SDK only defaults ours onto references - that do not already have one, so those references build into the caller's item type and never appear in the - capture. + What is left to ask about is whatever the seeding above could not answer, which is any reference carrying a + caller-supplied OutputItemType: the SDK only defaults ours onto references that do not already have one, so + those references build into the caller's item type and never appear in the capture. Deciding that per + reference matters - a build that captured something for one reference must not conclude it captured + something for all of them. Which reference to skip is decided by metadata rather than by comparing project paths, because the two sides cannot be correlated that way here: MSBuild canonicalizes %(MSBuildSourceProjectFile) through symlinks while %(FullPath) does not, so under a symlinked source directory the same project appears under two spellings. Both sides of the Exclude below come from the same item list, so they are the same strings. + + A build that resolved its references already asked every one of them for GetTargetPath, so asking again + cannot introduce a failure that build was not going to hit anyway - see + GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt, which fails a build with the target name + collection switched off entirely. GetTargetFrameworks is the half that does add a failure mode of its own, + because Aspire project references default SkipGetTargetFrameworkProperties=true and nothing else asks for + it, so a build that resolved its references does not ask for it here either. A cross-targeting reference + then resolves no name, because its outer build does not define GetTargetPath and SkipNonexistentTargets + skips it - the same outcome this target already chooses for a multi-targeted reference with no selected + TargetFramework, and better than failing a build over a debugger hint. --> + <_AspireProjectResourceResolvedTargetPathProbe Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" + Exclude="@(_MSBuildProjectReferenceExistent->WithMetadataValue('OutputItemType', '_AspireProjectResourceBuildOutput'))" + Condition="'$(_AspireProjectReferencesResolved)' == 'true'" /> + + <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" - Condition="'@(_AspireProjectResourceBuildOutput)' == ''" /> - <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" - Exclude="@(_MSBuildProjectReferenceExistent->WithMetadataValue('OutputItemType', '_AspireProjectResourceBuildOutput'))" - Condition="'@(_AspireProjectResourceBuildOutput)' != ''" /> + Condition="'$(_AspireProjectReferencesResolved)' != 'true'" /> + + + + + + + + @"CapturedWorker";""", GetGeneratedTargetNameMember(secondGeneratedSource)); } + [Fact] + public async Task ProjectResourcesRoutingTheirOutputElsewhereDoNotFailTheBuildThatResolvedThem() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // GetTargetFrameworks is the half of the probe that can fail a reference which builds perfectly well: + // Aspire project references default SkipGetTargetFrameworkProperties=true, so nothing in a normal build + // asks for it. A reference whose output the build routed to a caller-supplied OutputItemType is not in the + // capture the seeding reads, and it is also a reference an ordinary AppHost build resolves - so collecting + // its name must stay inside what that build already asked for. It already asked for GetTargetPath, which is + // what GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt pins, so that is all this asks too. + var result = await RunProjectMetadataSourceGenerationAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0 + RoutedWorker + + + + + """, + projectReferenceMetadataXml: """ + SomeoneElsesItem + """, + msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); + + Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); + + // Asserting the name resolved anyway is what keeps this from passing on a build that simply stopped + // collecting target names for the reference whose output was not captured. + var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); + Assert.Equal(""" public string? TargetName => @"RoutedWorker";""", GetGeneratedTargetNameMember(generatedSource)); + } + [Fact] public async Task GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt() { From 3eadb1111e6491548fee99f6712210c1d0eebc02 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 22:11:01 -0400 Subject: [PATCH 25/90] Redact host-only forward-slash UNC values in dashboard telemetry The private-location detector matched \\server on its own through the backslash alternative, but the forward-slash alternative required a share segment, so //server passed through unredacted while \\server did not. Both spellings name an internal host. Make the share segment optional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/dcp/DashboardTelemetryPassthrough.ts | 7 ++++++- extension/src/test/dashboardTelemetryRoutes.test.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/extension/src/dcp/DashboardTelemetryPassthrough.ts b/extension/src/dcp/DashboardTelemetryPassthrough.ts index 05fe51d5758..924cfbec51e 100644 --- a/extension/src/dcp/DashboardTelemetryPassthrough.ts +++ b/extension/src/dcp/DashboardTelemetryPassthrough.ts @@ -1030,9 +1030,14 @@ function sanitizeDashboardStringValue(value: string): string { /(?:^|[\s\r\n\\])net(?:\.exe)?.{1,5}(?:user|share)\b/i.test(boundedValue); // Treat dashboard leaf values that start like private locations as unsafe, including // UNC forms such as \\server\share, \\?\UNC\server\share, and //server/share. + // The share segment is optional on both spellings: \\server and //server name an internal + // host on their own, and the backslash alternative below already matched the host-only form, + // so requiring a share on the forward-slash form would redact one spelling of a private host + // name and pass the other through. A leading // followed by anything other than a separator or + // whitespace is redacted, which also covers //server/share because the host matches first. const containsPrivateLocation = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(boundedValue) || - /(?:^|[\s"'(])(?:[A-Za-z]:[\\/]|\\\\|\/\/[^/\s]+\/[^/\s]+|~[\\/]|\/(?:[^/\s]+[\\/])|\.\.?[\\/])/.test(boundedValue) || + /(?:^|[\s"'(])(?:[A-Za-z]:[\\/]|\\\\|\/\/[^/\s]+|~[\\/]|\/(?:[^/\s]+[\\/])|\.\.?[\\/])/.test(boundedValue) || /(?:^|[\s"'(])(?:(?:[^\\/\s"'()]+[\\/]){2,}[^\\/\s"'()]+|(?:[^\\/\s"'()]+[\\/])+[^\\/\s"'()]+\.(?:cs|fs|vb|ts|js|json|xml|props|targets|sln|slnx))\b/i.test(boundedValue); const containsEmail = /@[A-Za-z0-9-]+\.[A-Za-z0-9-]+/.test(boundedValue); diff --git a/extension/src/test/dashboardTelemetryRoutes.test.ts b/extension/src/test/dashboardTelemetryRoutes.test.ts index a678b2fbda3..bd5a3fa56e4 100644 --- a/extension/src/test/dashboardTelemetryRoutes.test.ts +++ b/extension/src/test/dashboardTelemetryRoutes.test.ts @@ -220,6 +220,7 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { String.raw`\\?\UNC\private-server\customer-share\workspace\apphost.csproj`, String.raw`\\private-server`, '//private-server/customer-share/workspace/apphost.csproj', + '//private-server', 'UNC-looking label private-server customer-share without leading slashes', ], propertyType: 1, @@ -237,6 +238,7 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { '', '', '', + '', 'UNC-looking label private-server customer-share without leading slashes', ] ); From db2c0db2c314b4a07a4b6dbdcc90bf848f7a971e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 22:40:41 -0400 Subject: [PATCH 26/90] Pin the private-location and credential forms telemetry must clean Two cleaning stages carry this between them and neither one covers everything, so the split needs to be visible in tests rather than inferred. VS Code's TelemetryLogger cleans extension-authored property values, and the dashboard passthrough cleans each dashboard leaf itself because the bundle reaches VS Code as a single JSON string. Cover every spelling a private location arrives in - \\server\share, //server/share, host-only \\server and //server, \\?\UNC\server\share, \\?\C:\path, JSON-escaped forms and drive-letter paths - plus the standalone credential formats that carry no assignment and no Bearer prefix, which an assignment-shaped detector alone would pass to the wire. Also pin the boundary: VS Code's path detector needs a separated segment after the leading slashes, so a bare host name survives it in either spelling. Extension property values are registry-constrained buckets that cannot carry one, and the free-form dashboard path cleans it itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/dashboardTelemetryRoutes.test.ts | 60 ++++++++++++++++- extension/src/test/telemetry.test.ts | 64 +++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/extension/src/test/dashboardTelemetryRoutes.test.ts b/extension/src/test/dashboardTelemetryRoutes.test.ts index bd5a3fa56e4..6bf118e7b68 100644 --- a/extension/src/test/dashboardTelemetryRoutes.test.ts +++ b/extension/src/test/dashboardTelemetryRoutes.test.ts @@ -210,7 +210,13 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { assert.strictEqual(parsed.v['Aspire.Dashboard.UserAgent'], ''); }); - test('POST /telemetry/operation sanitizes UNC paths before bundling', async () => { + // Every spelling a private location can arrive in from the dashboard. This is one test rather + // than several because the failure mode being pinned is a gap between spellings: the detector + // is a set of alternatives, and a change that tightens one of them leaves the same host name + // redacted under one spelling and transmitted under another. VS Code's own cleaner does not + // backstop this - the bundle reaches it as a single JSON string, which is why each leaf is + // cleaned before it is bundled - and it does not redact host-only UNC values at all. + test('POST /telemetry/operation sanitizes every private-location spelling before bundling', async () => { const { status } = await postJson(h.baseUrl, '/telemetry/operation', { eventName: 'aspire/dashboard/component/open', properties: { @@ -221,6 +227,14 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { String.raw`\\private-server`, '//private-server/customer-share/workspace/apphost.csproj', '//private-server', + // JSON-escaped spellings, which is how a UNC path looks once a dashboard + // client has already serialized it into a string it then sends as a value. + String.raw`\\\\private-server\\customer-share`, + String.raw`\\?\C:\customer\workspace`, + String.raw`C:\Users\customer\workspace\apphost.csproj`, + String.raw`D:\Work\customer\apphost.csproj`, + String.raw`D:\\Work\\customer`, + '/mnt/customer/project/apphost.csproj', 'UNC-looking label private-server customer-share without leading slashes', ], propertyType: 1, @@ -234,6 +248,12 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { assert.deepStrictEqual( parsed.v['Aspire.Dashboard.Resource.Types'], [ + '', + '', + '', + '', + '', + '', '', '', '', @@ -244,6 +264,44 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { ); }); + // Standalone credential formats carry no assignment and no `Bearer` prefix, so an + // assignment-shaped detector alone misses them. They are cleaned here rather than left to + // VS Code for the same reason the paths above are: the bundle is one opaque string by the + // time VS Code sees it, and a single match would replace the whole bundle rather than the leaf. + test('POST /telemetry/operation redacts standalone credential formats before bundling', async () => { + const { status } = await postJson(h.baseUrl, '/telemetry/operation', { + eventName: 'aspire/dashboard/component/open', + properties: { + 'Aspire.Dashboard.Resource.Types': { + value: [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcdEFGH', + `github_pat_11ABCDEFG0abcdefghijkl_${'abcdefghijklmnopqrstuvwxyz'}${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'}0123456`, + 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', + 'xoxb-123456789012-abcdefghijkl', + 'AIzaSyA0123456789abcdefghijklmnopqrstuvw', + 'project', + ], + propertyType: 1, + }, + }, + result: 1, + }); + + assert.strictEqual(status, 200); + const parsed = JSON.parse(h.fake.events[0].properties?.dashboard_properties ?? ''); + assert.deepStrictEqual( + parsed.v['Aspire.Dashboard.Resource.Types'], + [ + '', + '', + '', + '', + '', + 'project', + ] + ); + }); + test('POST /telemetry/operation sanitizes every nested string-array entry', async () => { const { status } = await postJson(h.baseUrl, '/telemetry/operation', { eventName: 'aspire/dashboard/component/open', diff --git a/extension/src/test/telemetry.test.ts b/extension/src/test/telemetry.test.ts index 84ee19c306f..96d73bcc513 100644 --- a/extension/src/test/telemetry.test.ts +++ b/extension/src/test/telemetry.test.ts @@ -188,6 +188,70 @@ suite('telemetry utilities', () => { assert.strictEqual(fake.events[0].measurements?.duration_ms, 12); }); + // Extension-authored telemetry is cleaned by VS Code's TelemetryLogger rather than by anything + // in this file. That is a deliberate choice - a hand-written replacement has to re-derive the + // platform's whole secret vocabulary and silently loses a category whenever it misses one - so + // this pins the categories the delegation is relied on for, standalone credential formats + // included: those carry no assignment and no `Bearer` prefix, so an assignment-shaped detector + // would pass them straight to the wire. + test('extension-authored property values are cleaned before reaching the transport', () => { + const githubPat = `github_pat_11ABCDEFG0abcdefghijkl_${'abcdefghijklmnopqrstuvwxyz'}${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'}0123456`; + const cleanedInputs = [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcdEFGH', + githubPat, + 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', + 'xoxb-123456789012-abcdefghijkl', + 'AIzaSyA0123456789abcdefghijklmnopqrstuvw', + String.raw`C:\Users\customer\workspace\apphost.csproj`, + String.raw`D:\Work\customer\apphost.csproj`, + '/mnt/customer/project/apphost.csproj', + String.raw`\\private-server\customer-share\workspace`, + '//private-server/customer-share/workspace', + String.raw`\\?\UNC\private-server\customer-share`, + String.raw`\\?\C:\customer\workspace`, + String.raw`\\\\private-server\\customer-share`, + ]; + + for (const input of cleanedInputs) { + sendTelemetryEvent('aspire/vscode/command/invoked', { command: input }); + } + + assert.deepStrictEqual( + fake.events.map(event => event.properties?.command), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '/', + String.raw`\\?`, + String.raw`\\?\`, + String.raw`\\`, + ] + ); + }); + + // The boundary of the delegation above. VS Code's path detector needs at least one + // separated segment after the leading slashes, so a bare host name survives it in either + // spelling. Extension-authored properties are registry-constrained buckets that cannot carry + // one; the dashboard passthrough, which does carry free-form strings, cleans each leaf itself + // rather than relying on this stage - see the private-location spelling coverage in + // dashboardTelemetryRoutes.test.ts. Pinned so the split stays visible if either side moves. + test('host-only UNC values survive the platform cleaning stage', () => { + sendTelemetryEvent('aspire/vscode/command/invoked', { command: String.raw`\\private-server` }); + sendTelemetryEvent('aspire/vscode/command/invoked', { command: '//private-server' }); + + assert.deepStrictEqual( + fake.events.map(event => event.properties?.command), + [String.raw`\\private-server`, '//private-server'] + ); + }); + test('telemetry levels are consulted on every emit', () => { fake.telemetryLevel = 'off'; sendTelemetryEvent('aspire/vscode/command/invoked', { command: 'cmd.off' }); From 830c8f22b37085fa82cbc4c6c80cb9263e5d4794 Mon Sep 17 00:00:00 2001 From: adamint Date: Sun, 9 Aug 2026 22:27:30 -0400 Subject: [PATCH 27/90] Do not offer attach for an Executable launch profile An Executable profile does not run the project's own output - the extension launches the profile's executablePath with its commandLineArgs - so a process named after the project's TargetName was never started, and the attach configuration would have looked for one. The resource contract does not carry which profile the AppHost selected, so only the default one can be read; refuse that case and say why rather than producing an attach that finds nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/loc/xlf/aspire-vscode.xlf | 3 ++ extension/package.nls.json | 1 + extension/src/debugger/languages/dotnet.ts | 15 ++++++- extension/src/loc/strings.ts | 1 + extension/src/test/dotnetDebugger.test.ts | 49 +++++++++++++++++++++- 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 71888054f24..af8024212df 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -865,6 +865,9 @@ {0} exited with code {1}{2} + + {0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead. + {0} · {1} diff --git a/extension/package.nls.json b/extension/package.nls.json index e92f1ada8dc..649b21002cf 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -282,6 +282,7 @@ "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", "aspire-vscode.strings.attachDebuggerProcessNameUnresolved": "Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.", + "aspire-vscode.strings.attachDebuggerExecutableLaunchProfile": "{0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead.", "aspire-vscode.strings.attachDebuggerTargetNameProbeAssumesDefaultConfiguration": "{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 50c8a8d563d..4b1ce13bd0a 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -484,6 +484,19 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } + // An Executable profile does not run the project's own output: the extension launches the profile's + // executablePath with its commandLineArgs (see configureExecutableLaunchProfile), so a process named + // after the project's TargetName was never started and attaching by that name would find nothing. + // Only the default profile can be checked - the resource contract does not carry which profile the + // AppHost actually selected - so this refuses the case where nothing else was chosen and explains why, + // rather than offering an attach that cannot succeed. + const defaultLaunchProfile = determineDefaultLaunchProfile(await readLaunchSettings(attachInfo.projectPath)); + if (defaultLaunchProfile.profile?.commandName === LaunchProfileCommandName.executable) { + throw new AttachDebuggerConfigurationError( + 'ResourceNotAttachable', + attachDebuggerExecutableLaunchProfile(attachInfo.resourceLabel, defaultLaunchProfile.profileName ?? '')); + } + let processName = attachInfo.reportedTargetName; if (processName === undefined) { processName = await getProcessNameFromTargetPath(attachInfo.projectPath, attachInfo.resourceLabel, dotNetService); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index d9dc530bb89..8784718a654 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -140,6 +140,7 @@ export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resour export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); export const attachDebuggerProcessNameUnresolved = (resource: string, error: string) => vscode.l10n.t('Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.', resource, error); +export const attachDebuggerExecutableLaunchProfile = (resource: string, profileName: string) => vscode.l10n.t('{0} runs through the \'{1}\' launch profile, which uses commandName \'Executable\', so the running process is the one that profile starts rather than the project\'s own output. Attach to it from the Run and Debug view instead.', resource, profileName); export const attachDebuggerTargetNameProbeAssumesDefaultConfiguration = (resource: string, projectPath: string) => vscode.l10n.t('{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.', resource, projectPath); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index ec74b1a5c7d..5406f064634 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -10,7 +10,7 @@ import * as io from '../utils/io'; import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import { extensionLogOutputChannel } from '../utils/logging'; -import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration } from '../loc/strings'; +import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile } from '../loc/strings'; class TestDotNetService { private _hasDevKit: boolean; @@ -177,6 +177,53 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); + test('attach configuration refuses a project whose default launch profile runs an executable', async () => { + const fs = require('fs'); + const path = require('path'); + + // A real launchSettings.json on disk, because the refusal is decided by reading the file the way + // `dotnet run` does rather than from anything the resource snapshot carries. + const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-executable-profile'); + const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); + fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ + profiles: { + 'Aspire_my-function': { + commandName: 'Executable', + executablePath: 'dotnet', + commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', + }, + }, + })); + + try { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'my-function', + displayName: 'My Function', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), + 'project.targetName': 'MyClassLibFunction', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' + && error.message === attachDebuggerExecutableLaunchProfile('My Function', 'Aspire_my-function')); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + } + finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + test('failed AppHost start writes error to debug console', async () => { const parentDebugSession = { id: 'aspire-session', From f84aa825df0070f3429ccad1f17157f3134114d8 Mon Sep 17 00:00:00 2001 From: adamint Date: Mon, 10 Aug 2026 00:02:51 -0400 Subject: [PATCH 28/90] Use the launch profile the AppHost reported, not the file default The attach guard read launchSettings.json and inferred the profile dotnet run would pick by default. The resource snapshot already carries the profile the AppHost actually applied (project.launchProfile, set from GetEffectiveLaunchProfile in ResourceSnapshotBuilder), and the two can differ in both directions: an AppHost that selects a non-default Executable profile was still offered an attach by TargetName to a process that was never started, and a project with WithExcludeLaunchProfile - where no profile applies at all and the project output really is running - was refused because the file's default happened to be an Executable profile. Key presence rather than value is used to tell 'no profile applies' (published as a null value) from an AppHost too old to report the property, which still falls back to inferring the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/src/debugger/languages/dotnet.ts | 60 +++++++++++-- extension/src/test/dotnetDebugger.test.ts | 97 ++++++++++++++++++++++ 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 4b1ce13bd0a..4b3ba76bf58 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -20,6 +20,7 @@ import { determineServerReadyAction, LaunchProfileCommandName, LaunchProfile, + LaunchProfileResult, expandEnvironmentVariables } from '../launchProfiles'; import { AspireDebugSession } from '../AspireDebugSession'; @@ -36,14 +37,29 @@ interface DotNetAttachDebuggerResourceInfo { projectPath: string; resourceLabel: string; reportedTargetName: string | undefined; + selectedLaunchProfile: SelectedLaunchProfile; } +// What the resource snapshot says about the launch profile the AppHost applied. +// 'named' - the AppHost reported the profile it selected, which is not necessarily the file's default. +// 'none' - the AppHost reported that no profile applies (ExcludeLaunchProfile, or no profile matched). +// 'unknown' - the AppHost never reported the property, so the file's default has to be inferred instead. +type SelectedLaunchProfile = + | { kind: 'named'; name: string } + | { kind: 'none' } + | { kind: 'unknown' }; + const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; // Well-known snapshot property added by the AppHost SDK target-name contract. // It carries the MSBuild-evaluated `TargetName`, which is the process name the C# debugger attaches to. const projectTargetNamePropertyName = 'project.targetName'; +// Carries the name of the launch profile the AppHost actually applied to this resource, which the +// AppHost resolves itself (ResourceSnapshotBuilder -> GetEffectiveLaunchProfile). It is published with a +// null value when no profile applies, so key presence - not just the value - distinguishes "no profile" +// from an AppHost too old to report the property at all. +const projectLaunchProfilePropertyName = 'project.launchProfile'; const resourceParentNamePropertyName = 'resource.parentName'; const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); @@ -432,9 +448,44 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho projectPath, resourceLabel: resource.displayName ?? resource.name, reportedTargetName: getReportedTargetName(resource), + selectedLaunchProfile: getSelectedLaunchProfile(resource), }; } +function getSelectedLaunchProfile(resource: DebuggableResourceSnapshot): SelectedLaunchProfile { + const properties = resource.properties; + if (!properties || !(projectLaunchProfilePropertyName in properties)) { + return { kind: 'unknown' }; + } + + const value: unknown = properties[projectLaunchProfilePropertyName]; + if (typeof value !== 'string' || value.trim().length === 0) { + return { kind: 'none' }; + } + + return { kind: 'named', name: value }; +} + +// Resolves the launch profile that governs how this resource was started. The AppHost-reported profile +// wins because it is the one that was actually applied; only an AppHost that never reported it falls back +// to inferring the file's default, which is what `dotnet run` would have picked. +async function resolveEffectiveLaunchProfile(attachInfo: DotNetAttachDebuggerResourceInfo): Promise { + if (attachInfo.selectedLaunchProfile.kind === 'none') { + return { profile: null, profileName: null }; + } + + const launchSettings = await readLaunchSettings(attachInfo.projectPath); + if (attachInfo.selectedLaunchProfile.kind === 'named') { + const profileName = attachInfo.selectedLaunchProfile.name; + // Match the SDK's ordinal, case-sensitive profile lookup so a profile that differs only in casing + // is treated as absent here exactly as it would be by `dotnet run`. + const profile = launchSettings?.profiles?.[profileName] ?? null; + return profile ? { profile, profileName } : { profile: null, profileName: null }; + } + + return determineDefaultLaunchProfile(launchSettings); +} + function getResourceParentName(resource: DebuggableResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceParentNamePropertyName]; return typeof value === 'string' ? value : null; @@ -487,14 +538,11 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR // An Executable profile does not run the project's own output: the extension launches the profile's // executablePath with its commandLineArgs (see configureExecutableLaunchProfile), so a process named // after the project's TargetName was never started and attaching by that name would find nothing. - // Only the default profile can be checked - the resource contract does not carry which profile the - // AppHost actually selected - so this refuses the case where nothing else was chosen and explains why, - // rather than offering an attach that cannot succeed. - const defaultLaunchProfile = determineDefaultLaunchProfile(await readLaunchSettings(attachInfo.projectPath)); - if (defaultLaunchProfile.profile?.commandName === LaunchProfileCommandName.executable) { + const effectiveLaunchProfile = await resolveEffectiveLaunchProfile(attachInfo); + if (effectiveLaunchProfile.profile?.commandName === LaunchProfileCommandName.executable) { throw new AttachDebuggerConfigurationError( 'ResourceNotAttachable', - attachDebuggerExecutableLaunchProfile(attachInfo.resourceLabel, defaultLaunchProfile.profileName ?? '')); + attachDebuggerExecutableLaunchProfile(attachInfo.resourceLabel, effectiveLaunchProfile.profileName ?? '')); } let processName = attachInfo.reportedTargetName; diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 5406f064634..eedf3ba328a 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -224,6 +224,103 @@ suite('Dotnet Debugger Extension Tests', () => { } }); + test('attach configuration refuses the launch profile the AppHost actually selected', async () => { + const fs = require('fs'); + const path = require('path'); + + // The file's default profile is a Project profile, so inferring the default here would happily + // offer an attach. The AppHost reported that it applied the later Executable profile instead, and + // that is the one that decides what process is running. + const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-selected-executable-profile'); + const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); + fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ + profiles: { + 'http': { + commandName: 'Project', + }, + 'run-as-executable': { + commandName: 'Executable', + executablePath: 'dotnet', + commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', + }, + }, + })); + + try { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'my-function', + displayName: 'My Function', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), + 'project.targetName': 'MyClassLibFunction', + 'project.launchProfile': 'run-as-executable', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' + && error.message === attachDebuggerExecutableLaunchProfile('My Function', 'run-as-executable')); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + } + finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + test('attach configuration is offered when the AppHost reports that no launch profile applies', async () => { + const fs = require('fs'); + const path = require('path'); + + // WithExcludeLaunchProfile leaves the project running its own output no matter what the file says, + // so refusing here because the file's default happens to be an Executable profile would take attach + // away from a resource that is perfectly attachable. + const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-excluded-launch-profile'); + const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); + fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ + profiles: { + 'run-as-executable': { + commandName: 'Executable', + executablePath: 'dotnet', + commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', + }, + }, + })); + + try { + const { extension } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); + + const debugConfiguration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'my-function', + displayName: 'My Function', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), + 'project.targetName': 'MyClassLibFunction', + 'project.launchProfile': null, + }, + }); + + assert.strictEqual(debugConfiguration.request, 'attach'); + assert.strictEqual(debugConfiguration.processName, 'MyClassLibFunction'); + } + finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + test('failed AppHost start writes error to debug console', async () => { const parentDebugSession = { id: 'aspire-session', From ae70e339ae4a41e4360641819e0e5c6dc9d8da1f Mon Sep 17 00:00:00 2001 From: adamint Date: Mon, 10 Aug 2026 00:23:40 -0400 Subject: [PATCH 29/90] Fail closed when the reported launch profile cannot be read The AppHost only publishes project.launchProfile for a profile it resolved at startup, so not finding that profile now means launchSettings.json changed or stopped parsing since the resource started. Treating that as 'no profile applies' bypassed the Executable guard and offered an attach by TargetName even though the running process may be whatever the original profile launched. The commandName that decides this is no longer knowable, so the attach is refused with an explanation instead of guessing a Project launch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/loc/xlf/aspire-vscode.xlf | 3 ++ extension/package.nls.json | 1 + extension/src/debugger/languages/dotnet.ts | 15 ++++++- extension/src/loc/strings.ts | 1 + extension/src/test/dotnetDebugger.test.ts | 49 +++++++++++++++++++++- 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index af8024212df..71a832c9094 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -868,6 +868,9 @@ {0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead. + + {0} was started with the '{1}' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead. + {0} · {1} diff --git a/extension/package.nls.json b/extension/package.nls.json index 649b21002cf..e2c51a12ec6 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -283,6 +283,7 @@ "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", "aspire-vscode.strings.attachDebuggerProcessNameUnresolved": "Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.", "aspire-vscode.strings.attachDebuggerExecutableLaunchProfile": "{0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead.", + "aspire-vscode.strings.attachDebuggerUnresolvedLaunchProfile": "{0} was started with the '{1}' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead.", "aspire-vscode.strings.attachDebuggerTargetNameProbeAssumesDefaultConfiguration": "{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 4b3ba76bf58..3eed3a09acd 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile, attachDebuggerUnresolvedLaunchProfile } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -480,7 +480,18 @@ async function resolveEffectiveLaunchProfile(attachInfo: DotNetAttachDebuggerRes // Match the SDK's ordinal, case-sensitive profile lookup so a profile that differs only in casing // is treated as absent here exactly as it would be by `dotnet run`. const profile = launchSettings?.profiles?.[profileName] ?? null; - return profile ? { profile, profileName } : { profile: null, profileName: null }; + if (!profile) { + // The AppHost only reports a profile name it resolved at startup, so failing to find it now + // means launchSettings.json changed or stopped parsing since then. Its commandName is what + // decides whether the project's own output is running, and that answer is now unavailable - + // so this fails closed rather than assuming a Project launch and offering an attach that may + // target a process the profile never started. + throw new AttachDebuggerConfigurationError( + 'ResourceNotAttachable', + attachDebuggerUnresolvedLaunchProfile(attachInfo.resourceLabel, profileName)); + } + + return { profile, profileName }; } return determineDefaultLaunchProfile(launchSettings); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 8784718a654..7f16ae35ba6 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -141,6 +141,7 @@ export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); export const attachDebuggerProcessNameUnresolved = (resource: string, error: string) => vscode.l10n.t('Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.', resource, error); export const attachDebuggerExecutableLaunchProfile = (resource: string, profileName: string) => vscode.l10n.t('{0} runs through the \'{1}\' launch profile, which uses commandName \'Executable\', so the running process is the one that profile starts rather than the project\'s own output. Attach to it from the Run and Debug view instead.', resource, profileName); +export const attachDebuggerUnresolvedLaunchProfile = (resource: string, profileName: string) => vscode.l10n.t('{0} was started with the \'{1}\' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead.', resource, profileName); export const attachDebuggerTargetNameProbeAssumesDefaultConfiguration = (resource: string, projectPath: string) => vscode.l10n.t('{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.', resource, projectPath); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index eedf3ba328a..ce0f0754844 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -10,7 +10,7 @@ import * as io from '../utils/io'; import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import { extensionLogOutputChannel } from '../utils/logging'; -import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile } from '../loc/strings'; +import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile, attachDebuggerUnresolvedLaunchProfile } from '../loc/strings'; class TestDotNetService { private _hasDevKit: boolean; @@ -276,6 +276,53 @@ suite('Dotnet Debugger Extension Tests', () => { } }); + test('attach configuration refuses when the launch profile the AppHost reported can no longer be read', async () => { + const fs = require('fs'); + const path = require('path'); + + // The AppHost resolved 'run-as-executable' when it started the resource, so the file has changed + // since. Its commandName is what decides whether the project's own output is running, and it is no + // longer knowable, so the guard must not quietly fall through to a Project-style attach. + const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-unresolved-launch-profile'); + const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); + fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ + profiles: { + 'http': { + commandName: 'Project', + }, + }, + })); + + try { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'my-function', + displayName: 'My Function', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), + 'project.targetName': 'MyClassLibFunction', + 'project.launchProfile': 'run-as-executable', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' + && error.message === attachDebuggerUnresolvedLaunchProfile('My Function', 'run-as-executable')); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + } + finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + test('attach configuration is offered when the AppHost reports that no launch profile applies', async () => { const fs = require('fs'); const path = require('path'); From ddf77eee6fb1cdf31cce6ead4fca7044a02a1f7f Mon Sep 17 00:00:00 2001 From: adamint Date: Mon, 10 Aug 2026 00:52:35 -0400 Subject: [PATCH 30/90] Record why the probe follows reference metadata and skips parented projects Two review questions came back to trade-offs that are not visible from the code alone. The target-name probe passes SetConfiguration/SetPlatform because the seeding path reads the name off the output ResolveProjectReferences produced under exactly those properties, so probing under anything else would make the two paths disagree about the same reference. The attach gate skips any parented project because MAUI platform resources derive from ProjectResource and report resourceType 'Project', and nothing in the resource snapshot names the debugger a resource needs, so the only thing separating them from an ordinary project is the parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/src/debugger/languages/dotnet.ts | 9 +++++++++ .../build/Aspire.Hosting.AppHost.in.targets | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 3eed3a09acd..7ffe463e9e1 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -423,6 +423,15 @@ function configureDotNetRunDebugConfiguration( } function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { + // The parent check is deliberately broader than it needs to be. MAUI platform resources derive from + // ProjectResource (MauiMacCatalystPlatformResource : ProjectResource, IMauiPlatformResource), so they + // report resourceType 'Project' and are only distinguishable from an ordinary project by their parent - + // the launch configuration type that actually names them as MAUI ('maui', MauiPlatformHelper) never + // reaches the resource snapshot. Attaching coreclr by TargetName to an app running on a device or + // simulator would be wrong, so a project with a parent is skipped. The cost is that an ordinary project + // given a parent purely for grouping (WithParentRelationship) also loses the attach action; that is a + // missing menu entry rather than a debugger pointed at the wrong process, so it is the safer side to err + // on until the snapshot carries something that names the debugger a resource needs. if (resource.resourceType !== 'Project' || resource.state !== 'Running' || getResourceParentName(resource) !== null) { return undefined; } diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index d9fc196e311..a400457e6c6 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -214,6 +214,15 @@ risk, so the warnings after each probe say where a failure came from and that SkipAspireProjectResourceTargetName=true turns probing off entirely. --> + Date: Mon, 10 Aug 2026 00:55:11 -0400 Subject: [PATCH 31/90] Record why app host path lookup does not re-resolve Reviewers keep reading this next to findAppHostForResource and seeing an inconsistency. It is a split by caller: state-changing commands and attach already return early when the resource does not resolve, while View logs and Open terminal keep the remembered path because a resource missing from the current snapshot is usually a refresh window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- extension/src/views/resourceLookup.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extension/src/views/resourceLookup.ts b/extension/src/views/resourceLookup.ts index 4e647b299b5..ee84b4ec058 100644 --- a/extension/src/views/resourceLookup.ts +++ b/extension/src/views/resourceLookup.ts @@ -79,6 +79,13 @@ export function findAppHostForResource(repository: AppHostDataRepository, elemen : undefined; } +// Deliberately returns the remembered path without re-resolving it, which is the opposite of what +// findAppHostForResource does. The split is by what the caller then does: commands that change resource +// state (_runResourceCommand) and attach both require findLatestResourceForElement to resolve and return +// early when it does not, so a stale path never reaches them. The diagnostic callers - View logs and Open +// terminal - fall back to the remembered resource on purpose, because a resource missing from the current +// snapshot is usually a refresh window rather than a gone app host, and dropping the action there would +// break a routine command far more often than it would prevent reading the wrong app host's output. export function getAppHostPathForResource(repository: AppHostDataRepository, element: ResourceElementRef): string | undefined { return element.appHostPath ?? findAppHostForResource(repository, element)?.appHostPath ?? repository.workspaceAppHostPath; } From 9b8a4e1b5b586a80ca076fff81f6d02583606a8d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 01:40:22 -0400 Subject: [PATCH 32/90] Fix stale AppHost path resource actions Validate cached AppHost paths before terminal-backed resource actions pass them to the CLI, and cover the ambiguous cached-path case for logs and terminal actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- extension/src/test/appHostTreeView.test.ts | 40 +++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 9 +++++ extension/src/views/resourceLookup.ts | 38 ++++++++++++++---- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index bc8f5db433f..fcc530808ab 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -3412,6 +3412,46 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('workspace resource terminal actions abort when cached AppHost path becomes ambiguous', async () => { + const commands: AspireSubcommand[] = []; + const appHostPath = '/repo/apps/Store/AppHost.csproj'; + const appHosts = [ + makeAppHost({ appHostPath, appHostPid: 1234, resources: [makeResource({ name: 'cache', displayName: 'cache' })] }), + ]; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts, + workspaceResources: [], + workspaceAppHost: undefined, + workspaceAppHostPath: undefined, + workspaceAppHostName: undefined, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostDescription: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const terminalProvider = { + getAspireCliExecutablePath: async () => 'aspire', + createEnvironment: () => ({}), + sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), + } as unknown as AspireTerminalProvider; + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + + const [runningAppHostItem] = provider.getChildren(); + const resourceItem = provider.getChildren(runningAppHostItem)[0]; + appHosts.splice( + 0, + appHosts.length, + makeAppHost({ appHostPath, appHostPid: 5678, resources: [makeResource({ name: 'cache' })] }), + makeAppHost({ appHostPath, appHostPid: 9012, resources: [makeResource({ name: 'cache' })] })); + + await provider.viewResourceLogs(resourceItem as any); + await provider.openResourceTerminal(resourceItem as any); + + assert.deepStrictEqual(commands, []); + provider.dispose(); + }); + test('openResourceTerminal adds replica when terminal metadata includes index', async () => { const commands: AspireSubcommand[] = []; const appHostPath = '/repo/apps/Store/AppHost.csproj'; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index ea3ca2014e7..a774cc84388 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1539,6 +1539,9 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider = ['terminal', 'attach', shellArg(latestResource.name)]; const appHostPath = getAppHostPathForResource(this._repository, element); + if (element.appHostPath && appHostPath === undefined) { + return; + } if (appHostPath) { command.push('--apphost', shellArg(appHostPath)); } @@ -1759,6 +1765,9 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider isMatchingAppHostPath(appHost.appHostPath, elementAppHostPath)); + const appHostByPid = element.appHostPid !== null + ? matchingAppHosts.find(appHost => appHost.appHostPid === element.appHostPid) + : undefined; + + if (appHostByPid) { + return appHostByPid.appHostPath; + } + + if (matchingAppHosts.length === 1) { + return matchingAppHosts[0].appHostPath; + } + + if (matchingAppHosts.length > 1) { + return undefined; + } + + return selectedAppHostPath && isMatchingAppHostPath(elementAppHostPath, selectedAppHostPath) + ? selectedAppHostPath + : undefined; + } + + return findAppHostForResource(repository, element)?.appHostPath ?? selectedAppHostPath; } function hasNoResources(resources: readonly ResourceJson[] | null | undefined): boolean { From 9eeb2e5ce155dce7e6218149d18f4165e1943c27 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 02:12:34 -0400 Subject: [PATCH 33/90] Fix resolved AppHost target path TFM probe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- .../build/Aspire.Hosting.AppHost.in.targets | 23 +++++++++---- .../AppHostSdkTargetsTests.cs | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index a400457e6c6..2359f9d591f 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -173,17 +173,26 @@ Condition="'$(_AspireProjectReferencesResolved)' != 'true'" /> - + + <_AspireProjectResourceResolvedTargetPathProbe Update="@(_AspireProjectResourceResolvedTargetPathProbe)"> + %(_AspireProjectResourceResolvedTargetPathProbe.GlobalPropertiesToRemove) + + $([System.Text.RegularExpressions.Regex]::Replace('%(_AspireProjectResourceResolvedTargetPathProbe.GlobalPropertiesToRemove)', '(?i)(^|;)\s*TargetFramework\s*(?=;|$)', '$1')) + TargetFramework;RuntimeIdentifier;SelfContained + RuntimeIdentifier;SelfContained + + + diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index f276201ba7a..28aeccc9b83 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -716,6 +716,39 @@ public async Task ProjectResourcesRoutingTheirOutputElsewhereDoNotFailTheBuildTh Assert.Equal(""" public string? TargetName => @"RoutedWorker";""", GetGeneratedTargetNameMember(generatedSource)); } + [Fact] + public async Task ProjectResourcesRoutingTheirOutputElsewhereUseTheResolvedTargetFramework() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Worker keeps a caller-supplied OutputItemType, so ResolveReferences builds the selected inner TFM but + // Aspire cannot seed the target name from @(_AspireProjectResourceBuildOutput). The resolved-reference + // GetTargetPath shortcut still has to use the same SetTargetFramework metadata as ResolveProjectReferences; + // otherwise it asks the outer build/default TFM for TargetName and can publish a debugger hint for the + // wrong process. + var result = await RunProjectMetadataSourceGenerationAsync( + workspace, + referencedProjectXml: """ + + Exe + net8.0;net9.0 + Eight Routed Worker + Nine Routed Worker + + """, + projectReferenceMetadataXml: """ + TargetFramework=net9.0 + TargetFramework + SomeoneElsesItem + """, + msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); + + Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); + + var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); + Assert.Equal(""" public string? TargetName => @"Nine Routed Worker";""", GetGeneratedTargetNameMember(generatedSource)); + } + [Fact] public async Task GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt() { From b05e5c6d6f1cfdf65b1b7bc00708227ac22fd787 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 02:56:42 -0400 Subject: [PATCH 34/90] Revert AppHost target probe change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- .../build/Aspire.Hosting.AppHost.in.targets | 23 ++++--------- .../AppHostSdkTargetsTests.cs | 33 ------------------- 2 files changed, 7 insertions(+), 49 deletions(-) diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index 2359f9d591f..a400457e6c6 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -173,26 +173,17 @@ Condition="'$(_AspireProjectReferencesResolved)' != 'true'" /> - - <_AspireProjectResourceResolvedTargetPathProbe Update="@(_AspireProjectResourceResolvedTargetPathProbe)"> - %(_AspireProjectResourceResolvedTargetPathProbe.GlobalPropertiesToRemove) - - $([System.Text.RegularExpressions.Regex]::Replace('%(_AspireProjectResourceResolvedTargetPathProbe.GlobalPropertiesToRemove)', '(?i)(^|;)\s*TargetFramework\s*(?=;|$)', '$1')) - TargetFramework;RuntimeIdentifier;SelfContained - RuntimeIdentifier;SelfContained - - - + diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 28aeccc9b83..f276201ba7a 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -716,39 +716,6 @@ public async Task ProjectResourcesRoutingTheirOutputElsewhereDoNotFailTheBuildTh Assert.Equal(""" public string? TargetName => @"RoutedWorker";""", GetGeneratedTargetNameMember(generatedSource)); } - [Fact] - public async Task ProjectResourcesRoutingTheirOutputElsewhereUseTheResolvedTargetFramework() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // Worker keeps a caller-supplied OutputItemType, so ResolveReferences builds the selected inner TFM but - // Aspire cannot seed the target name from @(_AspireProjectResourceBuildOutput). The resolved-reference - // GetTargetPath shortcut still has to use the same SetTargetFramework metadata as ResolveProjectReferences; - // otherwise it asks the outer build/default TFM for TargetName and can publish a debugger hint for the - // wrong process. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0;net9.0 - Eight Routed Worker - Nine Routed Worker - - """, - projectReferenceMetadataXml: """ - TargetFramework=net9.0 - TargetFramework - SomeoneElsesItem - """, - msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - Assert.Equal(""" public string? TargetName => @"Nine Routed Worker";""", GetGeneratedTargetNameMember(generatedSource)); - } - [Fact] public async Task GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt() { From 6f72f5be54018077f2145370ddfa0014b8daac2f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 11:15:33 -0400 Subject: [PATCH 35/90] Attach to the resource process directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- extension/CHANGELOG.md | 1 - extension/loc/xlf/aspire-vscode.xlf | 30 - extension/package.json | 2 +- extension/package.nls.json | 4 - .../src/dcp/DashboardTelemetryPassthrough.ts | 9 +- extension/src/debugger/debuggerExtensions.ts | 2 +- extension/src/debugger/languages/dotnet.ts | 138 +-- extension/src/loc/strings.ts | 4 - .../src/test-e2e/packageSurface.e2e.test.ts | 3 - extension/src/test/appHostTreeView.test.ts | 923 +++--------------- .../src/test/dashboardTelemetryRoutes.test.ts | 92 -- extension/src/test/dotnetDebugger.test.ts | 268 +---- extension/src/test/resourceLookup.test.ts | 68 -- extension/src/test/telemetry.test.ts | 64 -- .../src/views/AspireAppHostTreeProvider.ts | 90 +- extension/src/views/resourceLookup.ts | 117 --- src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets | 10 - .../DotNetBasedAppHostServerProject.cs | 5 - .../build/Aspire.Hosting.AppHost.in.targets | 315 +----- .../ResourcePropertySnapshotMetadata.cs | 1 - .../Dcp/ResourceSnapshotBuilder.cs | 45 +- src/Aspire.Hosting/IProjectMetadata.cs | 19 - .../Resources/MessageStrings.Designer.cs | 9 - .../Resources/MessageStrings.resx | 3 - .../Resources/xlf/MessageStrings.cs.xlf | 5 - .../Resources/xlf/MessageStrings.de.xlf | 5 - .../Resources/xlf/MessageStrings.es.xlf | 5 - .../Resources/xlf/MessageStrings.fr.xlf | 5 - .../Resources/xlf/MessageStrings.it.xlf | 5 - .../Resources/xlf/MessageStrings.ja.xlf | 5 - .../Resources/xlf/MessageStrings.ko.xlf | 5 - .../Resources/xlf/MessageStrings.pl.xlf | 5 - .../Resources/xlf/MessageStrings.pt-BR.xlf | 5 - .../Resources/xlf/MessageStrings.ru.xlf | 5 - .../Resources/xlf/MessageStrings.tr.xlf | 5 - .../Resources/xlf/MessageStrings.zh-Hans.xlf | 5 - .../Resources/xlf/MessageStrings.zh-Hant.xlf | 5 - src/Shared/Model/KnownProperties.cs | 7 - .../ResourceSnapshotMapperTests.cs | 28 - .../Model/KnownPropertyLookupTests.cs | 1 - .../AppHostSdkTargetsTests.cs | 887 ----------------- .../Dcp/ResourceSnapshotBuilderTests.cs | 164 +--- .../ProjectResourceBuilderExtensionTests.cs | 21 - ...Tests.ValidateMetadataSources.verified.txt | 11 - 44 files changed, 202 insertions(+), 3204 deletions(-) delete mode 100644 extension/src/test/resourceLookup.test.ts delete mode 100644 extension/src/views/resourceLookup.ts diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index eb13dde67b6..3fdd0241c66 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -10,7 +10,6 @@ ### Fixes -- Emit VS Code extension and dashboard telemetry with the `aspire/vscode/*` and `aspire/dashboard/*` wire names expected by downstream Aspire telemetry queries ([#18602](https://github.com/microsoft/aspire/pull/18602)). - Fix the Get Started walkthrough's Install Aspire CLI step to use a package-manager picker (WinGet, Homebrew, npm, .NET tool, mise) instead of shell-specific piped scripts, resolving failures on Windows when the default shell is `cmd.exe` ([#18459](https://github.com/microsoft/aspire/issues/18459), [#18522](https://github.com/microsoft/aspire/pull/18522)). - Fix stale global AppHosts appearing in the Aspire pane when switching back to a workspace view; global AppHosts are now cleared and re-filtered immediately on view switch ([#18506](https://github.com/microsoft/aspire/issues/18506), [#18516](https://github.com/microsoft/aspire/pull/18516)). diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 71a832c9094..e5addab3b88 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -79,12 +79,6 @@ Aspire: Launch default AppHost - - Attach debugger - - - Attach debugger: {0} - Attempted to start unsupported resource type: {0}. @@ -181,9 +175,6 @@ Could not determine the AppHost source file to open. - - Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually. - Create a new project @@ -370,9 +361,6 @@ Install the Aspire CLI - - Install the C# extension to attach the debugger to .NET project resources. - Invalid launch configuration for {0}. @@ -739,9 +727,6 @@ The pipeline step name to execute when command is 'do' - - The selected resource is no longer available. Refresh the Aspire pane and try again. - This command has dynamic inputs that the Aspire extension cannot prompt for yet. Run it from the Aspire Dashboard or Aspire CLI instead. @@ -751,9 +736,6 @@ This field is required. - - This resource is not a running .NET project resource that can be attached with the C# debugger. - This setting has been renamed to aspire.appHostsPollingInterval. @@ -784,9 +766,6 @@ VS Code did not start the Aspire {0} session for {1}. - - VS Code did not start the debugger attach session for {0}. - Value missing @@ -859,18 +838,9 @@ timed out after {0}ms - - {0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name. - {0} exited with code {1}{2} - - {0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead. - - - {0} was started with the '{1}' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead. - {0} · {1} diff --git a/extension/package.json b/extension/package.json index 963a0a8c73b..fdb45a2fed7 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1120,7 +1120,7 @@ "uuid": "14.0.0", "tmp": "0.2.7", "@nevware21/ts-utils": "0.14.0", - "fast-uri": "3.1.4", + "fast-uri": "3.1.5", "qs": "6.15.2", "ws": "8.21.0", "js-yaml": "4.3.0", diff --git a/extension/package.nls.json b/extension/package.nls.json index e2c51a12ec6..831facf0f0c 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -281,10 +281,6 @@ "aspire-vscode.strings.attachDebuggerResourceNotFound": "The selected resource is no longer available. Refresh the Aspire pane and try again.", "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", - "aspire-vscode.strings.attachDebuggerProcessNameUnresolved": "Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.", - "aspire-vscode.strings.attachDebuggerExecutableLaunchProfile": "{0} runs through the '{1}' launch profile, which uses commandName 'Executable', so the running process is the one that profile starts rather than the project's own output. Attach to it from the Run and Debug view instead.", - "aspire-vscode.strings.attachDebuggerUnresolvedLaunchProfile": "{0} was started with the '{1}' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead.", - "aspire-vscode.strings.attachDebuggerTargetNameProbeAssumesDefaultConfiguration": "{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", "aspire-vscode.strings.workspaceViewSelectedSingleAppHostWithLanguage": "Workspace view selected because aspire ls found one buildable {0} AppHost.", diff --git a/extension/src/dcp/DashboardTelemetryPassthrough.ts b/extension/src/dcp/DashboardTelemetryPassthrough.ts index 924cfbec51e..e68bdf53b95 100644 --- a/extension/src/dcp/DashboardTelemetryPassthrough.ts +++ b/extension/src/dcp/DashboardTelemetryPassthrough.ts @@ -1028,16 +1028,9 @@ function sanitizeDashboardStringValue(value: string): string { /(key|token|sig|secret|signature|password|passwd|pwd|android:value)[^A-Za-z0-9]/i.test(boundedValue) || /\b(?:login|psexec|certutil(?:\.exe)?|net(?:\.exe)?\s+(?:user|share)|user\s+-?\s*secrets\s+set)\b/i.test(boundedValue) || /(?:^|[\s\r\n\\])net(?:\.exe)?.{1,5}(?:user|share)\b/i.test(boundedValue); - // Treat dashboard leaf values that start like private locations as unsafe, including - // UNC forms such as \\server\share, \\?\UNC\server\share, and //server/share. - // The share segment is optional on both spellings: \\server and //server name an internal - // host on their own, and the backslash alternative below already matched the host-only form, - // so requiring a share on the forward-slash form would redact one spelling of a private host - // name and pass the other through. A leading // followed by anything other than a separator or - // whitespace is redacted, which also covers //server/share because the host matches first. const containsPrivateLocation = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(boundedValue) || - /(?:^|[\s"'(])(?:[A-Za-z]:[\\/]|\\\\|\/\/[^/\s]+|~[\\/]|\/(?:[^/\s]+[\\/])|\.\.?[\\/])/.test(boundedValue) || + /(?:^|[\s"'(])(?:[A-Za-z]:[\\/]|\\\\|~[\\/]|\/(?:[^/\s]+[\\/])|\.\.?[\\/])/.test(boundedValue) || /(?:^|[\s"'(])(?:(?:[^\\/\s"'()]+[\\/]){2,}[^\\/\s"'()]+|(?:[^\\/\s"'()]+[\\/])+[^\\/\s"'()]+\.(?:cs|fs|vb|ts|js|json|xml|props|targets|sln|slnx))\b/i.test(boundedValue); const containsEmail = /@[A-Za-z0-9-]+\.[A-Za-z0-9-]+/.test(boundedValue); diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 1576fdc489f..7e1112f86eb 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -24,7 +24,7 @@ export interface DebuggableResourceSnapshot { properties: Record | null; } -export type AttachDebuggerConfigurationErrorKind = 'ResourceNotAttachable' | 'ProcessNameUnresolved'; +export type AttachDebuggerConfigurationErrorKind = 'ResourceNotAttachable'; export class AttachDebuggerConfigurationError extends Error { constructor(public readonly errorKind: AttachDebuggerConfigurationErrorKind, message: string) { diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 7ffe463e9e1..656cb3fb7bd 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerProcessNameUnresolved, attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile, attachDebuggerUnresolvedLaunchProfile } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -20,7 +20,6 @@ import { determineServerReadyAction, LaunchProfileCommandName, LaunchProfile, - LaunchProfileResult, expandEnvironmentVariables } from '../launchProfiles'; import { AspireDebugSession } from '../AspireDebugSession'; @@ -34,32 +33,13 @@ interface IDotNetService { } interface DotNetAttachDebuggerResourceInfo { - projectPath: string; + processId: number; resourceLabel: string; - reportedTargetName: string | undefined; - selectedLaunchProfile: SelectedLaunchProfile; } -// What the resource snapshot says about the launch profile the AppHost applied. -// 'named' - the AppHost reported the profile it selected, which is not necessarily the file's default. -// 'none' - the AppHost reported that no profile applies (ExcludeLaunchProfile, or no profile matched). -// 'unknown' - the AppHost never reported the property, so the file's default has to be inferred instead. -type SelectedLaunchProfile = - | { kind: 'named'; name: string } - | { kind: 'none' } - | { kind: 'unknown' }; - const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; -// Well-known snapshot property added by the AppHost SDK target-name contract. -// It carries the MSBuild-evaluated `TargetName`, which is the process name the C# debugger attaches to. -const projectTargetNamePropertyName = 'project.targetName'; -// Carries the name of the launch profile the AppHost actually applied to this resource, which the -// AppHost resolves itself (ResourceSnapshotBuilder -> GetEffectiveLaunchProfile). It is published with a -// null value when no profile applies, so key presence - not just the value - distinguishes "no profile" -// from an AppHost too old to report the property at all. -const projectLaunchProfilePropertyName = 'project.launchProfile'; const resourceParentNamePropertyName = 'resource.parentName'; const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); @@ -436,7 +416,8 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho return undefined; } - if (getAttachDebuggerProcessId(resource) === undefined) { + const processId = getAttachDebuggerProcessId(resource); + if (processId === undefined) { return undefined; } @@ -454,73 +435,16 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho } return { - projectPath, + processId, resourceLabel: resource.displayName ?? resource.name, - reportedTargetName: getReportedTargetName(resource), - selectedLaunchProfile: getSelectedLaunchProfile(resource), }; } -function getSelectedLaunchProfile(resource: DebuggableResourceSnapshot): SelectedLaunchProfile { - const properties = resource.properties; - if (!properties || !(projectLaunchProfilePropertyName in properties)) { - return { kind: 'unknown' }; - } - - const value: unknown = properties[projectLaunchProfilePropertyName]; - if (typeof value !== 'string' || value.trim().length === 0) { - return { kind: 'none' }; - } - - return { kind: 'named', name: value }; -} - -// Resolves the launch profile that governs how this resource was started. The AppHost-reported profile -// wins because it is the one that was actually applied; only an AppHost that never reported it falls back -// to inferring the file's default, which is what `dotnet run` would have picked. -async function resolveEffectiveLaunchProfile(attachInfo: DotNetAttachDebuggerResourceInfo): Promise { - if (attachInfo.selectedLaunchProfile.kind === 'none') { - return { profile: null, profileName: null }; - } - - const launchSettings = await readLaunchSettings(attachInfo.projectPath); - if (attachInfo.selectedLaunchProfile.kind === 'named') { - const profileName = attachInfo.selectedLaunchProfile.name; - // Match the SDK's ordinal, case-sensitive profile lookup so a profile that differs only in casing - // is treated as absent here exactly as it would be by `dotnet run`. - const profile = launchSettings?.profiles?.[profileName] ?? null; - if (!profile) { - // The AppHost only reports a profile name it resolved at startup, so failing to find it now - // means launchSettings.json changed or stopped parsing since then. Its commandName is what - // decides whether the project's own output is running, and that answer is now unavailable - - // so this fails closed rather than assuming a Project launch and offering an attach that may - // target a process the profile never started. - throw new AttachDebuggerConfigurationError( - 'ResourceNotAttachable', - attachDebuggerUnresolvedLaunchProfile(attachInfo.resourceLabel, profileName)); - } - - return { profile, profileName }; - } - - return determineDefaultLaunchProfile(launchSettings); -} - function getResourceParentName(resource: DebuggableResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceParentNamePropertyName]; return typeof value === 'string' ? value : null; } -function getReportedTargetName(resource: DebuggableResourceSnapshot): string | undefined { - const value: unknown = resource.properties?.[projectTargetNamePropertyName]; - if (typeof value !== 'string') { - return undefined; - } - - const targetName = value.trim(); - return targetName.length > 0 ? targetName : undefined; -} - function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): number | undefined { const value: unknown = resource.properties?.[executablePidPropertyName]; if (typeof value === 'number' && Number.isInteger(value) && value > 0) { @@ -549,64 +473,20 @@ function isDotNetExecutable(resource: DebuggableResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, dotNetService: IDotNetService): Promise { +function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot): vscode.DebugConfiguration { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } - // An Executable profile does not run the project's own output: the extension launches the profile's - // executablePath with its commandLineArgs (see configureExecutableLaunchProfile), so a process named - // after the project's TargetName was never started and attaching by that name would find nothing. - const effectiveLaunchProfile = await resolveEffectiveLaunchProfile(attachInfo); - if (effectiveLaunchProfile.profile?.commandName === LaunchProfileCommandName.executable) { - throw new AttachDebuggerConfigurationError( - 'ResourceNotAttachable', - attachDebuggerExecutableLaunchProfile(attachInfo.resourceLabel, effectiveLaunchProfile.profileName ?? '')); - } - - let processName = attachInfo.reportedTargetName; - if (processName === undefined) { - processName = await getProcessNameFromTargetPath(attachInfo.projectPath, attachInfo.resourceLabel, dotNetService); - } - return { type: 'coreclr', request: 'attach', name: attachDebuggerConfigurationName(attachInfo.resourceLabel), - processName, + processId: String(attachInfo.processId), }; } -async function getProcessNameFromTargetPath(projectPath: string, resourceLabel: string, dotNetService: IDotNetService): Promise { - // This probe evaluates the project with MSBuild's default global properties, so it answers for the - // project's default configuration rather than the one the AppHost is running. That is only wrong for - // a project whose assembly name is conditioned on Configuration, and it cannot be made right here: - // the resource contract of an AppHost old enough to omit project.targetName carries no configuration, - // and executable.args - the one property that would name the running assembly outright - is published - // as sensitive and redacted to null before the extension ever sees it (AuxiliaryBackchannelRpcTarget - // replaces every IsSensitive value with null). Failing closed instead would remove attach support from - // exactly the AppHosts this fallback exists to serve, so the probe stays best effort and says so. - extensionLogOutputChannel.warn(attachDebuggerTargetNameProbeAssumesDefaultConfiguration(resourceLabel, projectPath)); - - try { - const targetPath = await dotNetService.getDotNetTargetPath(projectPath); - const fileName = targetPath.trim().split(/[\\/]/).pop() ?? ''; - const processName = fileName.replace(/\.(dll|exe)$/i, ''); - if (processName.length === 0) { - throw new Error(noOutputFromMsbuild); - } - - return processName; - } catch (error) { - throw new AttachDebuggerConfigurationError('ProcessNameUnresolved', attachDebuggerProcessNameUnresolved(resourceLabel, getErrorMessage(error))); - } -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSession: AspireDebugSession | undefined) => IDotNetService): ResourceDebuggerExtension { return { resourceType: 'project', @@ -622,9 +502,7 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); }, canAttachToResource: (resource) => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, - createAttachDebugSessionConfigurationCallback: async (resource): Promise => { - return await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(undefined)); - }, + createAttachDebugSessionConfigurationCallback: async (resource): Promise => createDotNetAttachDebugSessionConfiguration(resource), createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 7f16ae35ba6..894ddaaadfc 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -139,10 +139,6 @@ export const attachDebuggerUnavailable = vscode.l10n.t('This resource is not a r export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); -export const attachDebuggerProcessNameUnresolved = (resource: string, error: string) => vscode.l10n.t('Could not determine the process to attach to for {0}: {1}. Check that the project can be evaluated by MSBuild, or start the debugger and attach manually.', resource, error); -export const attachDebuggerExecutableLaunchProfile = (resource: string, profileName: string) => vscode.l10n.t('{0} runs through the \'{1}\' launch profile, which uses commandName \'Executable\', so the running process is the one that profile starts rather than the project\'s own output. Attach to it from the Run and Debug view instead.', resource, profileName); -export const attachDebuggerUnresolvedLaunchProfile = (resource: string, profileName: string) => vscode.l10n.t('{0} was started with the \'{1}\' launch profile, which is no longer readable from its launchSettings.json, so what the running process is cannot be determined. Restore the profile or attach from the Run and Debug view instead.', resource, profileName); -export const attachDebuggerTargetNameProbeAssumesDefaultConfiguration = (resource: string, projectPath: string) => vscode.l10n.t('{0} does not report a project target name, so the process to attach to is being evaluated from {1} using its default configuration. If the assembly name depends on the build configuration, update the AppHost so it reports the target name.', resource, projectPath); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index ed84eae4d29..f00c844d4b4 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -98,7 +98,6 @@ suite('Aspire package contribution surface E2E', function () { 'aspire-vscode.openInIntegratedBrowser', 'aspire-vscode.copyEndpointUrl', 'aspire-vscode.openResourceTerminal', - 'aspire-vscode.attachDebuggerToResource', ]) { assert.ok(hiddenPaletteCommands.includes(commandId), `${commandId} should stay hidden from the command palette.`); } @@ -445,7 +444,6 @@ const expectedActivationEvents = [ const expectedCommandIds = [ 'aspire-vscode.add', - 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.codeLensDebugPipelineStep', 'aspire-vscode.codeLensOpenDashboard', 'aspire-vscode.codeLensResourceAction', @@ -537,7 +535,6 @@ const expectedViewItemContextCommands = [ 'aspire-vscode.restartResource', 'aspire-vscode.executeResourceCommand', 'aspire-vscode.executeResourceCommandItem', - 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.viewResourceLogs', 'aspire-vscode.openResourceTerminal', 'aspire-vscode.openInExternalBrowser', diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index fcc530808ab..43cb9771fa7 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -45,9 +45,7 @@ function makeAttachableProjectProperties(overrides: Record item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group'); + return provider.getChildren(resourcesGroup)[0]; +} + function getResourceCommandItems(provider: AspireAppHostTreeProvider): readonly vscode.TreeItem[] { const [appHostItem] = provider.getChildren(); const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); @@ -1247,98 +1252,6 @@ suite('AspireAppHostTreeProvider', () => { assert.strictEqual(infoStub.calledOnce, true); }); - test('resource command item checks the latest resource snapshot before executing', async () => { - const runResourceCommandCalls: Array<[string, string | undefined, string, readonly string[]]> = []; - const appHost = makeAppHost({ - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - commands: { - restart: { displayName: 'Restart', description: null }, - }, - }), - ], - }); - const repository = { - viewMode: 'global' as ViewMode, - appHosts: [appHost], - workspaceResources: [], - workspaceAppHostPath: undefined, - workspaceAppHostCandidatePaths: [], - workspaceAppHostName: undefined, - onDidChangeData: (() => ({ dispose: () => { } })) as vscode.Event, - runResourceCommand: async (resourceName: string, appHostPath: string | undefined, commandName: string, additionalArgs: readonly string[] = []) => { - runResourceCommandCalls.push([resourceName, appHostPath, commandName, additionalArgs]); - return { stdout: '', stderr: '' }; - }, - } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); - sandbox.stub(vscode.window, 'showInformationMessage'); - const [commandItem] = getResourceCommandItems(provider); - appHost.resources = [ - makeResource({ - name: 'api', - displayName: 'API v2', - commands: {}, - }), - ]; - - await provider.executeResourceCommandItem(commandItem as any); - - assert.deepStrictEqual(runResourceCommandCalls, []); - provider.dispose(); - }); - - test('workspace resource command item does not execute when the AppHost path changes', async () => { - const runResourceCommandCalls: Array<[string, string | undefined, string, readonly string[]]> = []; - const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); - const repository = { - viewMode: 'workspace' as ViewMode, - appHosts: [], - workspaceResources: [ - makeResource({ - name: 'api', - displayName: 'API', - commands: { - restart: { displayName: 'Restart', description: null }, - }, - }), - ], - workspaceAppHostPath: '/repo/AppHost/AppHost.csproj', - workspaceAppHostCandidatePaths: [], - workspaceAppHostName: 'AppHost.csproj', - workspaceAppHostDescription: undefined, - onDidChangeData, - runResourceCommand: async (resourceName: string, appHostPath: string | undefined, commandName: string, additionalArgs: readonly string[] = []) => { - runResourceCommandCalls.push([resourceName, appHostPath, commandName, additionalArgs]); - return { stdout: '', stderr: '' }; - }, - } as unknown as AppHostDataRepository & { workspaceResources: ResourceJson[]; workspaceAppHostPath: string }; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); - sandbox.stub(vscode.window, 'showInformationMessage'); - const [workspaceResourcesItem] = provider.getChildren(); - const [resourceItem] = provider.getChildren(workspaceResourcesItem); - const commandsGroup = provider.getChildren(resourceItem).find(item => item.contextValue === 'commandsGroup'); - assert.ok(commandsGroup, 'Expected commands group'); - const [commandItem] = provider.getChildren(commandsGroup); - repository.workspaceAppHostPath = '/repo/OtherAppHost/AppHost.csproj'; - repository.workspaceResources = [ - makeResource({ - name: 'api', - displayName: 'Other API', - commands: { - restart: { displayName: 'Restart', description: null }, - }, - }), - ]; - - await provider.executeResourceCommandItem(commandItem as any); - - assert.deepStrictEqual(runResourceCommandCalls, []); - provider.dispose(); - }); - test('resource command item returns failed execution outcome after reporting error', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => 'aspire', @@ -1682,27 +1595,27 @@ suite('resolveAppHostSourcePath', () => { suite('getResourceContextValue', () => { test('resource with no commands returns just "resource"', () => { - assert.strictEqual(getResourceContextValue(makeResource(), true), 'resource'); + assert.strictEqual(getResourceContextValue(makeResource()), 'resource'); }); test('resource with start command', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Enabled' } }, - }), true); + })); assert.strictEqual(result, 'resource:canStart'); }); test('resource with resource-start command', () => { const result = getResourceContextValue(makeResource({ commands: { 'resource-start': { displayName: null, description: null, state: 'Enabled' } }, - }), true); + })); assert.strictEqual(result, 'resource:canStart'); }); test('resource with stop command', () => { const result = getResourceContextValue(makeResource({ commands: { 'stop': { displayName: null, description: null, state: 'Enabled' } }, - }), true); + })); assert.strictEqual(result, 'resource:canStop'); }); @@ -1713,7 +1626,7 @@ suite('getResourceContextValue', () => { 'stop': { displayName: null, description: null, state: 'Enabled' }, 'restart': { displayName: null, description: null, state: 'Enabled' }, }, - }), true); + })); assert.strictEqual(result, 'resource:canStart:canStop:canRestart'); }); @@ -1722,14 +1635,14 @@ suite('getResourceContextValue', () => { commands: { 'restart': { displayName: null, description: null }, }, - }), true); + })); assert.strictEqual(result, 'resource:canRestart'); }); test('resource with non-lifecycle commands has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'custom-action': { displayName: null, description: 'do something' } }, - }), true); + })); assert.strictEqual(result, 'resource'); }); @@ -1739,14 +1652,14 @@ suite('getResourceContextValue', () => { 'restart': { displayName: null, description: null, state: 'Enabled' }, 'custom-action': { displayName: null, description: null, state: 'Enabled' }, }, - }), true); + })); assert.strictEqual(result, 'resource:canRestart'); }); test('resource with terminal enabled property includes terminal context', () => { const result = getResourceContextValue(makeResource({ properties: { 'terminal.enabled': 'true' }, - }), true); + })); assert.strictEqual(result, 'resource:canOpenTerminal'); }); @@ -1756,11 +1669,11 @@ suite('getResourceContextValue', () => { 'restart': { displayName: null, description: null, state: 'Enabled' }, }, properties: { 'terminal.enabled': 'true' }, - }), true); + })); assert.strictEqual(result, 'resource:canRestart:canOpenTerminal'); }); - test('running project resource with redacted launch args includes attach debugger context', () => { + test('running .NET project with a process ID includes attach debugger context', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Running, @@ -1769,39 +1682,16 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource:canAttachDebugger'); }); - test('running F# project resource includes attach debugger context', () => { - const result = getResourceContextValue(makeResource({ - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'project.path': '/repo/worker/Worker.fsproj', - }), - }), true); - assert.strictEqual(result, 'resource:canAttachDebugger'); - }); - - test('running project resource with process id excludes attach debugger context without C# debugger support', () => { - const result = getResourceContextValue(makeResource({ - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), false); - assert.strictEqual(result, 'resource'); - }); - - test('project resource without process id does not include attach debugger context', () => { + test('project without a process ID does not include attach debugger context', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Running, - properties: { - 'project.path': '/repo/api/api.csproj', - 'executable.path': 'dotnet', - }, + properties: makeAttachableProjectProperties({ 'executable.pid': null }), }), true); assert.strictEqual(result, 'resource'); }); - test('non-running project resource with process id does not include attach debugger context', () => { + test('stopped project does not include attach debugger context', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Finished, @@ -1810,60 +1700,26 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource'); }); - test('running project resource without project path does not include attach debugger context', () => { - const result = getResourceContextValue(makeResource({ - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'project.path': null, - }), - }), true); - assert.strictEqual(result, 'resource'); - }); - - test('running project resource without dotnet executable does not include attach debugger context', () => { - const result = getResourceContextValue(makeResource({ - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.path': 'func', - }), - }), true); - assert.strictEqual(result, 'resource'); - }); - - test('running child project resource does not include attach debugger context', () => { + test('running .NET project excludes attach debugger context without C# support', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'resource.parentName': 'maui', - }), - }), true); - assert.strictEqual(result, 'resource'); - }); - - test('executable resource with process id does not include attach debugger context', () => { - const result = getResourceContextValue(makeResource({ - resourceType: 'Executable', - properties: { - 'executable.pid': '4242', - }, - }), true); + properties: makeAttachableProjectProperties(), + }), false); assert.strictEqual(result, 'resource'); }); test('resource with disabled lifecycle command has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Disabled' } }, - }), true); + })); assert.strictEqual(result, 'resource'); }); test('resource with api-only lifecycle command has base context only', () => { const result = getResourceContextValue(makeResource({ commands: { 'start': { displayName: null, description: null, state: 'Enabled', visibility: 'Api' } }, - }), true); + })); assert.strictEqual(result, 'resource'); }); @@ -2178,6 +2034,16 @@ suite('buildResourceDescription', () => { }); suite('AspireAppHostTreeProvider.findAppHostElement', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + test('returns undefined when given empty path', () => { const provider = makeTreeProvider([makeAppHost({ appHostPath: '/repo/AppHost/AppHost.csproj' })]); assert.strictEqual(provider.findAppHostElement(''), undefined); @@ -2601,7 +2467,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('attachDebuggerToResource starts a coreclr attach session for a running project resource', async () => { + test('attachDebuggerToResource starts CoreCLR with the selected resource process ID', async () => { const provider = makeTreeProvider([ makeAppHost({ resources: [ @@ -2615,69 +2481,55 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); - await (provider as any).attachDebuggerToResource(resourceItem); + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); - assert.ok(startDebuggingStub.calledOnce, 'Expected VS Code to start one attach session'); - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.type, 'coreclr'); - assert.strictEqual(configuration.request, 'attach'); - assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processName, 'api'); - assert.strictEqual(configuration.processId, undefined); - assert.strictEqual(configuration.cwd, undefined); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.type, 'coreclr'); + assert.strictEqual(configuration.request, 'attach'); + assert.strictEqual(configuration.name, 'Attach debugger: API'); + assert.strictEqual(configuration.processId, '4242'); + assert.strictEqual(configuration.processName, undefined); + provider.dispose(); }); - test('attachDebuggerToResource throws when VS Code declines to start the attach session', async () => { - const provider = makeTreeProvider([ - makeAppHost({ - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], + test('attachDebuggerToResource uses the latest resource process ID', async () => { + const appHost = makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }); + const provider = makeTreeProvider([appHost]); + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const resourceItem = getFirstResourceItem(provider); + appHost.resources = [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '5252' }), }), - ]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(false); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + ]; - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); + await (provider as any).attachDebuggerToResource(resourceItem); - await assert.rejects( - (provider as any).attachDebuggerToResource(resourceItem), - (error: unknown) => error instanceof Error && error.name === 'StartDebuggingDeclined'); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } + const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(configuration.processId, '5252'); + provider.dispose(); }); - test('attachDebuggerToResource propagates VS Code attach errors', async () => { - const provider = makeTreeProvider([ + test('attachDebuggerToResource rejects a resource removed before invocation', async () => { + const appHosts = [ makeAppHost({ resources: [ makeResource({ @@ -2689,29 +2541,23 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }), - ]); - const attachError = new Error('Adapter failed'); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').rejects(attachError); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + ]; + const provider = makeTreeProvider(appHosts); + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceItem = getFirstResourceItem(provider); + appHosts.length = 0; - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - await assert.rejects( - (provider as any).attachDebuggerToResource(resourceItem), - (error: unknown) => error === attachError); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); + assert.ok(startDebuggingStub.notCalled); + assert.ok(warningStub.calledOnce); + provider.dispose(); }); - test('attachDebuggerToResource uses the latest resource snapshot', async () => { + test('attachDebuggerToResource rejects a resource that is no longer attachable', async () => { const appHost = makeAppHost({ resources: [ makeResource({ @@ -2724,45 +2570,31 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }); const provider = makeTreeProvider([appHost]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - appHost.resources = [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.pid': '5252', - 'project.path': '/repo/api-v2/api-v2.csproj', - 'project.targetName': 'api-v2', - }), - }), - ]; + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceItem = getFirstResourceItem(provider); + appHost.resources = [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Finished, + properties: makeAttachableProjectProperties(), + }), + ]; - await (provider as any).attachDebuggerToResource(resourceItem); + const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.processName, 'api-v2'); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(startDebuggingStub.notCalled); + assert.ok(warningStub.calledOnce); + provider.dispose(); }); - test('attachDebuggerToResource resolves latest resource after AppHost process changes', async () => { - const appHosts = [ + test('attachDebuggerToResource reports missing C# debugger support', async () => { + const provider = makeTreeProvider([ makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 1234, resources: [ makeResource({ name: 'api', @@ -2773,487 +2605,42 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }), - ]; - const provider = makeTreeProvider(appHosts); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); + ]); + sandbox.stub(capabilities, 'isCsharpInstalled').returns(false); + const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - appHosts[0] = makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 5678, + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'CSharpExtensionMissing' }); + assert.ok(startDebuggingStub.notCalled); + assert.ok(warningStub.calledOnce); + provider.dispose(); + }); + + test('attachDebuggerToResource reports when VS Code declines the attach session', async () => { + const provider = makeTreeProvider([ + makeAppHost({ resources: [ makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.pid': '5252', - 'project.path': '/repo/api-next/api-next.csproj', - 'project.targetName': 'api-next', - }), - }), - ], - }); - - await (provider as any).attachDebuggerToResource(resourceItem); - - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.processName, 'api-next'); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource uses AppHost process identity when multiple AppHosts share a path', async () => { - const provider = makeTreeProvider([ - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 1234, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': '4242' }), - }), - ], - }), - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 5678, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.pid': '6262', - 'project.path': '/repo/second-api/second-api.csproj', - 'project.targetName': 'second-api', - }), + properties: makeAttachableProjectProperties(), }), ], }), ]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - - try { - const [, secondAppHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - - await (provider as any).attachDebuggerToResource(resourceItem); - - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.processName, 'second-api'); - } - finally { - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource fails closed when AppHost path resolution is ambiguous', async () => { - const appHosts = [ - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 1234, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': '4242' }), - }), - ], - }), - ]; - const provider = makeTreeProvider(appHosts); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - appHosts.splice( - 0, - appHosts.length, - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 5678, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': '5252' }), - }), - ], - }), - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 9012, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': '9292' }), - }), - ], - })); - - const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - - assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session when the current AppHost cannot be resolved unambiguously'); - assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); - } - finally { - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource does not use stale resource when AppHost process id is reused', async () => { - const appHosts = [ - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 1234, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], - }), - ]; - const provider = makeTreeProvider(appHosts); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - appHosts[0] = makeAppHost({ - appHostPath: '/repo/OtherAppHost/AppHost.csproj', - appHostPid: 1234, - resources: [ - makeResource({ - name: 'api', - displayName: 'Other API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.pid': '5252', - 'project.path': '/repo/other-api/other-api.csproj', - }), - }), - ], - }); - - const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - - assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for a resource from a different AppHost path'); - assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); - } - finally { - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource does not use stale resource when latest resource is missing', async () => { - const appHosts = [ - makeAppHost({ - appHostPath: '/repo/AppHost/AppHost.csproj', - appHostPid: 1234, - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], - }), - ]; - const provider = makeTreeProvider(appHosts); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - appHosts.length = 0; - - const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - - assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for a stale resource item'); - assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); - } - finally { - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource does not use stale workspace resource after AppHost path changes', async () => { - const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); - const repository = { - viewMode: 'workspace' as ViewMode, - appHosts: [], - workspaceResources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], - workspaceAppHostPath: '/repo/AppHost/AppHost.csproj', - workspaceAppHostCandidatePaths: [], - workspaceAppHostName: 'AppHost.csproj', - workspaceAppHostDescription: undefined, - onDidChangeData, - } as unknown as AppHostDataRepository & { workspaceResources: ResourceJson[]; workspaceAppHostPath: string }; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - - try { - const [workspaceResourcesItem] = provider.getChildren(); - const [resourceItem] = provider.getChildren(workspaceResourcesItem); - repository.workspaceAppHostPath = '/repo/OtherAppHost/AppHost.csproj'; - repository.workspaceResources = [ - makeResource({ - name: 'api', - displayName: 'Other API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ - 'executable.pid': '5252', - 'project.path': '/repo/other-api/other-api.csproj', - }), - }), - ]; - - const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - - assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session after the workspace AppHost path changed'); - assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); - } - finally { - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource shows a warning when C# debugger support is unavailable', async () => { - const provider = makeTreeProvider([ - makeAppHost({ - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], - }), - ]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(false); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - - const outcome = await (provider as any).attachDebuggerToResource(resourceItem); - - assert.deepStrictEqual(outcome, { success: false, errorKind: 'CSharpExtensionMissing' }); - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session without C# debugger support'); - assert.ok(warningStub.calledOnce, 'Expected VS Code to show a warning'); - } - finally { - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } - }); - - test('attachDebuggerToResource guard failures reach command telemetry as error outcomes', async () => { - // These are handled, user-visible guard failures (a warning is shown). The command is - // registered through withCommandTelemetry, so returning a handled-failure object — rather - // than void — is what makes the invocation record as an `error` outcome with a specific - // error_kind instead of a false `success`, while still suppressing VS Code's generic - // "command failed" notification. - const invocations: Array<{ command: string; outcome: string; errorKind?: string; source?: string }> = []; - const invocationSubscription = onDidInvokeCommand(event => invocations.push(event)); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); - const warningStub = sinon.stub(vscode.window, 'showWarningMessage'); - let csharpInstalled = true; - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').callsFake(() => csharpInstalled); - - const getResourceItem = (provider: AspireAppHostTreeProvider) => { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - return resourceItem; - }; - const runAttach = (provider: AspireAppHostTreeProvider, item: unknown) => - withCommandTelemetry('aspire-vscode.attachDebuggerToResource', () => (provider as any).attachDebuggerToResource(item), { source: 'tree' }); - - // A resource that exists at selection time but is removed from the model before the command - // runs (exercises the stale/not-found guard). - const staleAppHosts = [ - makeAppHost({ - resources: [ - makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), - ], - }), - ]; - const staleProvider = makeTreeProvider(staleAppHosts); - const unattachableProvider = makeTreeProvider([ - makeAppHost({ - resources: [ - makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties({ 'executable.path': 'node' }) }), - ], - }), - ]); - const csharpMissingProvider = makeTreeProvider([ - makeAppHost({ - resources: [ - makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), - ], - }), - ]); - - try { - const staleResourceItem = getResourceItem(staleProvider); - const unattachableResourceItem = getResourceItem(unattachableProvider); - const csharpMissingResourceItem = getResourceItem(csharpMissingProvider); - - // 1) The selected resource is gone from the model -> ResourceNotFound. - staleAppHosts.length = 0; - const staleOutcome = await runAttach(staleProvider, staleResourceItem); - assert.deepStrictEqual(staleOutcome, { success: false, errorKind: 'ResourceNotFound' }); - - // 2) The resource is present but no longer attachable -> ResourceNotAttachable. - const unattachableOutcome = await runAttach(unattachableProvider, unattachableResourceItem); - assert.deepStrictEqual(unattachableOutcome, { success: false, errorKind: 'ResourceNotAttachable' }); - - // 3) The C# extension is not installed -> CSharpExtensionMissing. - csharpInstalled = false; - const csharpMissingOutcome = await runAttach(csharpMissingProvider, csharpMissingResourceItem); - assert.deepStrictEqual(csharpMissingOutcome, { success: false, errorKind: 'CSharpExtensionMissing' }); - - assert.ok(startDebuggingStub.notCalled, 'Expected no attach session for any guard failure'); - assert.strictEqual(warningStub.callCount, 3, 'Expected a warning for each guard failure'); - assert.deepStrictEqual( - invocations.map(event => [event.command, event.outcome, event.errorKind, event.source]), - [ - ['aspire-vscode.attachDebuggerToResource', 'error', 'ResourceNotFound', 'tree'], - ['aspire-vscode.attachDebuggerToResource', 'error', 'ResourceNotAttachable', 'tree'], - ['aspire-vscode.attachDebuggerToResource', 'error', 'CSharpExtensionMissing', 'tree'], - ]); - } - finally { - invocationSubscription.dispose(); - warningStub.restore(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - staleProvider.dispose(); - unattachableProvider.dispose(); - csharpMissingProvider.dispose(); - } - }); - - test('attachDebuggerToResource declining to start is recorded as an error command outcome', async () => { - // The genuine "VS Code declined to start the attach session" path still throws, so - // withCommandTelemetry classifies it as an error via the thrown error name. This preserves - // the distinct behavior from the handled guard failures above. - const invocations: Array<{ command: string; outcome: string; errorKind?: string }> = []; - const invocationSubscription = onDidInvokeCommand(event => invocations.push(event)); - const provider = makeTreeProvider([ - makeAppHost({ - resources: [ - makeResource({ name: 'api', displayName: 'API', resourceType: 'Project', state: ResourceState.Running, properties: makeAttachableProjectProperties() }), - ], - }), - ]); - const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(false); - const csharpInstalledStub = sinon.stub(capabilities, 'isCsharpInstalled').returns(true); - - try { - const [appHostItem] = provider.getChildren(); - const resourcesGroup = provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup'); - assert.ok(resourcesGroup, 'Expected resources group'); - const [resourceItem] = provider.getChildren(resourcesGroup); - - await assert.rejects( - withCommandTelemetry('aspire-vscode.attachDebuggerToResource', () => (provider as any).attachDebuggerToResource(resourceItem), { source: 'tree' }), - (error: unknown) => error instanceof Error && error.name === 'StartDebuggingDeclined'); - - assert.deepStrictEqual( - invocations.map(event => [event.command, event.outcome, event.errorKind]), - [['aspire-vscode.attachDebuggerToResource', 'error', 'StartDebuggingDeclined']]); - } - finally { - invocationSubscription.dispose(); - csharpInstalledStub.restore(); - startDebuggingStub.restore(); - provider.dispose(); - } + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + sandbox.stub(vscode.debug, 'startDebugging').resolves(false); + + await assert.rejects( + (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)), + (error: unknown) => error instanceof Error + && error.name === 'StartDebuggingDeclined' + && error.message === 'VS Code did not start the debugger attach session for API.'); + provider.dispose(); }); test('workspace mode renders a running AppHost with no resources', () => { @@ -3412,46 +2799,6 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('workspace resource terminal actions abort when cached AppHost path becomes ambiguous', async () => { - const commands: AspireSubcommand[] = []; - const appHostPath = '/repo/apps/Store/AppHost.csproj'; - const appHosts = [ - makeAppHost({ appHostPath, appHostPid: 1234, resources: [makeResource({ name: 'cache', displayName: 'cache' })] }), - ]; - const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); - const repository = { - viewMode: 'workspace' as ViewMode, - appHosts, - workspaceResources: [], - workspaceAppHost: undefined, - workspaceAppHostPath: undefined, - workspaceAppHostName: undefined, - workspaceAppHostCandidatePaths: [appHostPath], - workspaceAppHostDescription: undefined, - onDidChangeData, - } as unknown as AppHostDataRepository; - const terminalProvider = { - getAspireCliExecutablePath: async () => 'aspire', - createEnvironment: () => ({}), - sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), - } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); - - const [runningAppHostItem] = provider.getChildren(); - const resourceItem = provider.getChildren(runningAppHostItem)[0]; - appHosts.splice( - 0, - appHosts.length, - makeAppHost({ appHostPath, appHostPid: 5678, resources: [makeResource({ name: 'cache' })] }), - makeAppHost({ appHostPath, appHostPid: 9012, resources: [makeResource({ name: 'cache' })] })); - - await provider.viewResourceLogs(resourceItem as any); - await provider.openResourceTerminal(resourceItem as any); - - assert.deepStrictEqual(commands, []); - provider.dispose(); - }); - test('openResourceTerminal adds replica when terminal metadata includes index', async () => { const commands: AspireSubcommand[] = []; const appHostPath = '/repo/apps/Store/AppHost.csproj'; diff --git a/extension/src/test/dashboardTelemetryRoutes.test.ts b/extension/src/test/dashboardTelemetryRoutes.test.ts index 6bf118e7b68..8563e35255a 100644 --- a/extension/src/test/dashboardTelemetryRoutes.test.ts +++ b/extension/src/test/dashboardTelemetryRoutes.test.ts @@ -210,98 +210,6 @@ suite('DashboardTelemetryPassthrough route-level normalization', () => { assert.strictEqual(parsed.v['Aspire.Dashboard.UserAgent'], ''); }); - // Every spelling a private location can arrive in from the dashboard. This is one test rather - // than several because the failure mode being pinned is a gap between spellings: the detector - // is a set of alternatives, and a change that tightens one of them leaves the same host name - // redacted under one spelling and transmitted under another. VS Code's own cleaner does not - // backstop this - the bundle reaches it as a single JSON string, which is why each leaf is - // cleaned before it is bundled - and it does not redact host-only UNC values at all. - test('POST /telemetry/operation sanitizes every private-location spelling before bundling', async () => { - const { status } = await postJson(h.baseUrl, '/telemetry/operation', { - eventName: 'aspire/dashboard/component/open', - properties: { - 'Aspire.Dashboard.Resource.Types': { - value: [ - String.raw`\\private-server\customer-share\workspace\apphost.csproj`, - String.raw`\\?\UNC\private-server\customer-share\workspace\apphost.csproj`, - String.raw`\\private-server`, - '//private-server/customer-share/workspace/apphost.csproj', - '//private-server', - // JSON-escaped spellings, which is how a UNC path looks once a dashboard - // client has already serialized it into a string it then sends as a value. - String.raw`\\\\private-server\\customer-share`, - String.raw`\\?\C:\customer\workspace`, - String.raw`C:\Users\customer\workspace\apphost.csproj`, - String.raw`D:\Work\customer\apphost.csproj`, - String.raw`D:\\Work\\customer`, - '/mnt/customer/project/apphost.csproj', - 'UNC-looking label private-server customer-share without leading slashes', - ], - propertyType: 1, - }, - }, - result: 1, - }); - - assert.strictEqual(status, 200); - const parsed = JSON.parse(h.fake.events[0].properties?.dashboard_properties ?? ''); - assert.deepStrictEqual( - parsed.v['Aspire.Dashboard.Resource.Types'], - [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - 'UNC-looking label private-server customer-share without leading slashes', - ] - ); - }); - - // Standalone credential formats carry no assignment and no `Bearer` prefix, so an - // assignment-shaped detector alone misses them. They are cleaned here rather than left to - // VS Code for the same reason the paths above are: the bundle is one opaque string by the - // time VS Code sees it, and a single match would replace the whole bundle rather than the leaf. - test('POST /telemetry/operation redacts standalone credential formats before bundling', async () => { - const { status } = await postJson(h.baseUrl, '/telemetry/operation', { - eventName: 'aspire/dashboard/component/open', - properties: { - 'Aspire.Dashboard.Resource.Types': { - value: [ - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcdEFGH', - `github_pat_11ABCDEFG0abcdefghijkl_${'abcdefghijklmnopqrstuvwxyz'}${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'}0123456`, - 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', - 'xoxb-123456789012-abcdefghijkl', - 'AIzaSyA0123456789abcdefghijklmnopqrstuvw', - 'project', - ], - propertyType: 1, - }, - }, - result: 1, - }); - - assert.strictEqual(status, 200); - const parsed = JSON.parse(h.fake.events[0].properties?.dashboard_properties ?? ''); - assert.deepStrictEqual( - parsed.v['Aspire.Dashboard.Resource.Types'], - [ - '', - '', - '', - '', - '', - 'project', - ] - ); - }); - test('POST /telemetry/operation sanitizes every nested string-array entry', async () => { const { status } = await postJson(h.baseUrl, '/telemetry/operation', { eventName: 'aspire/dashboard/component/open', diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index ce0f0754844..544aff4c140 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -9,8 +9,6 @@ import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration import * as io from '../utils/io'; import { ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; -import { extensionLogOutputChannel } from '../utils/logging'; -import { attachDebuggerTargetNameProbeAssumesDefaultConfiguration, attachDebuggerExecutableLaunchProfile, attachDebuggerUnresolvedLaunchProfile } from '../loc/strings'; class TestDotNetService { private _hasDevKit: boolean; @@ -63,7 +61,7 @@ suite('Dotnet Debugger Extension Tests', () => { return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; } - test('attach configuration uses reported target name without evaluating TargetPath', async () => { + test('attach configuration uses the selected resource process ID without evaluating TargetPath', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ @@ -75,85 +73,17 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.csproj', - 'project.targetName': 'FromReportedProperty', }, }); assert.strictEqual(configuration.type, 'coreclr'); assert.strictEqual(configuration.request, 'attach'); assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processName, 'FromReportedProperty'); + assert.strictEqual(configuration.processId, '1234'); + assert.strictEqual(configuration.processName, undefined); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('attach configuration derives process name from evaluated TargetPath when target name is not reported', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/My Attach Service.dll', null, true, true); - const warn = sinon.stub(extensionLogOutputChannel, 'warn'); - - const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ - name: 'worker', - displayName: 'Worker', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/worker/AttachDemo.Worker.csproj', - }, - }); - - assert.strictEqual(configuration.processName, 'My Attach Service'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/worker/AttachDemo.Worker.csproj')); - - // The probe answers for the project's default configuration, which is not necessarily the one the - // AppHost is running. Recording that is the only remedy available: the configuration is not in the - // resource contract of an AppHost old enough to need this fallback. - assert.deepStrictEqual(warn.getCalls().map(call => call.args[0]), [ - attachDebuggerTargetNameProbeAssumesDefaultConfiguration('Worker', '/repo/worker/AttachDemo.Worker.csproj') - ]); - }); - - test('attach configuration treats blank reported target name as absent', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); - - const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - 'project.targetName': ' ', - }, - }); - - assert.strictEqual(configuration.processName, 'FromTargetPath'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWith('/repo/api/Api.csproj')); - }); - - test('attach configuration reports process-name failure when TargetPath cannot be evaluated', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/unused.dll', null, true, true); - dotNetService.getDotNetTargetPathStub.rejects(new Error('MSBuild failed')); - - await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }), - (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ProcessNameUnresolved'); - }); - test('attach configuration rejects file-based project resources', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); @@ -167,7 +97,6 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.cs', - 'project.targetName': 'Api', }, }), (error: unknown) => error instanceof Error @@ -177,197 +106,6 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('attach configuration refuses a project whose default launch profile runs an executable', async () => { - const fs = require('fs'); - const path = require('path'); - - // A real launchSettings.json on disk, because the refusal is decided by reading the file the way - // `dotnet run` does rather than from anything the resource snapshot carries. - const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-executable-profile'); - const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); - fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); - fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ - profiles: { - 'Aspire_my-function': { - commandName: 'Executable', - executablePath: 'dotnet', - commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', - }, - }, - })); - - try { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); - - await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ - name: 'my-function', - displayName: 'My Function', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), - 'project.targetName': 'MyClassLibFunction', - }, - }), - (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' - && error.message === attachDebuggerExecutableLaunchProfile('My Function', 'Aspire_my-function')); - - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); - } - finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } - }); - - test('attach configuration refuses the launch profile the AppHost actually selected', async () => { - const fs = require('fs'); - const path = require('path'); - - // The file's default profile is a Project profile, so inferring the default here would happily - // offer an attach. The AppHost reported that it applied the later Executable profile instead, and - // that is the one that decides what process is running. - const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-selected-executable-profile'); - const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); - fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); - fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ - profiles: { - 'http': { - commandName: 'Project', - }, - 'run-as-executable': { - commandName: 'Executable', - executablePath: 'dotnet', - commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', - }, - }, - })); - - try { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); - - await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ - name: 'my-function', - displayName: 'My Function', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), - 'project.targetName': 'MyClassLibFunction', - 'project.launchProfile': 'run-as-executable', - }, - }), - (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' - && error.message === attachDebuggerExecutableLaunchProfile('My Function', 'run-as-executable')); - - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); - } - finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } - }); - - test('attach configuration refuses when the launch profile the AppHost reported can no longer be read', async () => { - const fs = require('fs'); - const path = require('path'); - - // The AppHost resolved 'run-as-executable' when it started the resource, so the file has changed - // since. Its commandName is what decides whether the project's own output is running, and it is no - // longer knowable, so the guard must not quietly fall through to a Project-style attach. - const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-unresolved-launch-profile'); - const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); - fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); - fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ - profiles: { - 'http': { - commandName: 'Project', - }, - }, - })); - - try { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); - - await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ - name: 'my-function', - displayName: 'My Function', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), - 'project.targetName': 'MyClassLibFunction', - 'project.launchProfile': 'run-as-executable', - }, - }), - (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable' - && error.message === attachDebuggerUnresolvedLaunchProfile('My Function', 'run-as-executable')); - - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); - } - finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } - }); - - test('attach configuration is offered when the AppHost reports that no launch profile applies', async () => { - const fs = require('fs'); - const path = require('path'); - - // WithExcludeLaunchProfile leaves the project running its own output no matter what the file says, - // so refusing here because the file's default happens to be an Executable profile would take attach - // away from a resource that is perfectly attachable. - const fixtureRoot = path.join(__dirname, '..', '..', '.test-fixtures', 'attach-excluded-launch-profile'); - const projectDirectory = path.join(fixtureRoot, 'MyClassLibFunction'); - fs.mkdirSync(path.join(projectDirectory, 'Properties'), { recursive: true }); - fs.writeFileSync(path.join(projectDirectory, 'Properties', 'launchSettings.json'), JSON.stringify({ - profiles: { - 'run-as-executable': { - commandName: 'Executable', - executablePath: 'dotnet', - commandLineArgs: 'exec RuntimeSupport.dll MyClassLibFunction::MyClassLibFunction.Function::FunctionHandler', - }, - }, - })); - - try { - const { extension } = createDebuggerExtension('/repo/bin/Debug/net10.0/MyClassLibFunction.dll', null, true, true); - - const debugConfiguration = await extension.createAttachDebugSessionConfigurationCallback!({ - name: 'my-function', - displayName: 'My Function', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': path.join(projectDirectory, 'MyClassLibFunction.csproj'), - 'project.targetName': 'MyClassLibFunction', - 'project.launchProfile': null, - }, - }); - - assert.strictEqual(debugConfiguration.request, 'attach'); - assert.strictEqual(debugConfiguration.processName, 'MyClassLibFunction'); - } - finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } - }); - test('failed AppHost start writes error to debug console', async () => { const parentDebugSession = { id: 'aspire-session', diff --git a/extension/src/test/resourceLookup.test.ts b/extension/src/test/resourceLookup.test.ts deleted file mode 100644 index c864d46574a..00000000000 --- a/extension/src/test/resourceLookup.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as assert from 'assert'; -import * as path from 'path'; -import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../views/AppHostDataRepository'; -import { findAppHostForResource, ResourceElementRef } from '../views/resourceLookup'; - -function appHost(appHostPath: string, appHostPid: number): AppHostDisplayInfo { - return { - appHostPath, - appHostPid, - cliPid: null, - dashboardUrl: null, - resources: [], - }; -} - -// findAppHostForResource only reads the app host list, so the repository is represented by that list -// alone rather than by driving a real repository through discovery. -function repositoryWith(...appHosts: AppHostDisplayInfo[]): AppHostDataRepository { - return { appHosts } as unknown as AppHostDataRepository; -} - -function resourceRef(appHostPid: number | null, appHostPath?: string): ResourceElementRef { - return { - resource: { name: 'api' } as ResourceJson, - appHostPid, - appHostPath, - }; -} - -suite('resourceLookup', () => { - const first = path.join('/repo', 'First', 'First.AppHost.csproj'); - const second = path.join('/repo', 'Second', 'Second.AppHost.csproj'); - - test('resolves the app host the element came from', () => { - const target = appHost(first, 100); - const resolved = findAppHostForResource(repositoryWith(appHost(second, 99), target), resourceRef(100, first)); - - assert.strictEqual(resolved, target); - }); - - test('does not resolve a different app host that reused the pid', () => { - // Every caller passes the result to the CLI as --apphost, so resolving by pid alone would send - // a resource name from one app host to another that happens to be running under the same pid. - const resolved = findAppHostForResource(repositoryWith(appHost(second, 100)), resourceRef(100, first)); - - assert.strictEqual(resolved, undefined); - }); - - test('resolves an app host restarted under a new pid', () => { - const restarted = appHost(first, 250); - const resolved = findAppHostForResource(repositoryWith(restarted, appHost(second, 99)), resourceRef(100, first)); - - assert.strictEqual(resolved, restarted); - }); - - test('fails closed when the path alone cannot pick one app host', () => { - const resolved = findAppHostForResource(repositoryWith(appHost(first, 300), appHost(first, 301)), resourceRef(100, first)); - - assert.strictEqual(resolved, undefined); - }); - - test('falls back to the pid when the element remembers no path', () => { - const target = appHost(first, 100); - const resolved = findAppHostForResource(repositoryWith(target, appHost(second, 99)), resourceRef(100)); - - assert.strictEqual(resolved, target); - }); -}); diff --git a/extension/src/test/telemetry.test.ts b/extension/src/test/telemetry.test.ts index 96d73bcc513..84ee19c306f 100644 --- a/extension/src/test/telemetry.test.ts +++ b/extension/src/test/telemetry.test.ts @@ -188,70 +188,6 @@ suite('telemetry utilities', () => { assert.strictEqual(fake.events[0].measurements?.duration_ms, 12); }); - // Extension-authored telemetry is cleaned by VS Code's TelemetryLogger rather than by anything - // in this file. That is a deliberate choice - a hand-written replacement has to re-derive the - // platform's whole secret vocabulary and silently loses a category whenever it misses one - so - // this pins the categories the delegation is relied on for, standalone credential formats - // included: those carry no assignment and no `Bearer` prefix, so an assignment-shaped detector - // would pass them straight to the wire. - test('extension-authored property values are cleaned before reaching the transport', () => { - const githubPat = `github_pat_11ABCDEFG0abcdefghijkl_${'abcdefghijklmnopqrstuvwxyz'}${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'}0123456`; - const cleanedInputs = [ - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.abcdEFGH', - githubPat, - 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', - 'xoxb-123456789012-abcdefghijkl', - 'AIzaSyA0123456789abcdefghijklmnopqrstuvw', - String.raw`C:\Users\customer\workspace\apphost.csproj`, - String.raw`D:\Work\customer\apphost.csproj`, - '/mnt/customer/project/apphost.csproj', - String.raw`\\private-server\customer-share\workspace`, - '//private-server/customer-share/workspace', - String.raw`\\?\UNC\private-server\customer-share`, - String.raw`\\?\C:\customer\workspace`, - String.raw`\\\\private-server\\customer-share`, - ]; - - for (const input of cleanedInputs) { - sendTelemetryEvent('aspire/vscode/command/invoked', { command: input }); - } - - assert.deepStrictEqual( - fake.events.map(event => event.properties?.command), - [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '/', - String.raw`\\?`, - String.raw`\\?\`, - String.raw`\\`, - ] - ); - }); - - // The boundary of the delegation above. VS Code's path detector needs at least one - // separated segment after the leading slashes, so a bare host name survives it in either - // spelling. Extension-authored properties are registry-constrained buckets that cannot carry - // one; the dashboard passthrough, which does carry free-form strings, cleans each leaf itself - // rather than relying on this stage - see the private-location spelling coverage in - // dashboardTelemetryRoutes.test.ts. Pinned so the split stays visible if either side moves. - test('host-only UNC values survive the platform cleaning stage', () => { - sendTelemetryEvent('aspire/vscode/command/invoked', { command: String.raw`\\private-server` }); - sendTelemetryEvent('aspire/vscode/command/invoked', { command: '//private-server' }); - - assert.deepStrictEqual( - fake.events.map(event => event.properties?.command), - [String.raw`\\private-server`, '//private-server'] - ); - }); - test('telemetry levels are consulted on every emit', () => { fake.telemetryLevel = 'off'; sendTelemetryEvent('aspire/vscode/command/invoked', { command: 'cmd.off' }); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index a774cc84388..c908e124286 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -64,7 +64,6 @@ import { executeResourceCommand as executeResourceCommandWithUi, type ResourceCo import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { isCommandCancellation } from '../utils/telemetry'; import * as debuggerExtensions from '../debugger/debuggerExtensions'; -import { findAppHostForResource, findLatestResourceForElement, getAppHostPathForResource } from './resourceLookup'; type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | WorkspaceAppHostItem | WorkspaceAppHostsGroupItem | RunningAppHostsGroupItem | WorkspaceAppHostActionItem | WorkspaceAppHostPathItem | HealthChecksGroupItem | HealthCheckItem | LogFileItem | CommandsGroupItem | ResourceCommandItem; @@ -322,7 +321,7 @@ class LogFileItem extends vscode.TreeItem { } class ResourcesGroupItem extends vscode.TreeItem { - constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number, public readonly appHostPath: string) { + constructor(public readonly resources: ResourceJson[], public readonly appHostPid: number) { super(resourcesGroupLabel, vscode.TreeItemCollapsibleState.Expanded); this.id = `resources:${appHostPid}`; this.iconPath = new vscode.ThemeIcon('layers', new vscode.ThemeColor('aspire.brandPurple')); @@ -402,6 +401,11 @@ function getParentResourceName(resource: ResourceJson): string | null { return resource.properties?.['resource.parentName'] ?? null; } +interface AttachDebuggerHandledFailure { + success: false; + errorKind: 'ResourceNotFound' | 'ResourceNotAttachable' | 'CSharpExtensionMissing'; +} + class ResourceItem extends vscode.TreeItem { constructor( public readonly resource: ResourceJson, @@ -430,7 +434,7 @@ class ResourceItem extends vscode.TreeItem { } } -export function getResourceContextValue(resource: ResourceJson, canAttachDebugger: boolean): string { +export function getResourceContextValue(resource: ResourceJson, canAttachDebugger: boolean = false): string { const commands = resource.commands; const parts = ['resource']; if (hasEnabledCommand(commands, 'start') || hasEnabledCommand(commands, 'resource-start')) { @@ -467,11 +471,6 @@ function getTerminalReplicaIndex(resource: ResourceJson): string | undefined { return trimmedValue && trimmedValue.length > 0 ? trimmedValue : undefined; } -interface AttachDebuggerHandledFailure { - success: false; - errorKind: 'ResourceNotFound' | 'ResourceNotAttachable' | 'CSharpExtensionMissing' | 'ProcessNameUnresolved'; -} - export function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { const state = resource.state; const health = resource.healthStatus; @@ -1224,7 +1223,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0) { - items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid, appHost.appHostPath)); + items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid)); } return items; @@ -1234,7 +1233,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); return sortResources(topLevel).map(r => { const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name); - return new ResourceItem(r, element.appHostPid, hasChildren, element.resources, element.appHostPath); + return new ResourceItem(r, element.appHostPid, hasChildren, element.resources); }); } @@ -1494,12 +1493,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const resource = findLatestResourceForElement(this._repository, element); - if (!resource) { + const latestElement = this.findResourceElement(element.resource.name, element.appHostPath); + if (!(latestElement instanceof ResourceItem)) { vscode.window.showWarningMessage(attachDebuggerResourceNotFound); return { success: false, errorKind: 'ResourceNotFound' }; } + const resource = latestElement.resource; const debuggerExtension = debuggerExtensions.getAttachDebuggerExtensionForResource(resource); if (!debuggerExtension) { const missingDebuggerExtension = debuggerExtensions.getMissingAttachDebuggerExtensionForResource(resource); @@ -1535,20 +1535,16 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { // aspire logs accepts the resource display name, not the internal name - const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; - const resourceName = resource.displayName ?? resource.name; + const resourceName = element.resource.displayName ?? element.resource.name; if (this._repository.viewMode === 'workspace') { - const appHostPath = getAppHostPathForResource(this._repository, element); - if (element.appHostPath && appHostPath === undefined) { - return; - } + const appHostPath = this._getAppHostPathForResource(element); const command = appHostPath ? ['logs', shellArg(resourceName), '--apphost', shellArg(appHostPath)] : ['logs', shellArg(resourceName)]; await this._terminalProvider.sendAspireCommandToAspireTerminal(command); return; } - const appHost = findAppHostForResource(this._repository, element); + const appHost = this._findAppHostForResource(element); if (!appHost) { return; } @@ -1556,17 +1552,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const latestResource = findLatestResourceForElement(this._repository, element) ?? element.resource; - const command: Array = ['terminal', 'attach', shellArg(latestResource.name)]; - const appHostPath = getAppHostPathForResource(this._repository, element); - if (element.appHostPath && appHostPath === undefined) { - return; - } + const command: Array = ['terminal', 'attach', shellArg(element.resource.name)]; + const appHostPath = this._getAppHostPathForResource(element); if (appHostPath) { command.push('--apphost', shellArg(appHostPath)); } - const replicaIndex = getTerminalReplicaIndex(latestResource); + const replicaIndex = getTerminalReplicaIndex(element.resource); if (replicaIndex) { command.push('--replica', shellArg(replicaIndex)); } @@ -1575,13 +1567,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const resource = findLatestResourceForElement(this._repository, element); - if (!resource) { - vscode.window.showInformationMessage(noCommandsAvailable); - return; - } - - const commands = resource.commands; + const commands = element.resource.commands; if (!commands || Object.keys(commands).length === 0) { vscode.window.showInformationMessage(noCommandsAvailable); return; @@ -1622,13 +1608,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const commandName = element.commandName; - const latestResource = findLatestResourceForElement(this._repository, element.resourceItem); - if (!latestResource) { - vscode.window.showInformationMessage(noCommandsAvailable); - return; - } - - const command = latestResource.commands?.[commandName]; + const command = element.commandJson; const resourceItem = element.resourceItem; if (!isEnabledCommand(command)) { @@ -1721,24 +1701,19 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider this.showResourceCommandOutput(resourceName, command, content, outputAppHostPath), { - resourceName: resource.name, - displayName: resource.displayName ?? resource.name, + resourceName: element.resource.name, + displayName: element.resource.displayName ?? element.resource.name, commandName, appHostPath: appHostPath ?? undefined, additionalArgs, @@ -1763,16 +1738,12 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const appHostPath = this._repository.viewMode === 'workspace' - ? getAppHostPathForResource(this._repository, element) - : findAppHostForResource(this._repository, element)?.appHostPath; - if (element.appHostPath && appHostPath === undefined) { - return undefined; - } - const resource = findLatestResourceForElement(this._repository, element) ?? element.resource; + ? this._getAppHostPathForResource(element) + : this._findAppHostForResource(element)?.appHostPath; const loader = createResourceCommandArgumentLoader({ cliExecutionProvider: this._terminalProvider, - resourceName: resource.name, + resourceName: element.resource.name, commandName, appHostPath: appHostPath ?? undefined, }); @@ -1780,6 +1751,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider a.appHostPid === element.appHostPid); + } + + private _getAppHostPathForResource(element: ResourceItem): string | undefined { + return element.appHostPath ?? this._findAppHostForResource(element)?.appHostPath ?? this._repository.workspaceAppHostPath; + } } /** diff --git a/extension/src/views/resourceLookup.ts b/extension/src/views/resourceLookup.ts deleted file mode 100644 index 272ccd21bfc..00000000000 --- a/extension/src/views/resourceLookup.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - AppHostDataRepository, - AppHostDisplayInfo, - ResourceJson, - isMatchingAppHostPath, -} from './AppHostDataRepository'; - -export interface ResourceElementRef { - resource: ResourceJson; - appHostPid: number | null; - appHostPath?: string; -} - -export function findLatestResourceForElement(repository: AppHostDataRepository, element: ResourceElementRef): ResourceJson | undefined { - const resources = findLatestResourcesForElement(repository, element); - return resources?.find(resource => resource.name === element.resource.name); -} - -export function findLatestResourcesForElement(repository: AppHostDataRepository, element: ResourceElementRef): readonly ResourceJson[] | undefined { - const workspaceResources = [...repository.workspaceResources]; - const selectedAppHostPath = repository.workspaceAppHost?.appHostPath ?? repository.workspaceAppHostPath; - - if (element.appHostPath) { - const matchingAppHosts = repository.appHosts.filter(appHost => isMatchingAppHostPath(appHost.appHostPath, element.appHostPath!)); - const appHostByPid = element.appHostPid !== null - ? matchingAppHosts.find(appHost => appHost.appHostPid === element.appHostPid) - : undefined; - const appHost = appHostByPid ?? (matchingAppHosts.length === 1 ? matchingAppHosts[0] : undefined); - if (appHost) { - if (workspaceResources.length > 0 && selectedAppHostPath && isMatchingAppHostPath(appHost.appHostPath, selectedAppHostPath) && hasNoResources(appHost.resources)) { - return workspaceResources; - } - - return appHost.resources ?? []; - } - - if (matchingAppHosts.length > 1) { - return undefined; - } - - if (!selectedAppHostPath || !isMatchingAppHostPath(element.appHostPath, selectedAppHostPath)) { - return undefined; - } - - return workspaceResources.length > 0 - ? workspaceResources - : repository.workspaceAppHost?.resources ?? []; - } - - const appHost = findAppHostForResource(repository, element); - - if (appHost && workspaceResources.length > 0 && selectedAppHostPath && isMatchingAppHostPath(appHost.appHostPath, selectedAppHostPath) && hasNoResources(appHost.resources)) { - return workspaceResources; - } - - if (appHost) { - return appHost.resources ?? []; - } - - return element.appHostPid === null ? workspaceResources : undefined; -} - -export function findAppHostForResource(repository: AppHostDataRepository, element: ResourceElementRef): AppHostDisplayInfo | undefined { - // A pid is not a durable identity for an app host. Tree items outlive the app host they were built - // from, and the OS reuses pids, so a stale action can name a pid that now belongs to a different app - // host - and every caller of this uses the result to pass --apphost to the CLI. When the element - // remembers which app host file it came from, that file is the identity: a pid that resolves outside - // it counts as not found, and an ambiguous file resolves to nothing rather than to a guess. This is - // the same resolution order findLatestResourcesForElement above uses. - if (element.appHostPath) { - const matchingAppHosts = repository.appHosts.filter(appHost => isMatchingAppHostPath(appHost.appHostPath, element.appHostPath!)); - const appHostByPid = matchingAppHosts.find(appHost => appHost.appHostPid === element.appHostPid); - - return appHostByPid ?? (matchingAppHosts.length === 1 ? matchingAppHosts[0] : undefined); - } - - return element.appHostPid !== null - ? repository.appHosts.find(appHost => appHost.appHostPid === element.appHostPid) - : undefined; -} - -export function getAppHostPathForResource(repository: AppHostDataRepository, element: ResourceElementRef): string | undefined { - const selectedAppHostPath = repository.workspaceAppHost?.appHostPath ?? repository.workspaceAppHostPath; - - if (element.appHostPath) { - const elementAppHostPath = element.appHostPath; - // Terminal-backed actions can intentionally fall back to the tree item's stale resource - // snapshot during refresh windows, so validate the cached AppHost path before it becomes a - // CLI --apphost argument. - const matchingAppHosts = repository.appHosts.filter(appHost => isMatchingAppHostPath(appHost.appHostPath, elementAppHostPath)); - const appHostByPid = element.appHostPid !== null - ? matchingAppHosts.find(appHost => appHost.appHostPid === element.appHostPid) - : undefined; - - if (appHostByPid) { - return appHostByPid.appHostPath; - } - - if (matchingAppHosts.length === 1) { - return matchingAppHosts[0].appHostPath; - } - - if (matchingAppHosts.length > 1) { - return undefined; - } - - return selectedAppHostPath && isMatchingAppHostPath(elementAppHostPath, selectedAppHostPath) - ? selectedAppHostPath - : undefined; - } - - return findAppHostForResource(repository, element)?.appHostPath ?? selectedAppHostPath; -} - -function hasNoResources(resources: readonly ResourceJson[] | null | undefined): boolean { - return resources === undefined || resources === null || resources.length === 0; -} diff --git a/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets b/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets index 03625a54034..bdbed9eafef 100644 --- a/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets +++ b/src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets @@ -38,16 +38,6 @@ true all false - - - _AspireProjectResourceBuildOutput diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index f954f384f43..d6ab64c689c 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -148,11 +148,6 @@ private XDocument CreateProjectFile(IEnumerable integratio {_repoRoot} true - - true true true 42.42.42 diff --git a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets index a400457e6c6..bfa347add2a 100644 --- a/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets +++ b/src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets @@ -41,320 +41,17 @@ - - - <_AspireProjectResourceIdentity>%(_AspireProjectResource.Identity) - <_AspireProjectResourceTypeName>%(_AspireProjectResource.AspireProjectMetadataTypeName) - <_AspireProjectResourceClassNameSource Condition="'$(_AspireProjectResourceTypeName)' == ''">%(_AspireProjectResource.Filename) - <_AspireProjectResourceClassNameSource Condition="'$(_AspireProjectResourceTypeName)' != ''">$(_AspireProjectResourceTypeName) - - + DependsOnTargets="_CreateAspireProjectResources"> - $([System.Text.RegularExpressions.Regex]::Replace('$(_AspireProjectResourceClassNameSource)', $(_GeneratedClassNameFixupRegex), '_')) - $([System.IO.Path]::GetFullPath('$(_AspireProjectResourceIdentity)')) + $([System.Text.RegularExpressions.Regex]::Replace($([System.IO.Path]::GetFileNameWithoutExtension(%(_AspireProjectResource.Identity))), $(_GeneratedClassNameFixupRegex), '_')) + $([System.Text.RegularExpressions.Regex]::Replace(%(_AspireProjectResource.AspireProjectMetadataTypeName), $(_GeneratedClassNameFixupRegex), '_')) + $([System.IO.Path]::GetFullPath(%(_AspireProjectResource.Identity))) - - - - <_AspireProjectReferencesResolved>true - - - - - - - - - <_AspireProjectResourceTargetPath Include="@(_AspireProjectResourceBuildOutput)" /> - - - <_AspireProjectResourceResolvedTargetPathProbe Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" - Exclude="@(_MSBuildProjectReferenceExistent->WithMetadataValue('OutputItemType', '_AspireProjectResourceBuildOutput'))" - Condition="'$(_AspireProjectReferencesResolved)' == 'true'" /> - - - <_AspirePreparedProjectResource Include="@(_MSBuildProjectReferenceExistent->WithMetadataValue('IsAspireProjectResource', 'true')->WithMetadataValue('BuildReference', 'true'))" - Condition="'$(_AspireProjectReferencesResolved)' != 'true'" /> - - - - - - - - - - - - - - - - - - - - - - <_AspireProjectResourceTargetFrameworkInfo Update="@(_AspireProjectResourceTargetFrameworkInfo)"> - %(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove) - - $([System.Text.RegularExpressions.Regex]::Replace('%(_AspireProjectResourceTargetFrameworkInfo.GlobalPropertiesToRemove)', '(?i)(^|;)\s*TargetFramework\s*(?=;|$)', '$1')) - - RuntimeIdentifier;SelfContained - TargetFramework;RuntimeIdentifier;SelfContained - - <_AspireProjectResourceTargetPathProbe Include="@(_AspireProjectResourceTargetFrameworkInfo)" - Condition="'%(_AspireProjectResourceTargetFrameworkInfo.SetTargetFramework)' != '' or '%(_AspireProjectResourceTargetFrameworkInfo.HasSingleTargetFramework)' == 'true' or '%(_AspireProjectResourceTargetFrameworkInfo.TargetFrameworks)' == ''" /> - - - - - - - - - - - - - - - <_AspireResolvedProjectFile>%(_AspireProjectResourceTargetPath.MSBuildSourceProjectFile) - <_AspireResolvedProjectFile Condition="'$(_AspireResolvedProjectFile)' != ''">$([System.IO.Path]::GetFullPath('$(_AspireResolvedProjectFile)')) - <_AspireResolvedTargetName>%(_AspireProjectResourceTargetPath.Filename) - - <_AspireResolvedTargetNameLiteral>$(_AspireResolvedTargetName.Replace('"', '""')) - - - - - $(_AspireResolvedTargetName) - $(_AspireResolvedTargetNameLiteral) - - - - - - - - - - - /// The target name that the ]]>%(ClassName) - /// - /// Evaluated by MSBuild when this AppHost was built, so it reflects any TargetName set by the project or - /// imported into it rather than the project file name. - /// -#nullable enable - public string? TargetName => @"]]>%(AspireProjectMetadataSource.ProjectTargetNameLiteral) - - - - + @@ -376,7 +73,7 @@ namespace Projects%3B /// The path to the ]]>%(ClassName) public string ProjectPath => """]]>%(ProjectPath)%(AspireProjectMetadataSource.TargetNameMember) /// Gets a value indicating whether building the project before running it should be suppressed. /// diff --git a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs index 0585257bb5c..09696328913 100644 --- a/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePropertySnapshotMetadata.cs @@ -50,7 +50,6 @@ internal static (string? DisplayName, bool IsHighlighted, int? SortOrder) Get(st (KnownResourceTypes.Project, KnownProperties.Project.Path) => (ResourcePropertyProjectPathDisplayName, true, 0), (KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile) => (ResourcePropertyProjectLaunchProfileDisplayName, true, 1), (KnownResourceTypes.Project, KnownProperties.Executable.Pid) => (ResourcePropertyExecutableProcessIdDisplayName, true, 2), - (KnownResourceTypes.Project, KnownProperties.Project.TargetName) => (ResourcePropertyProjectTargetNameDisplayName, true, 3), _ => (null, false, null) }; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 95cfd8aabf4..949134a43d6 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -131,7 +131,6 @@ public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourc public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSnapshot previous) { string? projectPath = null; - string? projectTargetName = null; string? launchProfileName = null; IResource? appModelResource = null; @@ -140,16 +139,13 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn { if (appModelResource is ProjectResource projectResource) { - var metadata = projectResource.GetProjectMetadata(); - projectPath = metadata.ProjectPath; - projectTargetName = metadata.TargetName; + projectPath = projectResource.GetProjectMetadata().ProjectPath; launchProfileName = projectResource.GetEffectiveLaunchProfile()?.Name; } else if (appModelResource.TryGetProjectMetadata(out var projectMetadata)) { // New-style, annotation-based C# service (DotnetProjectResource) projectPath = projectMetadata.ProjectPath; - projectTargetName = projectMetadata.TargetName; launchProfileName = appModelResource.GetEffectiveLaunchProfile()?.Name; } } @@ -175,40 +171,21 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn if (projectPath is not null) { - List projectProperties = [ - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Path, executable.Spec.ExecutablePath), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Args, effectiveArgs ?? [], isSensitive: true), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Pid, executable.Status?.ProcessId), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.Path, projectPath), - ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), - new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, - new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, - ]; - - // The target name is only known when the AppHost build baked it into the generated project metadata. - // Its absence - not a null or empty value - is the capability signal consumers use to decide whether the - // evaluated target name can be relied on, so nothing is written when it could not be resolved. - var previousProperties = previous.Properties; - if (!string.IsNullOrWhiteSpace(projectTargetName)) - { - projectProperties.Add(ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.TargetName, projectTargetName)); - } - else - { - // Snapshots are merged into the previously published one and SetResourcePropertyRange only adds or - // replaces, so simply omitting the property would leave an earlier value in place. That stale value - // would read as "the target name is available" and defeat the absence-is-the-signal contract, so - // the property has to be removed explicitly. - previousProperties = previousProperties.RemoveResourceProperty(KnownProperties.Project.TargetName); - } - return previous with { ResourceType = previous.ResourceType ?? KnownResourceTypes.Project, State = state, ExitCode = executable.Status?.ExitCode, - Properties = previousProperties.SetResourcePropertyRange([.. projectProperties]), + Properties = previous.Properties.SetResourcePropertyRange([ + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Path, executable.Spec.ExecutablePath), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.WorkDir, executable.Spec.WorkingDirectory), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Args, effectiveArgs ?? [], isSensitive: true), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Executable.Pid, executable.Status?.ProcessId), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.Path, projectPath), + ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), + new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, + new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), StartTimeStamp = executable.Status?.StartupTimestamp?.ToUniversalTime(), diff --git a/src/Aspire.Hosting/IProjectMetadata.cs b/src/Aspire.Hosting/IProjectMetadata.cs index 845474feedf..b1f4ca6167e 100644 --- a/src/Aspire.Hosting/IProjectMetadata.cs +++ b/src/Aspire.Hosting/IProjectMetadata.cs @@ -31,25 +31,6 @@ public interface IProjectMetadata : IResourceAnnotation /// public bool SuppressBuild => false; - /// - /// Gets the target name that the project evaluates to, or when it is unknown. - /// - /// - /// - /// This value is baked into the generated project metadata when the AppHost is built. It is the MSBuild-evaluated - /// TargetName, which defaults to AssemblyName, rather than the project file name. That distinction - /// matters when a project sets TargetName because the launched assembly is then named after the target - /// instead of the assembly or project file. - /// - /// - /// Implementations that are not produced by the AppHost build - for example metadata created from a project - /// path at runtime, file-based apps, or third-party implementations - default to when - /// they cannot determine the evaluated target name. Consumers must therefore treat the value as an optional - /// hint and fall back to their existing behavior when it is absent. - /// - /// - public string? TargetName => null; - /// /// Gets a value indicating whether the project is a file-based app (a .cs file) rather than a full project (.csproj). /// diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 5ff8f24c545..574805eb806 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -222,15 +222,6 @@ internal static string ResourcePropertyParameterValueDisplayName { } } - /// - /// Looks up a localized string similar to Target name. - /// - internal static string ResourcePropertyProjectTargetNameDisplayName { - get { - return ResourceManager.GetString("ResourcePropertyProjectTargetNameDisplayName", resourceCulture); - } - } - /// /// Looks up a localized string similar to Launch profile. /// diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index 0e636cb8184..48397cad0f8 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -171,9 +171,6 @@ Value - - Target name - Launch profile diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index d129ef57d4c..f3aabb2f61c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index 53332b121cd..bc2a6356284 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index 25085c4cc9c..f8ed94f521f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index 24d1bf75b4b..2c74c66c1fb 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index 32d46f19bf2..9fc89535a7c 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index 1c104f4d6d3..c1d2d60294b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index d5886a5b722..8167488898a 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index 6564dcb3d70..ff7a68442b1 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 07283f7a1dc..17901aec38d 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index 1ee72f1e5eb..9c70a113dc0 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 6abf92baf4e..003f91a38df 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index 4d7b3980cc2..041eed0fb4e 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index d8ea34aab47..23302c2edbb 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -132,11 +132,6 @@ Project path - - Target name - Target name - - Tool package Tool package diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 605b1d43687..e7d6a482e9b 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -55,13 +55,6 @@ public static class Project { public const string Path = "project.path"; public const string LaunchProfile = "project.launchProfile"; - - /// - /// The MSBuild-evaluated target name of the project, baked into the generated project metadata at - /// AppHost build time. Only present for project resources added through a ProjectReference; the absence - /// of the property is the signal that the producer could not determine the name. - /// - public const string TargetName = "project.targetName"; } public static class Terminal diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 789b4ea5a1e..83c02f3da3f 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -330,34 +330,6 @@ public void MapToResourceJson_ResolvesWaitingForDependencies() Assert.Equal(["messaging"], result.WaitingFor); } - [Fact] - public void MapToResourceJson_PreservesProjectTargetNameProperty() - { - // The CLI/backchannel property bag is a pass-through, so the build-time project.targetName - // contract reaches `aspire describe` without any mapper-specific handling. - var resource = new ResourceSnapshot - { - Name = "frontend", - DisplayName = "frontend", - ResourceType = "Project", - State = "Running", - Properties = new Dictionary - { - ["project.path"] = JsonValue.Create("/repo/Worker/Worker.csproj"), - ["project.targetName"] = JsonValue.Create("My Attach Service") - } - }; - - var result = ResourceSnapshotMapper.MapToResourceJson(resource, [resource]); - - Assert.NotNull(result.Properties); - Assert.Equal("My Attach Service", result.Properties["project.targetName"]?.GetValue()); - - var json = JsonSerializer.Serialize(result, ResourcesCommandJsonContext.RelaxedEscaping.ResourceJson); - using var document = JsonDocument.Parse(json); - Assert.Equal("My Attach Service", document.RootElement.GetProperty("properties").GetProperty("project.targetName").GetString()); - } - [Fact] public void MapToResourceJson_MapsListPropertiesAsJsonArrays() { diff --git a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs index c245395d724..b026519809a 100644 --- a/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/KnownPropertyLookupTests.cs @@ -23,7 +23,6 @@ public void FindProperty_GenericResourceProperty_ReturnsKnownProperty() [Theory] [InlineData(KnownProperties.Project.Path)] [InlineData(KnownProperties.Project.LaunchProfile)] - [InlineData(KnownProperties.Project.TargetName)] [InlineData(KnownProperties.Executable.Path)] [InlineData(KnownProperties.Executable.WorkDir)] [InlineData(KnownProperties.Executable.Args)] diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index f276201ba7a..9d5389daa98 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -92,658 +92,6 @@ public async Task AddReferenceToDashboardAndDcpFallsBackToRuntimeIdentifierToolF AssertDashboardAndOrchestrationReferences(packageReferences); } - [Fact] - public async Task ProjectMetadataUsesTargetNameImportedFromDirectoryBuildProps() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The AssemblyName deliberately lives outside the project file. Reading the raw project XML - // would fall back to the file name ("Worker") and produce a process name that does not exist. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - - """, - referencedDirectoryBuildPropsXml: """ - - My Attach Service - - """); - - Assert.Equal(""" public string? TargetName => @"My Attach Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesProjectFileNameWhenTargetNameIsNotSet() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The overwhelmingly common case: no AssemblyName anywhere, so the evaluated name falls back to the project - // file name. Consumers rely on the property being present and correct here, not just in the exotic cases. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - - """); - - Assert.Equal(""" public string? TargetName => @"Worker";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesTargetNameInheritedFromAnAncestorDirectoryBuildProps() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The AssemblyName is several directory levels above the project, which is where a repo-wide convention - // usually lives. Only an MSBuild evaluation can see it. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - - """, - referencedProjectDirectoryName: "src/services/Worker", - ancestorDirectoryBuildPropsXml: """ - - Inherited Attach Service - - """); - - Assert.Equal(""" public string? TargetName => @"Inherited Attach Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesTargetNameWhenItDivergesFromAssemblyName() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // TargetName is what the built output is actually named, and an SDK is free to set it to something other - // than AssemblyName. The debugger attaches to the built assembly, so TargetName is the value that has to - // win; asserting the divergence keeps a future refactor from quietly switching to AssemblyName. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Declared Name - Rewritten By Sdk - - """); - - Assert.Equal(""" public string? TargetName => @"Rewritten By Sdk";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesConfigurationConditionedTargetName() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Released Service - Debugged Service - - """, - extraArguments: ["-p:Configuration=Release"], - configuration: "Release"); - - Assert.Equal(""" public string? TargetName => @"Released Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesProjectReferenceConfiguration() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Released Service - Debugged Service - - """, - projectReferenceMetadataXml: """ - Configuration=Release - """); - - Assert.Equal(""" public string? TargetName => @"Released Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesProjectReferencePlatform() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - 64-bit Service - Any CPU Service - - """, - projectReferenceMetadataXml: """ - Platform=x64 - """); - - Assert.Equal(""" public string? TargetName => @"64-bit Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataUsesSolutionPreparedProjectReferenceConfigurationAndPlatform() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Worker_$(Configuration)_$(Platform)_$(TargetFramework) - - """, - solutionProjectConfiguration: "Release|x64"); - - Assert.Equal(""" public string? TargetName => @"Worker_Release_x64_net8.0";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataSkipsTargetNameForReferenceDisabledInSolutionConfiguration() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // A reference the solution excludes from the build carries BuildReference=false, and ResolveProjectReferences - // skips it. The probe has to skip it too: nothing caches GetTargetPath for it, so probing costs a fresh - // evaluation of a project the build was told not to touch. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Disabled Service - - """, - solutionProjectConfiguration: "Debug|AnyCPU", - buildProjectInSolution: false); - - // Absence is the capability signal, so an unprobed reference must omit the member rather than guess a name. - Assert.Null(GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataRemovesProjectReferenceGlobalProperties() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Unflavored Service - Flavored Service - - """, - projectReferenceMetadataXml: """ - Flavor - """, - extraArguments: ["-p:Flavor=Chocolate"]); - - Assert.Equal(""" public string? TargetName => @"Unflavored Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataOmitsTargetNameForMultiTargetedReferenceWithoutSelectedTargetFramework() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0;net9.0 - Eight Service - Nine Service - - """); - - Assert.Null(GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataRespectsProjectReferenceTargetFramework() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0;net9.0 - Eight Service - Nine Service - - """, - projectReferenceMetadataXml: """ - TargetFramework=net9.0 - """); - - Assert.Equal(""" public string? TargetName => @"Nine Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataPreservesExplicitTargetFrameworkWhenItIsAlsoRemoved() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0;net9.0 - Eight Clean Service - Eight Flavored Service - Nine Clean Service - Nine Flavored Service - - """, - projectReferenceMetadataXml: """ - TargetFramework=net9.0 - Flavor;TargetFramework - """, - extraArguments: ["-p:Flavor=Chocolate"]); - - Assert.Equal(""" public string? TargetName => @"Nine Clean Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataEscapesTargetNameForCSharpSource() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Ünicode "quoted" O'Brien - - """); - - Assert.Equal(""" public string? TargetName => @"Ünicode ""quoted"" O'Brien";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataResolvesTargetNameWhenProjectDirectoryContainsAnApostrophe() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // An apostrophe in the project path reaches two different MSBuild property functions: the - // GetFullPath over %(Identity) that normalizes ProjectPath, and the GetFullPath over the - // $(_AspireResolvedProjectFile) property that normalizes the resolved project file before the - // two lists are correlated. Both have to survive it, because a failure here does not degrade - // the target name - it fails metadata generation and takes the whole AppHost build with it. - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - Apostrophe Service - - """, - referencedProjectDirectoryName: "O'Brien"); - - Assert.Equal(""" public string? TargetName => @"Apostrophe Service";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectMetadataOmitsTargetNameWhenResolutionIsDisabled() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var generatedSource = await GenerateProjectMetadataSourceAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - My Attach Service - - """, - extraArguments: ["-p:SkipAspireProjectResourceTargetName=true"]); - - Assert.Null(GetGeneratedTargetNameMember(generatedSource)); - } - - [Theory] - [InlineData("GetTargetFrameworks")] - [InlineData("GetTargetPath")] - public async Task ProjectMetadataIsStillGeneratedWhenTheTargetNameProbeFails(string probedTarget) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // Hooking the probed target from the referenced project fails exactly the evaluation the probe performs and - // nothing else. This runs the codegen target on its own, so nothing has resolved the project references and - // the probe is the only thing asking - which is the case the probe still exists for. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: $""" - - Exe - net8.0 - - - - - """, - extraArguments: ["-p:BuildingProject=true"]); - - // Without this the assertions below would also pass on a build where the hook never ran and the probe - // simply succeeded with no name to report. - Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); - - // TargetName is a debugger hint, not a build input. A probe that cannot answer has to degrade to omitting - // the member - attach consumers fall back to TargetPath from there - rather than stopping the target and - // leaving the AppHost with no reference metadata at all. - Assert.True(File.Exists(result.GeneratedPath), $"Generated project metadata was not found at '{result.GeneratedPath}'.{Environment.NewLine}{result.DotNetResult.Output}"); - - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - Assert.Contains("Worker.csproj", generatedSource); - Assert.Null(GetGeneratedTargetNameMember(generatedSource)); - } - - [Theory] - [InlineData("GetTargetFrameworks")] - [InlineData("GetTargetPath")] - public async Task ProjectMetadataTargetNameProbeFailureStillFailsTheBuild(string probedTarget) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var referencedProjectXml = $""" - - Exe - net8.0 - - - - - """; - - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml, - extraArguments: ["-p:BuildingProject=true"]); - - // ContinueOnError keeps the codegen target running, but it does not demote the error the referenced - // project logged: that error belongs to the reference's own build, so the build still fails. The probe - // cannot isolate itself from that, which is why an ordinary build no longer runs it at all - see - // ProjectMetadataTargetNameComesFromTheReferenceBuildWithoutProbingIt. Where it does still run, it has to - // explain itself: a build that fails inside a target the user never asked for has to say where the failure - // came from and how to turn the probe off, because the target name it collects is only a debugger hint. - Assert.NotEqual(0, result.DotNetResult.ExitCode); - Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); - Assert.Contains("set SkipAspireProjectResourceTargetName=true to skip this evaluation", result.DotNetResult.Output); - - using var controlWorkspace = TemporaryWorkspace.Create(outputHelper); - - // The same project, with only the probe turned off, builds cleanly. That is what attributes the failure - // above to the probe rather than to a project that was broken to begin with, and it also shows neither - // probed target runs on an Aspire project reference during an ordinary build. - var controlResult = await RunProjectMetadataSourceGenerationAsync( - controlWorkspace, - referencedProjectXml, - extraArguments: ["-p:BuildingProject=true", "-p:SkipAspireProjectResourceTargetName=true"]); - - Assert.True(controlResult.DotNetResult.ExitCode == 0, controlResult.DotNetResult.Output); - } - - [Fact] - public async Task ProjectMetadataTargetNameComesFromTheReferenceBuildWithoutProbingIt() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The same hook that fails the probe above, on the one probed target an ordinary build does not otherwise - // reach. Reaching it is now the failure being pinned: a build that resolves its references has already - // produced the target path, so there is no probe left to run. AssemblyName differs from the project file - // name so a name sourced from anywhere but the reference's real build output shows up as a wrong value. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - RenamedWorker - - - - - """, - // ResolveReferences rather than Build: it runs ResolveProjectReferences, which is the step a real build - // performs before CoreCompile and the step that captures the reference's output. Compiling the AppHost - // itself would need the Aspire.Hosting reference the generated source derives from, which this - // workspace deliberately does not have. - msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - Assert.Equal(""" public string? TargetName => @"RenamedWorker";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task ProjectResourcesReachedThroughASymbolicLinkStillGetTheirTargetName() - { - Assert.SkipWhen(OperatingSystem.IsWindows(), "Creating a directory symbolic link on Windows needs elevation or developer mode."); - - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The resolved target path is matched back to its metadata source by project path, and the two sides do not - // have to spell that path the same way: the ProjectReference goes through the link, while MSBuild reports - // %(MSBuildSourceProjectFile) for whatever the link resolves to. AssemblyName differs from the project file - // name so a target name that came from anywhere but this reference is visible as a wrong value rather than - // as a coincidentally right one. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - LinkedWorker - - """, - msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources", - symlinkedReferenceDirectoryName: "LinkToWorker"); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - - // Pins that the reference really was reached through the link rather than through the directory it points - // at, so the assertion below is about a path the two sides could spell differently. - Assert.Contains("LinkToWorker", generatedSource, StringComparison.Ordinal); - Assert.Equal(""" public string? TargetName => @"LinkedWorker";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task AspireProjectResourcesCaptureTheirBuildOutputForTheTargetNameHint() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var sdkTargetsPath = SecurityElement.Escape(Path.Combine(GetRepoRoot(), "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets")); - var projectFile = Path.Combine(workspace.Path, "Host.csproj"); - - // The AppHost targets read @(_AspireProjectResourceBuildOutput), which only exists because the SDK defaults - // OutputItemType on every Aspire project resource. Nothing else in these tests evaluates the SDK targets - - // the AppHost harness spells the metadata out - so this is what keeps the two from drifting apart. - await File.WriteAllTextAsync(projectFile, - $$""" - - - - - - net8.0 - true - - - - - - - - - - - - - - - - - - """); - - var result = await RunDotNetWithArgumentsAsync(workspace.Path, ["msbuild", "-nologo", "-t:ReportOutputItemType", projectFile]); - - Assert.True(result.ExitCode == 0, result.Output); - Assert.Contains("OUTPUTITEMTYPE Resource: [_AspireProjectResourceBuildOutput]", result.Output); - - // A reference opted out of being a resource is an ordinary reference, and one that already routes its - // outputs somewhere keeps doing so: capturing the target name must not take an item type from its owner. - Assert.Contains("OUTPUTITEMTYPE Library: []", result.Output); - Assert.Contains("OUTPUTITEMTYPE Claimed: [SomeoneElsesItem]", result.Output); - } - - [Fact] - public async Task ProjectResourcesRoutingTheirOutputElsewhereAreStillProbed() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // Worker keeps a caller-supplied OutputItemType, so the SDK's default does not apply to it and its build - // output never reaches the collection the seeding reads. Worker2 uses the default and does reach it. Only - // Worker still needs probing, and a build that captured something for one reference must not conclude it - // captured something for all of them. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - ProbedWorker - - """, - projectReferenceMetadataXml: """ - SomeoneElsesItem - """, - msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources", - secondReferencedProjectXml: """ - - Exe - net8.0 - CapturedWorker - - """); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - Assert.Equal(""" public string? TargetName => @"ProbedWorker";""", GetGeneratedTargetNameMember(generatedSource)); - - var secondGeneratedPath = Path.Combine(Path.GetDirectoryName(result.GeneratedPath)!, "Worker2.ProjectMetadata.g.cs"); - var secondGeneratedSource = await File.ReadAllTextAsync(secondGeneratedPath); - Assert.Equal(""" public string? TargetName => @"CapturedWorker";""", GetGeneratedTargetNameMember(secondGeneratedSource)); - } - - [Fact] - public async Task ProjectResourcesRoutingTheirOutputElsewhereDoNotFailTheBuildThatResolvedThem() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // GetTargetFrameworks is the half of the probe that can fail a reference which builds perfectly well: - // Aspire project references default SkipGetTargetFrameworkProperties=true, so nothing in a normal build - // asks for it. A reference whose output the build routed to a caller-supplied OutputItemType is not in the - // capture the seeding reads, and it is also a reference an ordinary AppHost build resolves - so collecting - // its name must stay inside what that build already asked for. It already asked for GetTargetPath, which is - // what GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt pins, so that is all this asks too. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - RoutedWorker - - - - - """, - projectReferenceMetadataXml: """ - SomeoneElsesItem - """, - msbuildTarget: "ResolveReferences;WriteAspireProjectMetadataSources"); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - - // Asserting the name resolved anyway is what keeps this from passing on a build that simply stopped - // collecting target names for the reference whose output was not captured. - var generatedSource = await File.ReadAllTextAsync(result.GeneratedPath); - Assert.Equal(""" public string? TargetName => @"RoutedWorker";""", GetGeneratedTargetNameMember(generatedSource)); - } - - [Fact] - public async Task GetTargetPathIsReachedOnProjectResourcesWithoutAspireAskingForIt() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - // The target name probe is switched off entirely, so nothing Aspire contributes asks the reference for - // GetTargetPath. Resolving project references still reaches it, which is what separates the two probed - // targets: a reference that cannot answer GetTargetPath cannot be referenced by any project, Aspire or - // not, so that half of the probe adds no failure mode of its own. GetTargetFrameworks is the half that - // did, and the test above covers it. - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml: """ - - Exe - net8.0 - - - - - """, - extraArguments: ["-p:SkipAspireProjectResourceTargetName=true"], - msbuildTarget: "ResolveReferences"); - - Assert.NotEqual(0, result.DotNetResult.ExitCode); - Assert.Contains("aspire target name probe hook failed", result.DotNetResult.Output); - } - [Fact] public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled() { @@ -1395,239 +743,6 @@ await File.WriteAllTextAsync(Path.Combine(projectDirectory, "AppHost.csproj"), return await File.ReadAllLinesAsync(packageReferencesPath); } - /// - /// Builds a throwaway AppHost that ProjectReferences a single worker project, runs the Aspire - /// codegen target, and returns the generated IProjectMetadata source for the worker. - /// - private static async Task GenerateProjectMetadataSourceAsync( - TemporaryWorkspace workspace, - string referencedProjectXml, - string? referencedDirectoryBuildPropsXml = null, - string[]? extraArguments = null, - string targetFramework = "net8.0", - string configuration = "Debug", - string? projectReferenceMetadataXml = null, - string? solutionProjectConfiguration = null, - string referencedProjectDirectoryName = "Worker", - string? ancestorDirectoryBuildPropsXml = null, - bool buildProjectInSolution = true) - { - var result = await RunProjectMetadataSourceGenerationAsync( - workspace, - referencedProjectXml, - referencedDirectoryBuildPropsXml, - extraArguments, - targetFramework, - configuration, - projectReferenceMetadataXml, - solutionProjectConfiguration, - referencedProjectDirectoryName, - ancestorDirectoryBuildPropsXml, - buildProjectInSolution); - - Assert.True(result.DotNetResult.ExitCode == 0, result.DotNetResult.Output); - Assert.True(File.Exists(result.GeneratedPath), $"Generated project metadata was not found at '{result.GeneratedPath}'.{Environment.NewLine}{result.DotNetResult.Output}"); - - return await File.ReadAllTextAsync(result.GeneratedPath); - } - - private static async Task RunProjectMetadataSourceGenerationAsync( - TemporaryWorkspace workspace, - string referencedProjectXml, - string? referencedDirectoryBuildPropsXml = null, - string[]? extraArguments = null, - string targetFramework = "net8.0", - string configuration = "Debug", - string? projectReferenceMetadataXml = null, - string? solutionProjectConfiguration = null, - string referencedProjectDirectoryName = "Worker", - string? ancestorDirectoryBuildPropsXml = null, - bool buildProjectInSolution = true, - string msbuildTarget = "WriteAspireProjectMetadataSources", - string? secondReferencedProjectXml = null, - string? secondProjectReferenceMetadataXml = null, - string? symlinkedReferenceDirectoryName = null) - { - var repoRoot = GetRepoRoot(); - - // Terminate MSBuild's upward Directory.Build.props/targets probe at the workspace root so the - // generated metadata only reflects what this test authored, not whatever happens to sit above - // the temp directory on the machine running the test. Directory.Packages.props is discovered - // independently of that probe, so central package management has to be switched off explicitly. - await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.props"), ""); - await File.WriteAllTextAsync(Path.Combine(workspace.Path, "Directory.Build.targets"), ""); - await File.WriteAllTextAsync( - Path.Combine(workspace.Path, "Directory.Packages.props"), - "false"); - - var workerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, referencedProjectDirectoryName)).FullName; - var workerProjectFile = Path.Combine(workerDirectory, "Worker.csproj"); - await File.WriteAllTextAsync(workerProjectFile, - $""" - - - {referencedProjectXml} - - - """); - await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Program.cs"), """ - System.Console.WriteLine("worker"); - """); - - if (symlinkedReferenceDirectoryName is not null) - { - // The reference is reached through a link while MSBuild reports %(MSBuildSourceProjectFile) for the - // directory the link points at, which is the shape that would break a correlation done by comparing - // the two spellings of the path. - Directory.CreateSymbolicLink(Path.Combine(workspace.Path, symlinkedReferenceDirectoryName), workerDirectory); - } - - if (referencedDirectoryBuildPropsXml is not null) - { - await File.WriteAllTextAsync(Path.Combine(workerDirectory, "Directory.Build.props"), - $""" - - - {referencedDirectoryBuildPropsXml} - - - """); - } - - if (ancestorDirectoryBuildPropsXml is not null) - { - // Written to the top-most segment of the referenced project's path rather than next to the project, so - // the value is only visible after MSBuild's upward Directory.Build.props probe has climbed several - // levels. A consumer that reads the project XML cannot see it at all. - var ancestorDirectory = Path.Combine(workspace.Path, referencedProjectDirectoryName.Split('/')[0]); - await File.WriteAllTextAsync(Path.Combine(ancestorDirectory, "Directory.Build.props"), - $""" - - - {ancestorDirectoryBuildPropsXml} - - - """); - } - - if (secondReferencedProjectXml is not null) - { - var secondWorkerDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "Worker2")).FullName; - await File.WriteAllTextAsync(Path.Combine(secondWorkerDirectory, "Worker2.csproj"), - $""" - - - {secondReferencedProjectXml} - - - """); - await File.WriteAllTextAsync(Path.Combine(secondWorkerDirectory, "Program.cs"), """ - System.Console.WriteLine("worker2"); - """); - } - - var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "AppHost")).FullName; - var appHostTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets")); - var appHostProjectFile = Path.Combine(appHostDirectory, "AppHost.csproj"); - var solutionConfigurationXml = solutionProjectConfiguration is null - ? null - : $$""" - - <SolutionConfiguration><ProjectConfiguration Project="{C42D47BF-C684-40EB-B438-FC98C4DC6F5D}" AbsolutePath="{{SecurityElement.Escape(workerProjectFile)}}" BuildProjectInSolution="{{(buildProjectInSolution ? "True" : "False")}}">{{solutionProjectConfiguration}}</ProjectConfiguration></SolutionConfiguration> - - """; - - var secondProjectReferenceXml = secondReferencedProjectXml is null - ? null - : $""" - - {secondProjectReferenceMetadataXml} - - """; - - // The SDK props/targets are imported explicitly so the Aspire AppHost targets land *after* - // Sdk.targets, which is where a NuGet package's build/*.targets normally gets imported. The - // ordering matters because the codegen writes to $(IntermediateOutputPath), which is only - // defined once Microsoft.Common.CurrentVersion.targets has been evaluated. - // The ProjectReference metadata mirrors what Aspire.AppHost.Sdk defaults for Aspire project - // resources; this test imports only the AppHost targets, so the defaults are spelled out. - await File.WriteAllTextAsync(appHostProjectFile, - $$""" - - - - - - Exe - {{targetFramework}} - true - - 9.0.0 - <_AspireTasksAssembly>{{SecurityElement.Escape(GetAspireHostingTasksAssemblyPath())}} - true - true - - - - - {C42D47BF-C684-40EB-B438-FC98C4DC6F5D} - {{projectReferenceMetadataXml}} - - {{secondProjectReferenceXml}} - - - {{solutionConfigurationXml}} - - - - - - - """); - await File.WriteAllTextAsync(Path.Combine(appHostDirectory, "Program.cs"), """ - System.Console.WriteLine("apphost"); - """); - - var arguments = new List - { - "msbuild", - "-nologo", - "-restore", - $"-t:{msbuildTarget}", - appHostProjectFile - }; - - if (extraArguments is not null) - { - arguments.AddRange(extraArguments); - } - - var result = await RunDotNetWithArgumentsAsync(appHostDirectory, [.. arguments]); - var generatedPath = Path.Combine(appHostDirectory, "obj", configuration, targetFramework, "Aspire", "references", "Worker.ProjectMetadata.g.cs"); - return new ProjectMetadataSourceGenerationResult(result, generatedPath); - } - - private static string? GetGeneratedTargetNameMember(string generatedSource) - { - return generatedSource - .Split('\n') - .Select(line => line.TrimEnd('\r')) - .SingleOrDefault(line => line.Contains("TargetName =>", StringComparison.Ordinal)); - } - private static async Task CreateRunHookProjectAsync( string workspace, bool aspireUseCliBundle, @@ -2266,8 +1381,6 @@ private static string GetRepoRoot() private sealed record RunHookProject(string ProjectDirectory, string ProjectFile); - private sealed record ProjectMetadataSourceGenerationResult(DotNetResult DotNetResult, string GeneratedPath); - private sealed record DotNetResult(int ExitCode, string StandardOutput, string StandardError) { public string Output => StandardOutput + StandardError; diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 8ef5e880df2..7f8a83a822c 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Immutable; using Aspire.Dashboard.Model; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -85,163 +84,6 @@ public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties() AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 2); } - [Fact] - public void ProjectSnapshotAddsTargetNameWhenProjectMetadataSuppliesIt() - { - var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { TargetName = "My Attach Service" }); - project.Annotations.Add(new LaunchProfileAnnotation("https")); - - var executable = Executable.Create("project", "dotnet"); - executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); - executable.Status = new ExecutableStatus - { - EffectiveArgs = ["run"], - ProcessId = 1234 - }; - - var snapshot = CreateSnapshotBuilder(new Dictionary - { - [project.Name] = project - }).ToSnapshot(executable, CreatePreviousSnapshot()); - - AssertHighlightedProperty(snapshot, KnownProperties.Project.TargetName, "Target name", isSensitive: false, sortOrder: 3); - Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.TargetName).Value); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void ProjectSnapshotOmitsTargetNameWhenProjectMetadataDoesNotSupplyIt(string? targetName) - { - var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { TargetName = targetName }); - project.Annotations.Add(new LaunchProfileAnnotation("https")); - - var executable = Executable.Create("project", "dotnet"); - executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); - executable.Status = new ExecutableStatus - { - EffectiveArgs = ["run"], - ProcessId = 1234 - }; - - var snapshot = CreateSnapshotBuilder(new Dictionary - { - [project.Name] = project - }).ToSnapshot(executable, CreatePreviousSnapshot()); - - // Assert the complete property set rather than the absence of one name, so that a future property added - // to the project branch has to be acknowledged here instead of silently slipping through. - Assert.Equal( - [ - KnownProperties.Executable.Args, - KnownProperties.Executable.Path, - KnownProperties.Executable.Pid, - KnownProperties.Executable.WorkDir, - KnownProperties.Project.LaunchProfile, - KnownProperties.Project.Path, - KnownProperties.Resource.AppArgs, - KnownProperties.Resource.AppArgsSensitivity, - ], - snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void ProjectSnapshotRemovesStaleTargetNameWhenProjectMetadataNoLongerSuppliesIt(string? targetName) - { - var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { TargetName = targetName }); - project.Annotations.Add(new LaunchProfileAnnotation("https")); - - var executable = Executable.Create("project", "dotnet"); - executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); - executable.Status = new ExecutableStatus - { - EffectiveArgs = ["run"], - ProcessId = 1234 - }; - - // Snapshots are merged into the previously published one, so a carried-forward target name has to be - // removed rather than just omitted. Absence is the capability signal, and a surviving stale value would - // tell consumers the evaluated target name is still available. - var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.TargetName, "Stale.Target.Name")]); - - var snapshot = CreateSnapshotBuilder(new Dictionary - { - [project.Name] = project - }).ToSnapshot(executable, previous); - - Assert.Equal( - [ - KnownProperties.Executable.Args, - KnownProperties.Executable.Path, - KnownProperties.Executable.Pid, - KnownProperties.Executable.WorkDir, - KnownProperties.Project.LaunchProfile, - KnownProperties.Project.Path, - KnownProperties.Resource.AppArgs, - KnownProperties.Resource.AppArgsSensitivity, - ], - snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); - } - - [Fact] - public void ProjectSnapshotReplacesStaleTargetNameWhenProjectMetadataStillSuppliesIt() - { - var project = new ProjectResource("project"); - project.Annotations.Add(new TestProjectMetadata { TargetName = "My Attach Service" }); - project.Annotations.Add(new LaunchProfileAnnotation("https")); - - var executable = Executable.Create("project", "dotnet"); - executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); - executable.Status = new ExecutableStatus - { - EffectiveArgs = ["run"], - ProcessId = 1234 - }; - - var previous = CreatePreviousSnapshot(properties: [new(KnownProperties.Project.TargetName, "Stale.Target.Name")]); - - var snapshot = CreateSnapshotBuilder(new Dictionary - { - [project.Name] = project - }).ToSnapshot(executable, previous); - - Assert.Equal("My Attach Service", GetProperty(snapshot, KnownProperties.Project.TargetName).Value); - } - - [Fact] - public void ExecutableSnapshotWithoutProjectMetadataOmitsTargetName() - { - var executable = Executable.Create("exe", "dotnet"); - executable.Spec.WorkingDirectory = "/app"; - executable.Status = new ExecutableStatus - { - EffectiveArgs = ["run"], - ProcessId = 1234 - }; - - var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot()); - - // An executable that is not a project resource never takes the project branch, so it gets the executable - // property set plus the shared resource properties, and no project properties at all. - Assert.Equal( - [ - KnownProperties.Executable.Args, - KnownProperties.Executable.Path, - KnownProperties.Executable.Pid, - KnownProperties.Executable.WorkDir, - KnownProperties.Resource.AppArgs, - KnownProperties.Resource.AppArgsSensitivity, - ], - snapshot.Properties.Select(p => p.Name).Order(StringComparer.Ordinal)); - } - [Fact] public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations() { @@ -410,12 +252,12 @@ private static DcpResourceSnapshotBuilder CreateSnapshotBuilder(IDictionary(), [])); } - private static CustomResourceSnapshot CreatePreviousSnapshot(string resourceType = "resource", ImmutableArray properties = default) + private static CustomResourceSnapshot CreatePreviousSnapshot(string resourceType = "resource") { return new() { ResourceType = resourceType, - Properties = properties.IsDefault ? [] : properties + Properties = [] }; } @@ -452,8 +294,6 @@ private sealed class TestProjectMetadata : IProjectMetadata { public string ProjectPath => "/app/project.csproj"; - public string? TargetName { get; init; } - public LaunchSettings LaunchSettings { get; } = new() { Profiles = diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs index c18739e09e5..1b893626839 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceBuilderExtensionTests.cs @@ -106,27 +106,6 @@ public void WithProjectDefaultsAppliesToAProjectResourceThatWasAddedDirectly() Assert.Single(project.Resource.Annotations.OfType()); } - [Fact] - public void ProjectMetadataTargetNameDefaultsToNullForImplementationsThatDoNotSupplyIt() - { - // TargetName is a default interface member so metadata types that shipped before the - // build-time contract existed (external implementations, path-based and file-based apps) - // stay source and binary compatible. - IProjectMetadata metadata = new TestProject(); - - Assert.Null(metadata.TargetName); - } - - [Fact] - public void ProjectMetadataTargetNameIsNullForPathBasedProjects() - { - using var builder = TestDistributedApplicationBuilder.Create(); - - var project = builder.AddProject("project", Path.Combine(AppContext.BaseDirectory, "project.csproj"), options => options.ExcludeLaunchProfile = true); - - Assert.Null(project.Resource.GetProjectMetadata().TargetName); - } - [Fact] public void WithProjectDefaultsThrowsWhenResourceHasMultipleProjectMetadataAnnotations() { diff --git a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt index 7dfa1bec958..13bc2952277 100644 --- a/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt +++ b/tests/Aspire.Hosting.Tests/Snapshots/MSBuildTests.ValidateMetadataSources.verified.txt @@ -42,17 +42,6 @@ public class App : global::Aspire.Hosting.IProjectMetadata /// public string ProjectPath => """{AspirePath}/App/App.csproj"""; - /// - /// The target name that the App project builds to. - /// - /// - /// Evaluated by MSBuild when this AppHost was built, so it reflects any TargetName set by the project or - /// imported into it rather than the project file name. - /// -#nullable enable - public string? TargetName => @"App"; -#nullable restore - /// /// Gets a value indicating whether building the project before running it should be suppressed. /// From 099175f3a9fb91fb2ce9bb222b69f958fa8126e3 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 11:40:51 -0400 Subject: [PATCH 36/90] Fix attach target process selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- extension/loc/xlf/aspire-vscode.xlf | 18 ++++++++++++ extension/package.json | 2 +- extension/src/debugger/languages/dotnet.ts | 34 +++++++++++++++++----- extension/src/test/appHostTreeView.test.ts | 31 ++++++++++++++++---- extension/src/test/dotnetDebugger.test.ts | 8 ++--- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index e5addab3b88..b1d33dc611e 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -79,6 +79,12 @@ Aspire: Launch default AppHost + + Attach debugger + + + Attach debugger: {0} + Attempted to start unsupported resource type: {0}. @@ -361,6 +367,9 @@ Install the Aspire CLI + + Install the C# extension to attach the debugger to .NET project resources. + Invalid launch configuration for {0}. @@ -727,6 +736,9 @@ The pipeline step name to execute when command is 'do' + + The selected resource is no longer available. Refresh the Aspire pane and try again. + This command has dynamic inputs that the Aspire extension cannot prompt for yet. Run it from the Aspire Dashboard or Aspire CLI instead. @@ -736,6 +748,9 @@ This field is required. + + This resource is not a running .NET project resource that can be attached with the C# debugger. + This setting has been renamed to aspire.appHostsPollingInterval. @@ -766,6 +781,9 @@ VS Code did not start the Aspire {0} session for {1}. + + VS Code did not start the debugger attach session for {0}. + Value missing diff --git a/extension/package.json b/extension/package.json index fdb45a2fed7..963a0a8c73b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1120,7 +1120,7 @@ "uuid": "14.0.0", "tmp": "0.2.7", "@nevware21/ts-utils": "0.14.0", - "fast-uri": "3.1.5", + "fast-uri": "3.1.4", "qs": "6.15.2", "ws": "8.21.0", "js-yaml": "4.3.0", diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 656cb3fb7bd..99e6cdfe603 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -33,7 +33,7 @@ interface IDotNetService { } interface DotNetAttachDebuggerResourceInfo { - processId: number; + projectPath: string; resourceLabel: string; } @@ -416,8 +416,7 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho return undefined; } - const processId = getAttachDebuggerProcessId(resource); - if (processId === undefined) { + if (getAttachDebuggerProcessId(resource) === undefined) { return undefined; } @@ -435,7 +434,7 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho } return { - processId, + projectPath, resourceLabel: resource.displayName ?? resource.name, }; } @@ -473,17 +472,36 @@ function isDotNetExecutable(resource: DebuggableResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot): vscode.DebugConfiguration { +async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, dotNetService: IDotNetService): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } + let targetPath: string; + try { + targetPath = await dotNetService.getDotNetTargetPath(attachInfo.projectPath); + } + catch (error) { + throw new AttachDebuggerConfigurationError( + 'ResourceNotAttachable', + error instanceof Error ? error.message : String(error)); + } + + // `executable.pid` is the DCP launcher (`dotnet run`), not necessarily the managed + // application process. Use the C# debugger's process-name selector instead, deriving + // the name from the same TargetPath evaluation used by the normal project launch path. + const fileName = targetPath.trim().split(/[\\/]/).pop() ?? ''; + const processName = fileName.replace(/\.(dll|exe)$/i, ''); + if (processName.length === 0) { + throw new AttachDebuggerConfigurationError('ResourceNotAttachable', noOutputFromMsbuild); + } + return { type: 'coreclr', request: 'attach', name: attachDebuggerConfigurationName(attachInfo.resourceLabel), - processId: String(attachInfo.processId), + processName, }; } @@ -502,7 +520,9 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); }, canAttachToResource: (resource) => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, - createAttachDebugSessionConfigurationCallback: async (resource): Promise => createDotNetAttachDebugSessionConfiguration(resource), + createAttachDebugSessionConfigurationCallback: async (resource): Promise => { + return await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(undefined)); + }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 43cb9771fa7..58e90435c99 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as capabilities from '../capabilities'; +import * as debuggerExtensions from '../debugger/debuggerExtensions'; import * as cliModule from '../debugger/languages/cli'; import * as cliPathModule from '../utils/cliPath'; import * as configInfoProvider from '../utils/configInfoProvider'; @@ -2467,7 +2468,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('attachDebuggerToResource starts CoreCLR with the selected resource process ID', async () => { + test('attachDebuggerToResource starts CoreCLR with the resource attach configuration', async () => { const provider = makeTreeProvider([ makeAppHost({ resources: [ @@ -2482,6 +2483,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ]); sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processName: 'Api', + }); const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); @@ -2490,12 +2497,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { assert.strictEqual(configuration.type, 'coreclr'); assert.strictEqual(configuration.request, 'attach'); assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processId, '4242'); - assert.strictEqual(configuration.processName, undefined); + assert.strictEqual(configuration.processId, undefined); + assert.strictEqual(configuration.processName, 'Api'); provider.dispose(); }); - test('attachDebuggerToResource uses the latest resource process ID', async () => { + test('attachDebuggerToResource creates the configuration from the latest resource snapshot', async () => { const appHost = makeAppHost({ resources: [ makeResource({ @@ -2509,6 +2516,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }); const provider = makeTreeProvider([appHost]); sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const createConfigurationStub = sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processName: 'Api', + }); const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); const resourceItem = getFirstResourceItem(provider); appHost.resources = [ @@ -2523,8 +2536,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { await (provider as any).attachDebuggerToResource(resourceItem); - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.processId, '5252'); + assert.ok(startDebuggingStub.calledOnce); + assert.strictEqual(createConfigurationStub.firstCall.args[0].properties?.['executable.pid'], '5252'); provider.dispose(); }); @@ -2633,6 +2646,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ]); sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processName: 'Api', + }); sandbox.stub(vscode.debug, 'startDebugging').resolves(false); await assert.rejects( diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 544aff4c140..74615107267 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -61,7 +61,7 @@ suite('Dotnet Debugger Extension Tests', () => { return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; } - test('attach configuration uses the selected resource process ID without evaluating TargetPath', async () => { + test('attach configuration uses the project TargetPath process name instead of the launcher process ID', async () => { const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ @@ -79,9 +79,9 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(configuration.type, 'coreclr'); assert.strictEqual(configuration.request, 'attach'); assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processId, '1234'); - assert.strictEqual(configuration.processName, undefined); - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + assert.strictEqual(configuration.processId, undefined); + assert.strictEqual(configuration.processName, 'FromTargetPath'); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj')); }); test('attach configuration rejects file-based project resources', async () => { From f5d05a029ae7711e3e9693d258117aec2a36b8c9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 19:53:08 -0400 Subject: [PATCH 37/90] Scope resource attach refresh to its AppHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c235246b-d021-4d8e-b041-cc4984674ebe --- extension/src/test/appHostTreeView.test.ts | 50 +++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 11 +++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 58e90435c99..7fab34c5a94 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -2541,6 +2541,56 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource refreshes duplicate resource names from the owning AppHost', async () => { + const appHosts = [ + makeAppHost({ + appHostPath: '/repo/first/AppHost.csproj', + appHostPid: 1111, + resources: [ + makeResource({ + name: 'api', + displayName: 'First API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '111' }), + }), + ], + }), + makeAppHost({ + appHostPath: '/repo/second/AppHost.csproj', + appHostPid: 2222, + resources: [ + makeResource({ + name: 'api', + displayName: 'Second API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': '222' }), + }), + ], + }), + ]; + const provider = makeTreeProvider(appHosts); + sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + const createConfigurationStub = sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: Second API', + processName: 'SecondApi', + }); + sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const secondAppHostItem = provider.getChildren()[1]; + const resourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(resourcesGroup, 'Expected resources group for the second AppHost'); + const secondResourceItem = provider.getChildren(resourcesGroup)[0]; + + await (provider as any).attachDebuggerToResource(secondResourceItem); + + assert.strictEqual(createConfigurationStub.firstCall.args[0].displayName, 'Second API'); + assert.strictEqual(createConfigurationStub.firstCall.args[0].properties?.['executable.pid'], '222'); + provider.dispose(); + }); + test('attachDebuggerToResource rejects a resource removed before invocation', async () => { const appHosts = [ makeAppHost({ diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index c908e124286..c3793a3d246 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1493,7 +1493,16 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - const latestElement = this.findResourceElement(element.resource.name, element.appHostPath); + // Global resource items retain the AppHost PID rather than its path. Resolve that + // owner again before refreshing the resource snapshot so duplicate resource names + // in different AppHosts cannot attach to whichever host happens to render first. + const ownerAppHostPath = element.appHostPath ?? this._findAppHostForResource(element)?.appHostPath; + if (!ownerAppHostPath) { + vscode.window.showWarningMessage(attachDebuggerResourceNotFound); + return { success: false, errorKind: 'ResourceNotFound' }; + } + + const latestElement = this.findResourceElement(element.resource.name, ownerAppHostPath); if (!(latestElement instanceof ResourceItem)) { vscode.window.showWarningMessage(attachDebuggerResourceNotFound); return { success: false, errorKind: 'ResourceNotFound' }; From 17ec4de80d9dc64a28dc96dfcf6e7887773682f7 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 18:48:03 -0400 Subject: [PATCH 38/90] Surface explicit launch metadata for debugger attach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 96775000-0503-484a-9738-d68c9300ff52 --- docs/specs/cli-output-formats.md | 2 +- extension/src/debugger/languages/dotnet.ts | 26 +++--- extension/src/test/dotnetDebugger.test.ts | 68 +++++++++++++++ .../Dcp/ResourceSnapshotBuilder.cs | 13 +++ src/Shared/Model/KnownProperties.cs | 1 + .../ResourceSnapshotMapperTests.cs | 25 ++++++ .../Commands/DescribeCommandTests.cs | 86 +++++++++++++++++++ .../Dcp/ResourceSnapshotBuilderTests.cs | 27 ++++++ 8 files changed, 237 insertions(+), 11 deletions(-) diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index 350f22e00e8..fc59eec5035 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -169,7 +169,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream | `relationships` | Related resources as `{ "type": "...", "resourceName": "..." }`. | | `urls` | Endpoint objects with `name`, `displayName`, `url`, and `isInternal`. | | `volumes` | Volume objects with `source`, `target`, `mountType`, and `isReadOnly`. | -| `properties` | Resource properties keyed by property name. | +| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, and `resource.launchConfigurationType`. | | `environment` | Environment variables keyed by variable name. | | `healthReports` | Health report objects keyed by report name. | | `commands` | Resource command metadata keyed by command name. | diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index ef0a1467ee8..578f424084e 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -42,6 +42,7 @@ const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; const resourceParentNamePropertyName = 'resource.parentName'; +const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); export class DotNetService implements IDotNetService { @@ -404,16 +405,16 @@ function configureDotNetRunDebugConfiguration( } function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { - // The parent check is deliberately broader than it needs to be. MAUI platform resources derive from - // ProjectResource (MauiMacCatalystPlatformResource : ProjectResource, IMauiPlatformResource), so they - // report resourceType 'Project' and are only distinguishable from an ordinary project by their parent - - // the launch configuration type that actually names them as MAUI ('maui', MauiPlatformHelper) never - // reaches the resource snapshot. Attaching coreclr by TargetName to an app running on a device or - // simulator would be wrong, so a project with a parent is skipped. The cost is that an ordinary project - // given a parent purely for grouping (WithParentRelationship) also loses the attach action; that is a - // missing menu entry rather than a debugger pointed at the wrong process, so it is the safer side to err - // on until the snapshot carries something that names the debugger a resource needs. - if (resource.resourceType !== 'Project' || resource.state !== 'Running' || getResourceParentName(resource) !== null) { + if (resource.resourceType !== 'Project' || resource.state !== 'Running') { + return undefined; + } + + const launchConfigurationType = getLaunchConfigurationType(resource); + // Newer AppHosts identify MAUI platform resources explicitly. Older AppHosts do not emit this + // property, so retain the parent fallback there rather than risking a CoreCLR attach to a device + // or simulator process. Ordinary grouped projects from newer AppHosts remain attachable. + if (launchConfigurationType === 'maui' || + (launchConfigurationType === null && getResourceParentName(resource) !== null)) { return undefined; } @@ -445,6 +446,11 @@ function getResourceParentName(resource: DebuggableResourceSnapshot): string | n return typeof value === 'string' ? value : null; } +function getLaunchConfigurationType(resource: DebuggableResourceSnapshot): string | null { + const value: unknown = resource.properties?.[resourceLaunchConfigurationTypePropertyName]; + return typeof value === 'string' ? value.trim().toLowerCase() : null; +} + function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): number | undefined { const value: unknown = resource.properties?.[executablePidPropertyName]; if (typeof value === 'number' && Number.isInteger(value) && value > 0) { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 31c7bfac19e..3c3bf011575 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -123,6 +123,74 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); + test('attach configuration keeps parented project resources attachable', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api-grouped', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.launchConfigurationType': 'project', + 'resource.parentName': 'group', + }, + }); + + assert.strictEqual(configuration.processName, 'FromTargetPath'); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj')); + }); + + test('attach configuration rejects parented MAUI platform resources', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'mauiapp-android-emulator', + displayName: 'MAUI', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/maui/MauiApp.csproj', + 'resource.launchConfigurationType': 'maui', + 'resource.parentName': 'mauiapp', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration rejects parented resources without explicit launch metadata', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'legacy-parented', + displayName: 'Legacy parented project', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.parentName': 'group', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + test('failed AppHost start writes error to debug console', async () => { const parentDebugSession = { id: 'aspire-session', diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 949134a43d6..30bec67bf41 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREEXTENSION001 // Launch configuration metadata is experimental but needed for snapshot serialization. + using System.Collections.Immutable; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; @@ -132,11 +134,17 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn { string? projectPath = null; string? launchProfileName = null; + string? launchConfigurationType = null; IResource? appModelResource = null; if (executable.AppModelResourceName is not null && _resourceState.ApplicationModel.TryGetValue(executable.AppModelResourceName, out appModelResource)) { + if (appModelResource.TryGetLastAnnotation(out var debugSupport)) + { + launchConfigurationType = debugSupport.LaunchConfigurationType; + } + if (appModelResource is ProjectResource projectResource) { projectPath = projectResource.GetProjectMetadata().ProjectPath; @@ -168,6 +176,9 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn } var launchArguments = GetLaunchArgs(executable, effectiveArgs); + ImmutableArray launchConfigurationProperties = launchConfigurationType is null + ? [] + : [new(KnownProperties.Resource.LaunchConfigurationType, launchConfigurationType)]; if (projectPath is not null) { @@ -185,6 +196,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + .. launchConfigurationProperties, ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), @@ -207,6 +219,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Executable, KnownProperties.Executable.Pid, executable.Status?.ProcessId), new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + .. launchConfigurationProperties, ]), EnvironmentVariables = environment, CreationTimeStamp = executable.Metadata.CreationTimestamp?.ToUniversalTime(), diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 966da171a29..86f6d0e7cd9 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -28,6 +28,7 @@ public static class Resource public const string ConnectionString = "resource.connectionString"; public const string ConnectionProperties = "resource.connectionProperties"; public const string ParentName = "resource.parentName"; + public const string LaunchConfigurationType = "resource.launchConfigurationType"; public const string AppArgs = "resource.appArgs"; public const string AppArgsSensitivity = "resource.appArgsSensitivity"; public const string ExcludeFromMcp = "resource.excludeFromMcp"; diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 83c02f3da3f..5b208f7be35 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -5,6 +5,7 @@ using System.Text.Json.Nodes; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Dashboard.Model; using Aspire.Shared.Model.Serialization; namespace Aspire.Cli.Tests.Backchannel; @@ -31,6 +32,30 @@ public void ResourceSnapshotDeserialization_WithNumericPropertyValue_PreservesJs Assert.Equal(12345, pid.GetValue()); } + [Fact] + public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() + { + var snapshot = new ResourceSnapshot + { + Name = "mauiapp-android-emulator", + DisplayName = "MAUI", + ResourceType = "Project", + State = "Running", + Properties = + { + [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("mauiapp"), + } + }; + + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); + + Assert.Equal("maui", result.Properties![KnownProperties.Resource.LaunchConfigurationType]!.GetValue()); + Assert.Equal("MauiApp.csproj", result.Source); + } + [Fact] public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() { diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index ddeb2299583..f5bfee3eeed 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -257,6 +257,92 @@ public void DescribeCommand_SnapshotFormat_OutputsWrappedJsonArray() Assert.Equal("frontend", deserialized.Resources[0].Name); } + [Fact] + public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForParentedProjectAndMauiResources() + { + var resourcesOutput = new ResourcesOutput + { + Resources = + [ + new ResourceJson + { + Name = "api-grouped", + DisplayName = "API", + ResourceType = "Project", + State = "Running", + Source = "Api.csproj", + Properties = new Dictionary + { + [KnownProperties.Executable.Args] = null, + [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("https"), + [KnownProperties.Project.Path] = JsonValue.Create("/repo/api/Api.csproj"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("project"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("group"), + } + }, + new ResourceJson + { + Name = "mauiapp-android-emulator", + DisplayName = "MAUI", + ResourceType = "Project", + State = "Running", + Source = "MauiApp.csproj", + Properties = new Dictionary + { + [KnownProperties.Executable.Args] = null, + [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), + [KnownProperties.Executable.Pid] = JsonValue.Create(1234), + [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), + [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), + [KnownProperties.Resource.ParentName] = JsonValue.Create("mauiapp"), + } + } + ] + }; + + var json = JsonSerializer.Serialize(resourcesOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); + + Assert.Equal(""" + { + "resources": [ + { + "name": "api-grouped", + "displayName": "API", + "resourceType": "Project", + "state": "Running", + "source": "Api.csproj", + "properties": { + "executable.args": null, + "executable.path": "dotnet", + "project.launchProfile": "https", + "project.path": "/repo/api/Api.csproj", + "resource.launchConfigurationType": "project", + "resource.parentName": "group" + } + }, + { + "name": "mauiapp-android-emulator", + "displayName": "MAUI", + "resourceType": "Project", + "state": "Running", + "source": "MauiApp.csproj", + "properties": { + "executable.args": null, + "executable.path": "dotnet", + "executable.pid": 1234, + "project.launchProfile": "AndroidEmulator", + "project.path": "/repo/maui/MauiApp.csproj", + "resource.launchConfigurationType": "maui", + "resource.parentName": "mauiapp" + } + } + ] + } + """, json); + } + [Fact] public async Task DescribeCommand_Follow_JsonFormat_DeduplicatesIdenticalSnapshots() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 7f8a83a822c..8a831425134 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPROJECTS001 // Project launch defaults are experimental but needed to verify snapshot emission. + using Aspire.Dashboard.Model; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -84,6 +86,31 @@ public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties() AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 2); } + [Fact] + public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject() + { + var builder = DistributedApplication.CreateBuilder(); + var project = builder.AddResource(new ProjectResource("project")); + project.Resource.Annotations.Add(new TestProjectMetadata()); + var configuredProject = project.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true }); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, configuredProject.Resource.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["run"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [configuredProject.Resource.Name] = configuredProject.Resource + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchConfigurationType = Assert.Single(snapshot.Properties, p => p.Name == KnownProperties.Resource.LaunchConfigurationType); + Assert.Equal("project", Assert.IsType(launchConfigurationType.Value)); + } + [Fact] public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations() { From a69ed2bf7d496f9cc6c7e8f846d150433fc723fd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 19:13:18 -0400 Subject: [PATCH 39/90] Cover debugger attach package surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 96775000-0503-484a-9738-d68c9300ff52 --- extension/src/test-e2e/packageSurface.e2e.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index 2f86f4f5688..86b9ae7dd61 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -122,6 +122,7 @@ suite('Aspire package contribution surface E2E', function () { 'aspire-vscode.openInIntegratedBrowser', 'aspire-vscode.copyEndpointUrl', 'aspire-vscode.openResourceTerminal', + 'aspire-vscode.attachDebuggerToResource', ]) { assert.ok(hiddenPaletteCommands.includes(commandId), `${commandId} should stay hidden from the command palette.`); } @@ -554,6 +555,7 @@ function createExpectedLanguageModelTools(strings: { const expectedCommandIds = [ 'aspire-vscode.add', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.codeLensDebugPipelineStep', 'aspire-vscode.codeLensOpenDashboard', 'aspire-vscode.codeLensResourceAction', @@ -644,6 +646,7 @@ const expectedViewItemContextCommands = [ 'aspire-vscode.stopResource', 'aspire-vscode.startResource', 'aspire-vscode.restartResource', + 'aspire-vscode.attachDebuggerToResource', 'aspire-vscode.executeResourceCommand', 'aspire-vscode.executeResourceCommandItem', 'aspire-vscode.viewResourceLogs', From 6177728c007c2d2dd770b1f869a4173640fb2ded Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 19:19:05 -0400 Subject: [PATCH 40/90] Use launched configuration for debugger attach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 96775000-0503-484a-9738-d68c9300ff52 --- extension/src/debugger/languages/dotnet.ts | 44 +++++++++++++++++-- extension/src/test/dotnetDebugger.test.ts | 51 +++++++++++++++++++--- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 578f424084e..619efdedf88 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -29,15 +29,17 @@ import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabled interface IDotNetService { getAndActivateDevKit(): Promise buildDotNetProject(projectFile: string): Promise; - getDotNetTargetPath(projectFile: string): Promise; + getDotNetTargetPath(projectFile: string, configuration?: string): Promise; getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } interface DotNetAttachDebuggerResourceInfo { + configuration?: string; projectPath: string; resourceLabel: string; } +const executableArgsPropertyName = 'executable.args'; const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; @@ -124,7 +126,7 @@ export class DotNetService implements IDotNetService { }); } - async getDotNetTargetPath(projectFile: string): Promise { + async getDotNetTargetPath(projectFile: string, configuration?: string): Promise { const args = [ 'msbuild', projectFile, @@ -133,6 +135,10 @@ export class DotNetService implements IDotNetService { '-v:q', '-property:GenerateFullPaths=true' ]; + if (configuration) { + args.push(`-property:Configuration=${configuration}`); + } + try { const { stdout } = await this.execFileAsync('dotnet', args, { cwd: path.dirname(projectFile), @@ -436,11 +442,43 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho } return { + configuration: getDotNetLaunchConfiguration(resource), projectPath, resourceLabel: resource.displayName ?? resource.name, }; } +function getDotNetLaunchConfiguration(resource: DebuggableResourceSnapshot): string | undefined { + const executableArgs: unknown = resource.properties?.[executableArgsPropertyName]; + if (!Array.isArray(executableArgs)) { + return undefined; + } + + // Project launcher arguments have the shape: + // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--no-launch-profile", "--", ...appArgs] + // Stop at the application-argument separator so an app's own --configuration value is not mistaken + // for the MSBuild configuration DCP used to launch the project. + for (let index = 0; index < executableArgs.length; index++) { + const argument = executableArgs[index]; + if (typeof argument !== 'string') { + continue; + } + + if (argument === '--') { + break; + } + + if (argument === '--configuration') { + const configuration = executableArgs[index + 1]; + return typeof configuration === 'string' && configuration.trim().length > 0 + ? configuration.trim() + : undefined; + } + } + + return undefined; +} + function getResourceParentName(resource: DebuggableResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceParentNamePropertyName]; return typeof value === 'string' ? value : null; @@ -487,7 +525,7 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR let targetPath: string; try { - targetPath = await dotNetService.getDotNetTargetPath(attachInfo.projectPath); + targetPath = await dotNetService.getDotNetTargetPath(attachInfo.projectPath, attachInfo.configuration); } catch (error) { throw new AttachDebuggerConfigurationError( diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 3c3bf011575..2a22e31310f 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -4,7 +4,7 @@ import { EventEmitter } from 'events'; import * as nodePath from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { createProjectDebuggerExtension, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; +import { createProjectDebuggerExtension, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; @@ -36,8 +36,8 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } - getDotNetTargetPath(projectFile: string): Promise { - return this.getDotNetTargetPathStub(projectFile); + getDotNetTargetPath(projectFile: string, configuration?: string): Promise { + return this.getDotNetTargetPathStub(projectFile, configuration); } buildDotNetProject(projectFile: string): Promise { @@ -98,7 +98,48 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(configuration.name, 'Attach debugger: API'); assert.strictEqual(configuration.processId, undefined); assert.strictEqual(configuration.processName, 'FromTargetPath'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj')); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + }); + + test('attach configuration evaluates TargetPath with the launched project configuration', async () => { + const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); + + const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.strictEqual(configuration.processName, 'ReleaseApi'); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', 'Release')); + }); + + test('TargetPath evaluation passes the project configuration to MSBuild', async () => { + const dotNetService = new DotNetService(undefined); + const execFileAsync = sinon.stub(dotNetService, 'execFileAsync').resolves({ + stdout: '/repo/bin/Release/net10.0/ReleaseApi.dll\n', + stderr: '', + }); + + const targetPath = await dotNetService.getDotNetTargetPath('/repo/api/Api.csproj', 'Release'); + + assert.strictEqual(targetPath, '/repo/bin/Release/net10.0/ReleaseApi.dll'); + assert.deepStrictEqual(execFileAsync.firstCall.args[1], [ + 'msbuild', + '/repo/api/Api.csproj', + '-nologo', + '-getProperty:TargetPath', + '-v:q', + '-property:GenerateFullPaths=true', + '-property:Configuration=Release', + ]); }); test('attach configuration rejects file-based project resources', async () => { @@ -141,7 +182,7 @@ suite('Dotnet Debugger Extension Tests', () => { }); assert.strictEqual(configuration.processName, 'FromTargetPath'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj')); + assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); }); test('attach configuration rejects parented MAUI platform resources', async () => { From 80253abbc5b6155abd1e820cf9691d0ae3cba4a6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 19:26:40 -0400 Subject: [PATCH 41/90] Reject ambiguous framework-dependent debugger attach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 96775000-0503-484a-9738-d68c9300ff52 --- extension/src/debugger/languages/dotnet.ts | 69 +++++++++++++++++++--- extension/src/test/dotnetDebugger.test.ts | 50 +++++++++++++--- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 619efdedf88..d446681f88d 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerUnavailable } from '../../loc/strings'; import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; import * as util from 'util'; import * as path from 'path'; @@ -29,10 +29,16 @@ import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabled interface IDotNetService { getAndActivateDevKit(): Promise buildDotNetProject(projectFile: string): Promise; - getDotNetTargetPath(projectFile: string, configuration?: string): Promise; + getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise; + getDotNetTargetPath(projectFile: string): Promise; getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } +interface DotNetAttachTargetInfo { + targetPath: string; + useAppHost: boolean; +} + interface DotNetAttachDebuggerResourceInfo { configuration?: string; projectPath: string; @@ -126,12 +132,13 @@ export class DotNetService implements IDotNetService { }); } - async getDotNetTargetPath(projectFile: string, configuration?: string): Promise { + async getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise { const args = [ 'msbuild', projectFile, '-nologo', '-getProperty:TargetPath', + '-getProperty:UseAppHost', '-v:q', '-property:GenerateFullPaths=true' ]; @@ -139,6 +146,46 @@ export class DotNetService implements IDotNetService { args.push(`-property:Configuration=${configuration}`); } + try { + const { stdout } = await this.execFileAsync('dotnet', args, { + cwd: path.dirname(projectFile), + encoding: 'utf8', + env: createAspireCliPathProcessEnvironment() + }); + // Multiple -getProperty switches return: + // { "Properties": { "TargetPath": "/repo/bin/Release/net10.0/Api.dll", "UseAppHost": "false" } } + const payload: unknown = JSON.parse(stdout); + const properties = typeof payload === 'object' && payload !== null && 'Properties' in payload + ? (payload as { Properties?: unknown }).Properties + : undefined; + const targetPath = typeof properties === 'object' && properties !== null && 'TargetPath' in properties + ? (properties as { TargetPath?: unknown }).TargetPath + : undefined; + const useAppHost = typeof properties === 'object' && properties !== null && 'UseAppHost' in properties + ? (properties as { UseAppHost?: unknown }).UseAppHost + : undefined; + if (typeof targetPath !== 'string' || targetPath.trim().length === 0) { + throw new Error(noOutputFromMsbuild); + } + + return { + targetPath: targetPath.trim(), + useAppHost: typeof useAppHost === 'string' && useAppHost.trim().toLowerCase() === 'true', + }; + } catch (err) { + throw new Error(failedToGetTargetPath(String(err))); + } + } + + async getDotNetTargetPath(projectFile: string): Promise { + const args = [ + 'msbuild', + projectFile, + '-nologo', + '-getProperty:TargetPath', + '-v:q', + '-property:GenerateFullPaths=true' + ]; try { const { stdout } = await this.execFileAsync('dotnet', args, { cwd: path.dirname(projectFile), @@ -523,9 +570,9 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } - let targetPath: string; + let targetInfo: DotNetAttachTargetInfo; try { - targetPath = await dotNetService.getDotNetTargetPath(attachInfo.projectPath, attachInfo.configuration); + targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration); } catch (error) { throw new AttachDebuggerConfigurationError( @@ -533,10 +580,16 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR error instanceof Error ? error.message : String(error)); } + // Without an apphost, dotnet run starts the target DLL under another process named "dotnet". + // That name is not unique enough to identify this resource without introducing process-tree discovery. + if (!targetInfo.useAppHost) { + throw new AttachDebuggerConfigurationError('ResourceNotAttachable', attachDebuggerUnavailable); + } + // `executable.pid` is the DCP launcher (`dotnet run`), not necessarily the managed - // application process. Use the C# debugger's process-name selector instead, deriving - // the name from the same TargetPath evaluation used by the normal project launch path. - const fileName = targetPath.trim().split(/[\\/]/).pop() ?? ''; + // application process. Apphost-backed projects have a unique process name derived from + // TargetPath, which the C# debugger can select without a second process-discovery subsystem. + const fileName = targetInfo.targetPath.split(/[\\/]/).pop() ?? ''; const processName = fileName.replace(/\.(dll|exe)$/i, ''); if (processName.length === 0) { throw new AttachDebuggerConfigurationError('ResourceNotAttachable', noOutputFromMsbuild); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 2a22e31310f..9cb1f3f0ec8 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -14,6 +14,7 @@ import * as hotReload from '../debugger/hotReload'; class TestDotNetService { private _hasDevKit: boolean; + public getDotNetAttachTargetInfoStub: sinon.SinonStub; public getDotNetTargetPathStub: sinon.SinonStub; public buildDotNetProjectStub: sinon.SinonStub; @@ -23,6 +24,9 @@ class TestDotNetService { public runApiEnvironment: NodeJS.ProcessEnv | undefined; constructor(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean) { + this.getDotNetAttachTargetInfoStub = sinon.stub(); + this.getDotNetAttachTargetInfoStub.resolves({ targetPath: outputPath, useAppHost: true }); + this.getDotNetTargetPathStub = sinon.stub(); this.getDotNetTargetPathStub.resolves(outputPath); @@ -36,8 +40,12 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } - getDotNetTargetPath(projectFile: string, configuration?: string): Promise { - return this.getDotNetTargetPathStub(projectFile, configuration); + getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise<{ targetPath: string, useAppHost: boolean }> { + return this.getDotNetAttachTargetInfoStub(projectFile, configuration); + } + + getDotNetTargetPath(projectFile: string): Promise { + return this.getDotNetTargetPathStub(projectFile); } buildDotNetProject(projectFile: string): Promise { @@ -98,7 +106,8 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(configuration.name, 'Attach debugger: API'); assert.strictEqual(configuration.processId, undefined); assert.strictEqual(configuration.processName, 'FromTargetPath'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); test('attach configuration evaluates TargetPath with the launched project configuration', async () => { @@ -118,24 +127,46 @@ suite('Dotnet Debugger Extension Tests', () => { }); assert.strictEqual(configuration.processName, 'ReleaseApi'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', 'Release')); + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', 'Release')); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('TargetPath evaluation passes the project configuration to MSBuild', async () => { + test('attach configuration rejects projects launched without an apphost', async () => { const dotNetService = new DotNetService(undefined); const execFileAsync = sinon.stub(dotNetService, 'execFileAsync').resolves({ - stdout: '/repo/bin/Release/net10.0/ReleaseApi.dll\n', + stdout: JSON.stringify({ + Properties: { + TargetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + UseAppHost: 'false', + }, + }), stderr: '', }); + const extension = createProjectDebuggerExtension(() => dotNetService); - const targetPath = await dotNetService.getDotNetTargetPath('/repo/api/Api.csproj', 'Release'); + await assert.rejects( + extension.createAttachDebugSessionConfigurationCallback!({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], + 'project.path': '/repo/api/Api.csproj', + }, + }), + (error: unknown) => error instanceof Error + && error.name === 'AttachDebuggerConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); - assert.strictEqual(targetPath, '/repo/bin/Release/net10.0/ReleaseApi.dll'); assert.deepStrictEqual(execFileAsync.firstCall.args[1], [ 'msbuild', '/repo/api/Api.csproj', '-nologo', '-getProperty:TargetPath', + '-getProperty:UseAppHost', '-v:q', '-property:GenerateFullPaths=true', '-property:Configuration=Release', @@ -182,7 +213,8 @@ suite('Dotnet Debugger Extension Tests', () => { }); assert.strictEqual(configuration.processName, 'FromTargetPath'); - assert.ok(dotNetService.getDotNetTargetPathStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); test('attach configuration rejects parented MAUI platform resources', async () => { From b06d0d453650d28838bebffc1d2d82e015708c2f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 21:25:56 -0400 Subject: [PATCH 42/90] feat(extension): add resource debug core Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/debuggerExtensions.ts | 57 --- extension/src/debugger/languages/dotnet.ts | 48 +- .../src/debugger/resourceAttachProviders.ts | 57 +++ .../src/debugger/resourceDebugContracts.ts | 66 +++ .../src/debugger/resourceDebugService.ts | 213 +++++++++ .../debugger/resourceDebugSessionRegistry.ts | 166 +++++++ extension/src/extension.ts | 13 +- extension/src/test/appHostTreeView.test.ts | 25 +- extension/src/test/dotnetDebugger.test.ts | 56 ++- .../src/test/resourceDebugService.test.ts | 448 ++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 89 ++-- .../src/views/treeItems/resourceItems.ts | 4 +- extension/src/views/treePresentation.ts | 4 +- 13 files changed, 1094 insertions(+), 152 deletions(-) create mode 100644 extension/src/debugger/resourceAttachProviders.ts create mode 100644 extension/src/debugger/resourceDebugContracts.ts create mode 100644 extension/src/debugger/resourceDebugService.ts create mode 100644 extension/src/debugger/resourceDebugSessionRegistry.ts create mode 100644 extension/src/test/resourceDebugService.test.ts diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 2600b994c61..7a229e7e323 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -1,5 +1,4 @@ import path from "path"; -import * as vscode from "vscode"; import { ExecutableLaunchConfiguration, EnvVar, LaunchOptions, AspireResourceExtendedDebugConfiguration, AspireExtendedDebugConfiguration, AspireResourceDebugSession } from "../dcp/types"; import { debugProject, runProject } from "../loc/strings"; import { getEnvironmentWithoutE2EBridgeVariables, mergeEnvs } from "../utils/environment"; @@ -17,23 +16,6 @@ import { mauiDebuggerExtension } from "./languages/maui"; import { isDirectory } from "../utils/io"; import { waitForRunStartIdle } from "./runStartRegistry"; -export interface DebuggableResourceSnapshot { - name: string; - displayName: string | null; - resourceType: string; - state: string | null; - properties: Record | null; -} - -export type AttachDebuggerConfigurationErrorKind = 'ResourceNotAttachable'; - -export class AttachDebuggerConfigurationError extends Error { - constructor(public readonly errorKind: AttachDebuggerConfigurationErrorKind, message: string) { - super(message); - this.name = 'AttachDebuggerConfigurationError'; - } -} - // Represents a resource-specific debugger extension for when the default session configuration is not sufficient to launch the resource. export interface ResourceDebuggerExtension { resourceType: string; @@ -43,8 +25,6 @@ export interface ResourceDebuggerExtension { getProjectFile: (launchConfig: ExecutableLaunchConfiguration) => string; getSupportedFileTypes: () => string[]; createDebugSessionConfigurationCallback?: (launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration) => Promise; - canAttachToResource?: (resource: DebuggableResourceSnapshot) => boolean; - createAttachDebugSessionConfigurationCallback?: (resource: DebuggableResourceSnapshot) => Promise; } export interface AlreadyStartedResourceDebugSession extends AspireResourceDebugSession { @@ -110,43 +90,6 @@ export async function prepareDebugSession(debugSessionConfig: AspireExtendedDebu }; } -export async function createAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, debuggerExtension: ResourceDebuggerExtension): Promise { - if (!debuggerExtension.createAttachDebugSessionConfigurationCallback) { - throw new AttachDebuggerConfigurationError('ResourceNotAttachable', `Resource type '${resource.resourceType}' does not support debugger attach.`); - } - - return await debuggerExtension.createAttachDebugSessionConfigurationCallback(resource); -} - -export function getAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { - return getResourceDebuggerExtensions().find(extension => extension.canAttachToResource?.(resource) === true); -} - -export function getMissingAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { - if (getAttachDebuggerExtensionForResource(resource)) { - return undefined; - } - - return getKnownAttachDebuggerExtensionForResource(resource); -} - -export function getKnownAttachDebuggerExtensionForResource(resource: DebuggableResourceSnapshot): ResourceDebuggerExtension | undefined { - return getKnownResourceDebuggerExtensions().find(extension => extension.canAttachToResource?.(resource) === true); -} - -function getKnownResourceDebuggerExtensions(): ResourceDebuggerExtension[] { - return [ - projectDebuggerExtension, - azureFunctionsDebuggerExtension, - pythonDebuggerExtension, - goDebuggerExtension, - nodeDebuggerExtension, - browserDebuggerExtension, - bunDebuggerExtension, - mauiDebuggerExtension, - ]; -} - export function getResourceDebuggerExtensions(platform: NodeJS.Platform = process.platform): ResourceDebuggerExtension[] { const extensions = []; if (isCsharpInstalled()) { diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index d446681f88d..f13c6b12742 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -9,7 +9,9 @@ import * as os from 'os'; import * as fs from 'fs'; import { doesFileExist } from '../../utils/io'; import { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, ProjectLaunchConfiguration } from '../../dcp/types'; -import { AttachDebuggerConfigurationError, DebuggableResourceSnapshot, ResourceDebuggerExtension } from '../debuggerExtensions'; +import { ResourceDebuggerExtension } from '../debuggerExtensions'; +import { ResourceAttachConfigurationError, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; +import type { ResourceAttachProvider } from '../resourceAttachProviders'; import { readLaunchSettings, determineBaseLaunchProfile, @@ -457,7 +459,7 @@ function configureDotNetRunDebugConfiguration( )); } -function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { +function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { if (resource.resourceType !== 'Project' || resource.state !== 'Running') { return undefined; } @@ -495,7 +497,7 @@ function getDotNetAttachDebuggerResourceInfo(resource: DebuggableResourceSnapsho }; } -function getDotNetLaunchConfiguration(resource: DebuggableResourceSnapshot): string | undefined { +function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): string | undefined { const executableArgs: unknown = resource.properties?.[executableArgsPropertyName]; if (!Array.isArray(executableArgs)) { return undefined; @@ -526,17 +528,17 @@ function getDotNetLaunchConfiguration(resource: DebuggableResourceSnapshot): str return undefined; } -function getResourceParentName(resource: DebuggableResourceSnapshot): string | null { +function getResourceParentName(resource: ResourceDebugResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceParentNamePropertyName]; return typeof value === 'string' ? value : null; } -function getLaunchConfigurationType(resource: DebuggableResourceSnapshot): string | null { +function getLaunchConfigurationType(resource: ResourceDebugResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceLaunchConfigurationTypePropertyName]; return typeof value === 'string' ? value.trim().toLowerCase() : null; } -function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): number | undefined { +function getAttachDebuggerProcessId(resource: ResourceDebugResourceSnapshot): number | undefined { const value: unknown = resource.properties?.[executablePidPropertyName]; if (typeof value === 'number' && Number.isInteger(value) && value > 0) { return value; @@ -554,7 +556,7 @@ function getAttachDebuggerProcessId(resource: DebuggableResourceSnapshot): numbe return processId; } -function isDotNetExecutable(resource: DebuggableResourceSnapshot): boolean { +function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { const executablePath: unknown = resource.properties?.[executablePathPropertyName]; if (typeof executablePath !== 'string') { return false; @@ -564,10 +566,10 @@ function isDotNetExecutable(resource: DebuggableResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableResourceSnapshot, dotNetService: IDotNetService): Promise { +export async function createDotNetAttachDebugSessionConfiguration(resource: ResourceDebugResourceSnapshot, dotNetService: IDotNetService): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { - throw new AttachDebuggerConfigurationError('ResourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); + throw new ResourceAttachConfigurationError('resourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); } let targetInfo: DotNetAttachTargetInfo; @@ -575,15 +577,15 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration); } catch (error) { - throw new AttachDebuggerConfigurationError( - 'ResourceNotAttachable', + throw new ResourceAttachConfigurationError( + 'resourceNotAttachable', error instanceof Error ? error.message : String(error)); } // Without an apphost, dotnet run starts the target DLL under another process named "dotnet". // That name is not unique enough to identify this resource without introducing process-tree discovery. if (!targetInfo.useAppHost) { - throw new AttachDebuggerConfigurationError('ResourceNotAttachable', attachDebuggerUnavailable); + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); } // `executable.pid` is the DCP launcher (`dotnet run`), not necessarily the managed @@ -592,7 +594,7 @@ async function createDotNetAttachDebugSessionConfiguration(resource: DebuggableR const fileName = targetInfo.targetPath.split(/[\\/]/).pop() ?? ''; const processName = fileName.replace(/\.(dll|exe)$/i, ''); if (processName.length === 0) { - throw new AttachDebuggerConfigurationError('ResourceNotAttachable', noOutputFromMsbuild); + throw new ResourceAttachConfigurationError('resourceNotAttachable', noOutputFromMsbuild); } return { @@ -617,10 +619,6 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); }, - canAttachToResource: (resource) => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, - createAttachDebugSessionConfigurationCallback: async (resource): Promise => { - return await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(undefined)); - }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); @@ -847,3 +845,19 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess } export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession)); + +export function createProjectResourceAttachProvider(dotNetServiceProducer: () => IDotNetService): ResourceAttachProvider { + return { + id: 'dotnet', + requiredDebuggerExtensions: [{ + id: 'ms-dotnettools.csharp', + label: 'C#', + }], + canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, + createDebugConfiguration: async resource => + await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer()), + }; +} + +export const projectResourceAttachProvider: ResourceAttachProvider = + createProjectResourceAttachProvider(() => new DotNetService(undefined)); diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts new file mode 100644 index 00000000000..c7c2c329058 --- /dev/null +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; +import { isExtensionInstalled } from '../capabilities'; +import { + type ResourceAttachProviderId, + type ResourceDebugExtensionRequirement, + type ResourceDebugResourceSnapshot, +} from './resourceDebugContracts'; +import { projectResourceAttachProvider } from './languages/dotnet'; + +/** + * Defines attach behavior independently from resource launch behavior. Providers own both + * eligibility and debugger configuration creation so the orchestration service never needs + * language-specific process metadata. + */ +export interface ResourceAttachProvider { + readonly id: ResourceAttachProviderId; + readonly requiredDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; + createDebugConfiguration(resource: ResourceDebugResourceSnapshot): Promise; +} + +export class ResourceAttachProviderRegistry { + constructor( + private readonly _knownProviders: readonly ResourceAttachProvider[], + private readonly _isDebuggerExtensionInstalled?: (extensionId: string) => boolean, + ) { + } + + getKnownProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return this._knownProviders.find(provider => provider.canAttachToResource(resource)); + } + + getInstalledProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return this._knownProviders.find(provider => + provider.canAttachToResource(resource) && + this.getMissingDebuggerExtensions(provider).length === 0); + } + + getMissingDebuggerExtensions(provider: ResourceAttachProvider): readonly ResourceDebugExtensionRequirement[] { + return provider.requiredDebuggerExtensions.filter(requirement => + !(this._isDebuggerExtensionInstalled?.(requirement.id) ?? isExtensionInstalled(requirement.id))); + } +} + +export function createResourceAttachProviderRegistry(): ResourceAttachProviderRegistry { + return new ResourceAttachProviderRegistry([projectResourceAttachProvider]); +} + +const defaultResourceAttachProviderRegistry = createResourceAttachProviderRegistry(); + +export function getKnownResourceAttachProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return defaultResourceAttachProviderRegistry.getKnownProviderForResource(resource); +} + +export function getInstalledResourceAttachProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return defaultResourceAttachProviderRegistry.getInstalledProviderForResource(resource); +} diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts new file mode 100644 index 00000000000..cd1f66fe061 --- /dev/null +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -0,0 +1,66 @@ +import type * as vscode from 'vscode'; + +export type ResourceDebugSource = 'tree' | 'languageModelTool'; + +export type ResourceAttachProviderId = 'dotnet'; + +/** + * An AppHost selected by a caller. The absolute path remains internal to the editor + * control plane; only the safe display path may be used by presentation layers. + */ +export interface ResourceDebugAppHostTarget { + readonly absolutePath: string; + readonly displayPath: string; +} + +export interface ResourceDebugRequest { + readonly source: ResourceDebugSource; + readonly appHost: ResourceDebugAppHostTarget; + readonly resourceName: string; + readonly cancellationToken?: vscode.CancellationToken; +} + +/** + * The CLI resource snapshot supplied to attach providers. This is internal-only: + * provider configuration may require process or project metadata that must never + * cross the resource-debug result boundary. + */ +export interface ResourceDebugResourceSnapshot { + readonly name: string; + readonly displayName: string | null; + readonly resourceType: string; + readonly state: string | null; + readonly properties: Record | null; +} + +export interface ResourceDebugExtensionRequirement { + readonly id: string; + readonly label: string; +} + +export type ResourceDebugErrorKind = + | 'resourceSnapshotFailed' + | 'providerResolutionFailed' + | 'configurationFailed' + | 'debuggerStartDeclined' + | 'debuggerStartFailed'; + +export type ResourceDebugResult = + | { readonly outcome: 'started'; readonly providerId: ResourceAttachProviderId } + | { readonly outcome: 'alreadyDebugging' } + | { readonly outcome: 'appHostNotFound' } + | { readonly outcome: 'resourceNotFound' } + | { readonly outcome: 'unsupportedResource' } + | { readonly outcome: 'resourceNotRunning' } + | { readonly outcome: 'debuggerExtensionMissing'; readonly debuggerExtensions: readonly ResourceDebugExtensionRequirement[] } + | { readonly outcome: 'cancelled' } + | { readonly outcome: 'error'; readonly errorKind: ResourceDebugErrorKind }; + +export type ResourceAttachConfigurationErrorKind = 'resourceNotAttachable'; + +export class ResourceAttachConfigurationError extends Error { + constructor(public readonly errorKind: ResourceAttachConfigurationErrorKind, message: string) { + super(message); + this.name = 'ResourceAttachConfigurationError'; + } +} diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts new file mode 100644 index 00000000000..2f0160490f6 --- /dev/null +++ b/extension/src/debugger/resourceDebugService.ts @@ -0,0 +1,213 @@ +import * as vscode from 'vscode'; +import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; +import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + ResourceAttachConfigurationError, + type ResourceDebugAppHostTarget, + type ResourceDebugExtensionRequirement, + type ResourceDebugRequest, + type ResourceDebugResult, +} from './resourceDebugContracts'; +import { ResourceAttachProviderRegistry, type ResourceAttachProvider } from './resourceAttachProviders'; +import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; + +export interface ResourceDebugAppHostRepository { + fetchAppHostsOnce(): Promise; +} + +export type ResourceDebugAppHostIdentityComparer = + (left: string | undefined, right: string | undefined) => AppHostIdentityRelation; + +export type ResourceDebugStartDebugging = + (workspaceFolder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; + +export interface ResourceDebugServiceDependencies { + readonly appHostRepository: ResourceDebugAppHostRepository; + readonly attachProviders: ResourceAttachProviderRegistry; + readonly sessionRegistry: ResourceDebugSessionRegistry; + readonly startDebugging: ResourceDebugStartDebugging; + readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; +} + +/** + * Resolves and attaches to a resource using a fresh CLI snapshot. It deliberately returns only + * bounded, presentation-safe outcomes; tree and language-model callers own their own UX. + */ +export class ResourceDebugService implements vscode.Disposable { + private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; + + constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { + this._compareAppHostIdentity = _dependencies.compareAppHostIdentity ?? compareAppHostIdentity; + } + + dispose(): void { + this._dependencies.sessionRegistry.dispose(); + } + + async debug(request: ResourceDebugRequest): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + return await this._dependencies.sessionRegistry.runSerialized(request.appHost, request.resourceName, async () => + await this._debugSerialized(request)); + } + + private async _debugSerialized(request: ResourceDebugRequest): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + let appHosts: readonly AppHostDisplayInfo[]; + try { + appHosts = await this._dependencies.appHostRepository.fetchAppHostsOnce(); + } + catch (error) { + return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested + ? { outcome: 'cancelled' } + : { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; + } + + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const appHostMatches = appHosts.map(appHost => ({ + appHost, + relation: this._compareAppHostIdentity(request.appHost.absolutePath, appHost.appHostPath), + })); + if (appHostMatches.some(match => match.relation === 'ambiguous')) { + return { outcome: 'appHostNotFound' }; + } + + const matchingAppHosts = appHostMatches + .filter(match => match.relation === 'same') + .map(match => match.appHost); + if (matchingAppHosts.length !== 1) { + return { outcome: 'appHostNotFound' }; + } + + const appHost = matchingAppHosts[0]; + const resources = (appHost.resources ?? []).filter(resource => resource.name === request.resourceName); + if (resources.length !== 1) { + return { outcome: 'resourceNotFound' }; + } + + const resource = resources[0]; + if (resource.state !== 'Running') { + return { outcome: 'resourceNotRunning' }; + } + + let provider: ResourceAttachProvider | undefined; + let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + try { + provider = this._dependencies.attachProviders.getKnownProviderForResource(resource); + missingDebuggerExtensions = provider + ? this._dependencies.attachProviders.getMissingDebuggerExtensions(provider) + : []; + } + catch (error) { + return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested + ? { outcome: 'cancelled' } + : { outcome: 'error', errorKind: 'providerResolutionFailed' }; + } + + if (!provider) { + return { outcome: 'unsupportedResource' }; + } + + if (missingDebuggerExtensions.length > 0) { + return { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ + id: requirement.id, + label: requirement.label, + })), + }; + } + + const resolvedTarget: ResourceDebugAppHostTarget = { + absolutePath: appHost.appHostPath, + displayPath: request.appHost.displayPath, + }; + return await this._attach(request, resolvedTarget, resource, provider); + } + + private async _attach( + request: ResourceDebugRequest, + appHost: ResourceDebugAppHostTarget, + resource: ResourceJson, + knownProvider: ResourceAttachProvider, + ): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + if (this._dependencies.sessionRegistry.hasActiveSession(appHost, resource.name)) { + return { outcome: 'alreadyDebugging' }; + } + + let provider: ResourceAttachProvider | undefined; + let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + try { + provider = this._dependencies.attachProviders.getInstalledProviderForResource(resource); + missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(knownProvider); + } + catch (error) { + return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested + ? { outcome: 'cancelled' } + : { outcome: 'error', errorKind: 'providerResolutionFailed' }; + } + + if (!provider) { + if (missingDebuggerExtensions.length > 0) { + return { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ + id: requirement.id, + label: requirement.label, + })), + }; + } + + return { outcome: 'unsupportedResource' }; + } + + let configuration: vscode.DebugConfiguration; + try { + configuration = await provider.createDebugConfiguration(resource); + } + catch (error) { + if (error instanceof ResourceAttachConfigurationError) { + return { outcome: 'unsupportedResource' }; + } + + return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested + ? { outcome: 'cancelled' } + : { outcome: 'error', errorKind: 'configurationFailed' }; + } + + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const attempt = this._dependencies.sessionRegistry.createAttempt(appHost, resource.name, configuration); + try { + const started = await this._dependencies.startDebugging(undefined, attempt.configuration); + if (!started) { + attempt.abandon(); + return { outcome: 'error', errorKind: 'debuggerStartDeclined' }; + } + + attempt.markStarted(); + return { outcome: 'started', providerId: provider.id }; + } + catch (error) { + attempt.abandon(); + return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested + ? { outcome: 'cancelled' } + : { outcome: 'error', errorKind: 'debuggerStartFailed' }; + } + } +} diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts new file mode 100644 index 00000000000..69f8eb55d22 --- /dev/null +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -0,0 +1,166 @@ +import * as vscode from 'vscode'; +import { getAppHostIdentityKey } from '../utils/appHostIdentity'; +import type { ResourceDebugAppHostTarget } from './resourceDebugContracts'; + +const resourceDebugSessionMarkerConfigKey = '__aspireResourceDebugSessionMarker'; + +export interface ResourceDebugSessionEvents { + readonly onDidStartDebugSession: vscode.Event; + readonly onDidTerminateDebugSession: vscode.Event; +} + +export interface ResourceDebugSessionAttempt { + readonly configuration: vscode.DebugConfiguration; + markStarted(): void; + abandon(): void; +} + +interface TrackedAttachAttempt { + readonly marker: number; + readonly resourceKey: string; + readonly sessionIds: Set; + startAccepted: boolean; + terminated: boolean; +} + +/** + * Tracks only attach sessions created by ResourceDebugService. The marker is intentionally + * private to generated configurations so unrelated VS Code debug sessions cannot affect + * resource attach serialization or lifecycle state. + */ +export class ResourceDebugSessionRegistry implements vscode.Disposable { + private readonly _attempts = new Map(); + private readonly _attemptsByResource = new Map>(); + private readonly _resourceLocks = new Map>(); + private readonly _subscriptions: vscode.Disposable; + private _nextMarker = 0; + + constructor(events: ResourceDebugSessionEvents = vscode.debug) { + this._subscriptions = vscode.Disposable.from( + events.onDidStartDebugSession(session => this._onDidStartDebugSession(session)), + events.onDidTerminateDebugSession(session => this._onDidTerminateDebugSession(session))); + } + + dispose(): void { + this._subscriptions.dispose(); + this._attempts.clear(); + this._attemptsByResource.clear(); + this._resourceLocks.clear(); + } + + hasActiveSession(appHost: ResourceDebugAppHostTarget, resourceName: string): boolean { + const attemptMarkers = this._attemptsByResource.get(this._getResourceKey(appHost.absolutePath, resourceName)); + if (!attemptMarkers) { + return false; + } + + return Array.from(attemptMarkers).some(marker => { + const attempt = this._attempts.get(marker); + return attempt !== undefined && !attempt.terminated && (attempt.startAccepted || attempt.sessionIds.size > 0); + }); + } + + async runSerialized(appHost: ResourceDebugAppHostTarget, resourceName: string, operation: () => Promise): Promise { + const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); + const precedingOperation = this._resourceLocks.get(resourceKey); + let releaseCurrentOperation: (() => void) | undefined; + const currentOperation = new Promise(resolve => { + releaseCurrentOperation = resolve; + }); + this._resourceLocks.set(resourceKey, currentOperation); + + await precedingOperation?.catch(() => undefined); + try { + return await operation(); + } + finally { + releaseCurrentOperation!(); + if (this._resourceLocks.get(resourceKey) === currentOperation) { + this._resourceLocks.delete(resourceKey); + } + } + } + + createAttempt(appHost: ResourceDebugAppHostTarget, resourceName: string, configuration: vscode.DebugConfiguration): ResourceDebugSessionAttempt { + const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); + const marker = ++this._nextMarker; + const attempt: TrackedAttachAttempt = { + marker, + resourceKey, + sessionIds: new Set(), + startAccepted: false, + terminated: false, + }; + this._attempts.set(marker, attempt); + const attemptMarkers = this._attemptsByResource.get(resourceKey) ?? new Set(); + attemptMarkers.add(marker); + this._attemptsByResource.set(resourceKey, attemptMarkers); + + return { + configuration: { + ...configuration, + [resourceDebugSessionMarkerConfigKey]: marker, + }, + markStarted: () => { + if (attempt.terminated) { + this._removeAttempt(attempt); + return; + } + + attempt.startAccepted = true; + }, + abandon: () => this._removeAttempt(attempt), + }; + } + + private _onDidStartDebugSession(session: vscode.DebugSession): void { + const attempt = this._getAttempt(session); + if (!attempt || attempt.terminated) { + return; + } + + attempt.sessionIds.add(session.id); + } + + private _onDidTerminateDebugSession(session: vscode.DebugSession): void { + const attempt = this._getAttempt(session); + if (!attempt) { + return; + } + + attempt.sessionIds.delete(session.id); + if (attempt.sessionIds.size > 0) { + return; + } + + attempt.terminated = true; + if (attempt.startAccepted) { + this._removeAttempt(attempt); + } + else { + const attemptMarkers = this._attemptsByResource.get(attempt.resourceKey); + attemptMarkers?.delete(attempt.marker); + if (attemptMarkers?.size === 0) { + this._attemptsByResource.delete(attempt.resourceKey); + } + } + } + + private _getAttempt(session: vscode.DebugSession): TrackedAttachAttempt | undefined { + const marker = session.configuration?.[resourceDebugSessionMarkerConfigKey]; + return typeof marker === 'number' ? this._attempts.get(marker) : undefined; + } + + private _removeAttempt(attempt: TrackedAttachAttempt): void { + this._attempts.delete(attempt.marker); + const attemptMarkers = this._attemptsByResource.get(attempt.resourceKey); + attemptMarkers?.delete(attempt.marker); + if (attemptMarkers?.size === 0) { + this._attemptsByResource.delete(attempt.resourceKey); + } + } + + private _getResourceKey(appHostPath: string, resourceName: string): string { + return `${getAppHostIdentityKey(appHostPath)}\u0000${resourceName}`; + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 2a028de6f64..fe81c5efbd6 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -35,6 +35,9 @@ import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; +import { createResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; +import { ResourceDebugService } from './debugger/resourceDebugService'; +import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; let aspireExtensionContext = new AspireExtensionContext(); @@ -108,6 +111,14 @@ export async function activate(context: vscode.ExtensionContext) { // Aspire panel - running app hosts tree view const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); + const resourceDebugService = new ResourceDebugService({ + appHostRepository: dataRepository, + attachProviders: createResourceAttachProviderRegistry(), + sessionRegistry: new ResourceDebugSessionRegistry(), + startDebugging: (workspaceFolder, configuration) => + vscode.debug.startDebugging(workspaceFolder, configuration), + }); + context.subscriptions.push(resourceDebugService); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); appHostLaunchService.setRunningAppHostProvider(async token => { const appHosts = await dataRepository.fetchRunningAppHostsOnce(token); @@ -115,7 +126,7 @@ export async function activate(context: vscode.ExtensionContext) { }); appHostLaunchService.setExternalAppHostStopper((appHostPath, token) => stopExternalAppHost(terminalProvider, appHostPath, token)); - const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState); + const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState, undefined, resourceDebugService); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.appHosts', { treeDataProvider: appHostTreeProvider, showCollapseAll: true, diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index e843d4e5949..a38e59e0c76 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as capabilities from '../capabilities'; -import * as debuggerExtensions from '../debugger/debuggerExtensions'; +import { projectResourceAttachProvider } from '../debugger/languages/dotnet'; import * as cliModule from '../utils/process/cliProcess'; import * as cliPathModule from '../utils/cliPath'; import * as configInfoProvider from '../utils/configInfoProvider'; @@ -105,6 +105,7 @@ function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: Vie workspaceAppHostName: undefined, workspaceAppHostDescription, onDidChangeData, + fetchAppHostsOnce: async () => appHosts, } as unknown as AppHostDataRepository; return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); @@ -2650,8 +2651,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); - sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); + sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ type: 'coreclr', request: 'attach', name: 'Attach debugger: API', @@ -2683,8 +2684,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }); const provider = makeTreeProvider([appHost]); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); - const createConfigurationStub = sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); + const createConfigurationStub = sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ type: 'coreclr', request: 'attach', name: 'Attach debugger: API', @@ -2739,8 +2740,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ]; const provider = makeTreeProvider(appHosts); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); - const createConfigurationStub = sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); + const createConfigurationStub = sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ type: 'coreclr', request: 'attach', name: 'Attach debugger: Second API', @@ -2774,7 +2775,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ]; const provider = makeTreeProvider(appHosts); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); const resourceItem = getFirstResourceItem(provider); @@ -2801,7 +2802,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }); const provider = makeTreeProvider([appHost]); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); const resourceItem = getFirstResourceItem(provider); @@ -2837,7 +2838,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(false); + sandbox.stub(capabilities, 'isExtensionInstalled').returns(false); const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); @@ -2863,8 +2864,8 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]); - sandbox.stub(capabilities, 'isCsharpInstalled').returns(true); - sandbox.stub(debuggerExtensions, 'createAttachDebugSessionConfiguration').resolves({ + sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); + sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ type: 'coreclr', request: 'attach', name: 'Attach debugger: API', diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 9cb1f3f0ec8..5f1d6adc8d4 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -4,10 +4,11 @@ import { EventEmitter } from 'events'; import * as nodePath from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { createProjectDebuggerExtension, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; +import { createProjectDebuggerExtension, createProjectResourceAttachProvider, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; +import type { ResourceAttachProvider } from '../debugger/resourceAttachProviders'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; @@ -81,15 +82,20 @@ suite('Dotnet Debugger Extension Tests', () => { teardown(() => sinon.restore()); - function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, doesFileExistStub: sinon.SinonStub } { + function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, attachProvider: ResourceAttachProvider, doesFileExistStub: sinon.SinonStub } { const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit); - return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist) }; + return { + dotNetService: fakeDotNetService, + extension: createProjectDebuggerExtension(() => fakeDotNetService), + attachProvider: createProjectResourceAttachProvider(() => fakeDotNetService), + doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist), + }; } test('attach configuration uses the project TargetPath process name instead of the launcher process ID', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); - const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + const configuration = await attachProvider.createDebugConfiguration({ name: 'api', displayName: 'API', resourceType: 'Project', @@ -111,9 +117,9 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('attach configuration evaluates TargetPath with the launched project configuration', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); - const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + const configuration = await attachProvider.createDebugConfiguration({ name: 'api', displayName: 'API', resourceType: 'Project', @@ -142,10 +148,10 @@ suite('Dotnet Debugger Extension Tests', () => { }), stderr: '', }); - const extension = createProjectDebuggerExtension(() => dotNetService); + const attachProvider = createProjectResourceAttachProvider(() => dotNetService); await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ + attachProvider.createDebugConfiguration({ name: 'api', displayName: 'API', resourceType: 'Project', @@ -158,8 +164,8 @@ suite('Dotnet Debugger Extension Tests', () => { }, }), (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); assert.deepStrictEqual(execFileAsync.firstCall.args[1], [ 'msbuild', @@ -174,10 +180,10 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('attach configuration rejects file-based project resources', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ + attachProvider.createDebugConfiguration({ name: 'api', displayName: 'API', resourceType: 'Project', @@ -189,16 +195,16 @@ suite('Dotnet Debugger Extension Tests', () => { }, }), (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); test('attach configuration keeps parented project resources attachable', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); - const configuration = await extension.createAttachDebugSessionConfigurationCallback!({ + const configuration = await attachProvider.createDebugConfiguration({ name: 'api-grouped', displayName: 'API', resourceType: 'Project', @@ -218,10 +224,10 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('attach configuration rejects parented MAUI platform resources', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ + attachProvider.createDebugConfiguration({ name: 'mauiapp-android-emulator', displayName: 'MAUI', resourceType: 'Project', @@ -235,17 +241,17 @@ suite('Dotnet Debugger Extension Tests', () => { }, }), (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); test('attach configuration rejects parented resources without explicit launch metadata', async () => { - const { extension, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); await assert.rejects( - extension.createAttachDebugSessionConfigurationCallback!({ + attachProvider.createDebugConfiguration({ name: 'legacy-parented', displayName: 'Legacy parented project', resourceType: 'Project', @@ -258,8 +264,8 @@ suite('Dotnet Debugger Extension Tests', () => { }, }), (error: unknown) => error instanceof Error - && error.name === 'AttachDebuggerConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'ResourceNotAttachable'); + && error.name === 'ResourceAttachConfigurationError' + && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts new file mode 100644 index 00000000000..22c317e5648 --- /dev/null +++ b/extension/src/test/resourceDebugService.test.ts @@ -0,0 +1,448 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; +import { projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { ResourceAttachProvider, ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; +import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService } from '../debugger/resourceDebugService'; +import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; +import type { ResourceDebugAppHostTarget, ResourceDebugRequest, ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; + +const target: ResourceDebugAppHostTarget = { + absolutePath: '/repo/AppHost.csproj', + displayPath: 'AppHost.csproj', +}; + +function createResource(overrides: Partial = {}): ResourceJson { + return { + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + stateStyle: null, + healthStatus: null, + healthReports: null, + exitCode: null, + dashboardUrl: null, + urls: null, + commands: null, + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + }, + ...overrides, + }; +} + +function createAppHost(overrides: Partial = {}): AppHostDisplayInfo { + return { + appHostPath: target.absolutePath, + appHostPid: 42, + cliPid: null, + dashboardUrl: null, + resources: [createResource()], + ...overrides, + }; +} + +function createRequest(overrides: Partial = {}): ResourceDebugRequest { + return { + source: 'tree', + appHost: target, + resourceName: 'api', + ...overrides, + }; +} + +function createProvider(overrides: Partial = {}): ResourceAttachProvider { + return { + id: 'dotnet', + requiredDebuggerExtensions: [{ + id: 'ms-dotnettools.csharp', + label: 'C#', + }], + canAttachToResource: () => true, + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }), + ...overrides, + }; +} + +class TestDebugSessionEvents implements ResourceDebugSessionEvents { + private _startListener: ((session: vscode.DebugSession) => void) | undefined; + private _terminateListener: ((session: vscode.DebugSession) => void) | undefined; + + onDidStartDebugSession(listener: (session: vscode.DebugSession) => void): vscode.Disposable { + this._startListener = listener; + return new vscode.Disposable(() => { + this._startListener = undefined; + }); + } + + onDidTerminateDebugSession(listener: (session: vscode.DebugSession) => void): vscode.Disposable { + this._terminateListener = listener; + return new vscode.Disposable(() => { + this._terminateListener = undefined; + }); + } + + start(configuration: vscode.DebugConfiguration): void { + this._startListener?.({ + id: 'resource-attach-session', + configuration, + } as vscode.DebugSession); + } + + terminate(configuration: vscode.DebugConfiguration): void { + this._terminateListener?.({ + id: 'resource-attach-session', + configuration, + } as vscode.DebugSession); + } +} + +function createService(options: { + appHosts?: readonly AppHostDisplayInfo[]; + provider?: ResourceAttachProvider; + isExtensionInstalled?: (extensionId: string) => boolean; + startDebugging?: (folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; + compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; +} = {}): { + service: ResourceDebugService; + repository: ResourceDebugAppHostRepository; + sessions: ResourceDebugSessionRegistry; + events: TestDebugSessionEvents; +} { + const repository: ResourceDebugAppHostRepository = { + fetchAppHostsOnce: async () => options.appHosts ?? [createAppHost()], + }; + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const providers = new ResourceAttachProviderRegistry( + [options.provider ?? createProvider()], + options.isExtensionInstalled ?? (() => true)); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: providers, + sessionRegistry: sessions, + startDebugging: options.startDebugging ?? (async () => true), + compareAppHostIdentity: options.compareAppHostIdentity, + }); + + return { service, repository, sessions, events }; +} + +suite('Resource debug service', () => { + teardown(() => sinon.restore()); + + test('keeps ResourceDebuggerExtension launch-only', () => { + assert.deepStrictEqual( + Object.keys(projectDebuggerExtension).sort(), + [ + 'createDebugSessionConfigurationCallback', + 'debugAdapter', + 'extensionId', + 'getDisplayName', + 'getProjectFile', + 'getSupportedFileTypes', + 'resourceType', + ]); + }); + + test('registers .NET attach behavior independently from the launch provider', () => { + const providers = new ResourceAttachProviderRegistry([projectResourceAttachProvider], () => true); + + assert.strictEqual(providers.getKnownProviderForResource(createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '42', + }, + }))?.id, 'dotnet'); + }); + + test('uses a fresh AppHost snapshot instead of a tree resource', async () => { + let fetchCount = 0; + let configuredResource: ResourceDebugResourceSnapshot | undefined; + const repository: ResourceDebugAppHostRepository = { + fetchAppHostsOnce: async () => { + fetchCount++; + return [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': `dotnet-${fetchCount}`, + }, + })], + })]; + }, + }; + const provider = createProvider({ + createDebugConfiguration: async resource => { + configuredResource = resource; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: new ResourceAttachProviderRegistry([provider], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(fetchCount, 1); + assert.strictEqual(configuredResource?.properties?.['executable.path'], 'dotnet-1'); + sessions.dispose(); + }); + + test('resolves duplicate resource names only within the requested AppHost', async () => { + let configuredResource: ResourceDebugResourceSnapshot | undefined; + const { service, sessions } = createService({ + appHosts: [ + createAppHost({ + appHostPath: '/repo/first/AppHost.csproj', + resources: [createResource({ displayName: 'First API' })], + }), + createAppHost({ + appHostPath: target.absolutePath, + resources: [createResource({ displayName: 'Second API' })], + }), + ], + provider: createProvider({ + createDebugConfiguration: async resource => { + configuredResource = resource; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: Second API' }; + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(configuredResource?.displayName, 'Second API'); + sessions.dispose(); + }); + + test('fails closed when the AppHost identity is ambiguous', async () => { + const { service, sessions } = createService({ + compareAppHostIdentity: () => 'ambiguous', + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + + test('fails closed when one matching AppHost identity is ambiguous', async () => { + const { service, sessions } = createService({ + appHosts: [ + createAppHost(), + createAppHost({ appHostPath: '/repo/ambiguous/AppHost.csproj' }), + ], + compareAppHostIdentity: (_requestedPath, appHostPath) => + appHostPath === target.absolutePath ? 'same' : 'ambiguous', + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + + test('fails closed when a resource is stale or duplicated', async () => { + const missing = createService({ + appHosts: [createAppHost({ resources: [] })], + }); + const duplicated = createService({ + appHosts: [createAppHost({ resources: [createResource(), createResource()] })], + }); + + assert.deepStrictEqual(await missing.service.debug(createRequest()), { outcome: 'resourceNotFound' }); + assert.deepStrictEqual(await duplicated.service.debug(createRequest()), { outcome: 'resourceNotFound' }); + missing.sessions.dispose(); + duplicated.sessions.dispose(); + }); + + test('reports a missing debugger extension without exposing resource details', async () => { + const { service, sessions } = createService({ + isExtensionInstalled: () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + }); + sessions.dispose(); + }); + + test('returns typed outcomes for unsupported and stopped resources', async () => { + const unsupported = createService({ + provider: createProvider({ canAttachToResource: () => false }), + }); + const stopped = createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + }); + + assert.deepStrictEqual(await unsupported.service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); + unsupported.sessions.dispose(); + stopped.sessions.dispose(); + }); + + test('normalizes provider eligibility errors without exposing their details', async () => { + const { service, sessions } = createService({ + provider: createProvider({ + canAttachToResource: () => { + throw new Error('process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'providerResolutionFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + sessions.dispose(); + }); + + test('serializes concurrent requests and returns alreadyDebugging for the duplicate', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let markStartCalled: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + markStartCalled = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + markStartCalled!(); + return startRequest; + }); + const { service, repository, sessions } = createService({ startDebugging }); + let fetchCount = 0; + repository.fetchAppHostsOnce = async () => { + fetchCount++; + return [createAppHost()]; + }; + + const first = service.debug(createRequest()); + const second = service.debug(createRequest()); + await startCalled; + assert.strictEqual(startDebugging.callCount, 1); + assert.strictEqual(fetchCount, 1); + + completeStart!(true); + + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await second, { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + + test('returns alreadyDebugging while an independent attach session is active', async () => { + const { service, sessions } = createService(); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + + test('returns a bounded failure when VS Code declines to start debugging', async () => { + const { service, sessions } = createService({ + startDebugging: async () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { + outcome: 'error', + errorKind: 'debuggerStartDeclined', + }); + sessions.dispose(); + }); + + test('normalizes configuration errors without exposing their details', async () => { + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new Error('process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + sessions.dispose(); + }); + + test('returns cancelled when the request cancellation token is already cancelled', async () => { + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ startDebugging }); + + assert.deepStrictEqual(await service.debug(createRequest({ cancellationToken: cancellation.token })), { + outcome: 'cancelled', + }); + assert.strictEqual(startDebugging.callCount, 0); + cancellation.dispose(); + sessions.dispose(); + }); + + test('does not start debugging when cancellation occurs during configuration', async () => { + let finishConfiguration: (() => void) | undefined; + let markConfigurationStarted: (() => void) | undefined; + const configuration = new Promise(resolve => { + finishConfiguration = resolve; + }); + const configurationStarted = new Promise(resolve => { + markConfigurationStarted = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + markConfigurationStarted!(); + await configuration; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging, + }); + + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await configurationStarted; + cancellation.cancel(); + finishConfiguration!(); + + assert.deepStrictEqual(await operation, { outcome: 'cancelled' }); + assert.strictEqual(startDebugging.callCount, 0); + cancellation.dispose(); + sessions.dispose(); + }); + + test('removes a terminated independent attach session without stopping its resource', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + return true; + }, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(sessions.hasActiveSession(target, 'api'), true); + + events.terminate(startedConfiguration!); + + assert.strictEqual(sessions.hasActiveSession(target, 'api'), false); + sessions.dispose(); + }); +}); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 7be84412c29..34fc67f9de4 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -40,7 +40,9 @@ import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { isSameFileSystemEntry } from '../utils/appHostDiscovery'; import { isAppHostSourceFile, isProjectFile } from '../utils/paths/comparison'; import { isCommandCancellation } from '../utils/telemetry'; -import * as debuggerExtensions from '../debugger/debuggerExtensions'; +import { createResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; +import { ResourceDebugService } from '../debugger/resourceDebugService'; +import { ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; import { getParentResourceName, getTerminalReplicaIndex, @@ -114,6 +116,8 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider(); private _treeView: vscode.TreeView | undefined; + private readonly _resourceDebugService: ResourceDebugService; + private readonly _ownsResourceDebugService: boolean; private _documentCloseSubscription: vscode.Disposable | undefined; @@ -123,7 +127,16 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider + vscode.debug.startDebugging(workspaceFolder, configuration), + }); this._dataSubscription = this._repository.onDidChangeData(() => { this._clearLaunchingPathsForRunningAppHosts(); this._clearStoppingPathsForStoppedAppHosts(); @@ -178,6 +191,9 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider extension.id === 'ms-dotnettools.csharp')) { + vscode.window.showWarningMessage(attachDebuggerCsharpExtensionRequired); + return { success: false, errorKind: 'CSharpExtensionMissing' }; + } - throw error; - } + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; + case 'resourceNotRunning': + case 'unsupportedResource': + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; + case 'error': + if (result.errorKind === 'debuggerStartDeclined') { + const error = new Error(attachDebuggerDeclined(element.resource.displayName ?? element.resource.name)); + error.name = 'StartDebuggingDeclined'; + throw error; + } - const resourceLabel = resource.displayName ?? resource.name; - const started = await vscode.debug.startDebugging(undefined, configuration); - if (!started) { - const error = new Error(attachDebuggerDeclined(resourceLabel)); - error.name = 'StartDebuggingDeclined'; - throw error; + vscode.window.showWarningMessage(attachDebuggerUnavailable); + return { success: false, errorKind: 'ResourceNotAttachable' }; } } diff --git a/extension/src/views/treeItems/resourceItems.ts b/extension/src/views/treeItems/resourceItems.ts index 0f7125c65bd..cdbb1edc9f4 100644 --- a/extension/src/views/treeItems/resourceItems.ts +++ b/extension/src/views/treeItems/resourceItems.ts @@ -9,7 +9,7 @@ import { } from '../../loc/strings'; import { isLinkableUrl } from '../../utils/urlSchemes'; import { ResourceCommandJson, ResourceJson } from '../../data/AppHostDataRepository'; -import * as debuggerExtensions from '../../debugger/debuggerExtensions'; +import { getInstalledResourceAttachProviderForResource } from '../../debugger/resourceAttachProviders'; import { getComparisonKey } from '../../utils/paths/comparison'; import { buildResourceDescription, @@ -146,6 +146,6 @@ export class ResourceItem extends vscode.TreeItem { this.tooltip = buildResourceTooltip(resource); this.contextValue = getResourceContextValue( resource, - debuggerExtensions.getAttachDebuggerExtensionForResource(resource) !== undefined); + getInstalledResourceAttachProviderForResource(resource) !== undefined); } } diff --git a/extension/src/views/treePresentation.ts b/extension/src/views/treePresentation.ts index 28d06b6899a..52118c5d8e9 100644 --- a/extension/src/views/treePresentation.ts +++ b/extension/src/views/treePresentation.ts @@ -13,7 +13,7 @@ import { } from '../loc/strings'; import { isLinkableUrl } from '../utils/urlSchemes'; import { ResourceCommandJson, ResourceJson } from '../data/AppHostDataRepository'; -import * as debuggerExtensions from '../debugger/debuggerExtensions'; +import { getKnownResourceAttachProviderForResource } from '../debugger/resourceAttachProviders'; export const integratedBrowserOpenCommand = 'workbench.action.browser.open'; export const terminalEnabledPropertyName = 'terminal.enabled'; @@ -113,7 +113,7 @@ export function getResourceContextValue(resource: ResourceJson, canAttachDebugge if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } - if (canAttachDebugger && debuggerExtensions.getKnownAttachDebuggerExtensionForResource(resource) !== undefined) { + if (canAttachDebugger && getKnownResourceAttachProviderForResource(resource) !== undefined) { parts.push('canAttachDebugger'); } return parts.join(':'); From 51efe08350814feaa7c50af69136b436021a7bdd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 22:15:34 -0400 Subject: [PATCH 43/90] fix(extension): harden resource debugger attach Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/data/AppHostDataRepository.ts | 9 +- extension/src/debugger/languages/dotnet.ts | 3 +- .../src/debugger/resourceAttachProviders.ts | 30 +- .../src/debugger/resourceDebugContracts.ts | 21 ++ .../src/debugger/resourceDebugService.ts | 164 ++++++--- .../debugger/resourceDebugSessionRegistry.ts | 95 +++++- extension/src/extension.ts | 7 +- extension/src/loc/strings.ts | 2 + .../src/test/appHostDataRepository.test.ts | 21 ++ extension/src/test/appHostTreeView.test.ts | 267 ++++++++------- .../src/test/aspireCodeLensProvider.test.ts | 7 +- extension/src/test/dotnetDebugger.test.ts | 2 +- .../src/test/resourceDebugService.test.ts | 319 +++++++++++++++++- .../src/views/AspireAppHostTreeProvider.ts | 64 ++-- .../src/views/treeItems/resourceItems.ts | 8 +- extension/src/views/treePresentation.ts | 6 +- 16 files changed, 755 insertions(+), 270 deletions(-) diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index a9ff58047f9..93dd68dbbe4 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -473,7 +473,7 @@ export class AppHostDataRepository { const appHostList = await this.fetchRunningAppHostsOnce(); const appHostsWithResources = await Promise.allSettled(appHostList.map(async appHost => ({ ...appHost, - resources: await this._fetchAppHostResourcesOnce(appHost.appHostPath), + resources: await this.fetchAppHostResourcesOnce(appHost.appHostPath), }))); return appHostsWithResources.map((result, index) => { @@ -1314,8 +1314,11 @@ export class AppHostDataRepository { } } - private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { - const snapshot = await this._runCliJson('aspire describe', this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath])); + async fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken): Promise { + const snapshot = await this._runCliJson( + 'aspire describe', + this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath]), + { cancellationToken }); return snapshot.resources ?? []; } diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index f13c6b12742..6a243928ac6 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -10,8 +10,7 @@ import * as fs from 'fs'; import { doesFileExist } from '../../utils/io'; import { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, ProjectLaunchConfiguration } from '../../dcp/types'; import { ResourceDebuggerExtension } from '../debuggerExtensions'; -import { ResourceAttachConfigurationError, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; -import type { ResourceAttachProvider } from '../resourceAttachProviders'; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; import { readLaunchSettings, determineBaseLaunchProfile, diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts index c7c2c329058..bd61a913aff 100644 --- a/extension/src/debugger/resourceAttachProviders.ts +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -1,23 +1,9 @@ -import * as vscode from 'vscode'; import { isExtensionInstalled } from '../capabilities'; import { - type ResourceAttachProviderId, + type ResourceAttachProvider, type ResourceDebugExtensionRequirement, type ResourceDebugResourceSnapshot, } from './resourceDebugContracts'; -import { projectResourceAttachProvider } from './languages/dotnet'; - -/** - * Defines attach behavior independently from resource launch behavior. Providers own both - * eligibility and debugger configuration creation so the orchestration service never needs - * language-specific process metadata. - */ -export interface ResourceAttachProvider { - readonly id: ResourceAttachProviderId; - readonly requiredDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; - canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; - createDebugConfiguration(resource: ResourceDebugResourceSnapshot): Promise; -} export class ResourceAttachProviderRegistry { constructor( @@ -41,17 +27,3 @@ export class ResourceAttachProviderRegistry { !(this._isDebuggerExtensionInstalled?.(requirement.id) ?? isExtensionInstalled(requirement.id))); } } - -export function createResourceAttachProviderRegistry(): ResourceAttachProviderRegistry { - return new ResourceAttachProviderRegistry([projectResourceAttachProvider]); -} - -const defaultResourceAttachProviderRegistry = createResourceAttachProviderRegistry(); - -export function getKnownResourceAttachProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { - return defaultResourceAttachProviderRegistry.getKnownProviderForResource(resource); -} - -export function getInstalledResourceAttachProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { - return defaultResourceAttachProviderRegistry.getInstalledProviderForResource(resource); -} diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index cd1f66fe061..405abddc295 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -38,6 +38,27 @@ export interface ResourceDebugExtensionRequirement { readonly label: string; } +/** + * A language-specific debugger attach provider. The resource-debug orchestrator supplies a + * cancellation token because future providers may have cancellable configuration discovery. + * Existing providers that delegate to debugger APIs without cancellation support can omit it. + */ +export interface ResourceAttachProvider { + readonly id: ResourceAttachProviderId; + readonly requiredDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; + createDebugConfiguration(resource: ResourceDebugResourceSnapshot, cancellationToken?: vscode.CancellationToken): Promise; +} + +/** + * The tree consumes only the extension-wide debug service. It must not create its own service + * because that would split session tracking and allow duplicate attach commands. + */ +export interface ResourceDebugger { + debug(request: ResourceDebugRequest): Promise; + canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; +} + export type ResourceDebugErrorKind = | 'resourceSnapshotFailed' | 'providerResolutionFailed' diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 2f0160490f6..2158ba0a97b 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -1,19 +1,23 @@ import * as vscode from 'vscode'; import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; import { ResourceAttachConfigurationError, + type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugExtensionRequirement, + type ResourceDebugger, type ResourceDebugRequest, type ResourceDebugResult, } from './resourceDebugContracts'; -import { ResourceAttachProviderRegistry, type ResourceAttachProvider } from './resourceAttachProviders'; +import { ResourceAttachProviderRegistry } from './resourceAttachProviders'; import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; export interface ResourceDebugAppHostRepository { - fetchAppHostsOnce(): Promise; + fetchRunningAppHostsOnce(cancellationToken?: vscode.CancellationToken): Promise; + fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken): Promise; } export type ResourceDebugAppHostIdentityComparer = @@ -34,7 +38,7 @@ export interface ResourceDebugServiceDependencies { * Resolves and attaches to a resource using a fresh CLI snapshot. It deliberately returns only * bounded, presentation-safe outcomes; tree and language-model callers own their own UX. */ -export class ResourceDebugService implements vscode.Disposable { +export class ResourceDebugService implements vscode.Disposable, ResourceDebugger { private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { @@ -45,28 +49,50 @@ export class ResourceDebugService implements vscode.Disposable { this._dependencies.sessionRegistry.dispose(); } - async debug(request: ResourceDebugRequest): Promise { - if (request.cancellationToken?.isCancellationRequested) { - return { outcome: 'cancelled' }; + canAttachToResource(resource: ResourceJson): boolean { + try { + return this._dependencies.attachProviders.getInstalledProviderForResource(resource) !== undefined; + } + catch (error) { + this._logFailure('checking whether a resource can be attached', error); + return false; } - - return await this._dependencies.sessionRegistry.runSerialized(request.appHost, request.resourceName, async () => - await this._debugSerialized(request)); } - private async _debugSerialized(request: ResourceDebugRequest): Promise { + async debug(request: ResourceDebugRequest): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; } + const resolvedAppHost = await this._resolveAppHost(request); + if ('outcome' in resolvedAppHost) { + return resolvedAppHost; + } + + const resolvedTarget: ResourceDebugAppHostTarget = { + absolutePath: resolvedAppHost.appHostPath, + displayPath: request.appHost.displayPath, + }; + return await this._dependencies.sessionRegistry.runSerialized( + resolvedTarget, + request.resourceName, + request.cancellationToken, + async () => await this._debugSerialized(request, resolvedTarget), + () => ({ outcome: 'cancelled' })); + } + + private async _resolveAppHost(request: ResourceDebugRequest): Promise { let appHosts: readonly AppHostDisplayInfo[]; try { - appHosts = await this._dependencies.appHostRepository.fetchAppHostsOnce(); + appHosts = await this._dependencies.appHostRepository.fetchRunningAppHostsOnce(request.cancellationToken); } catch (error) { - return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested - ? { outcome: 'cancelled' } - : { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the running AppHost', error); + return { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; } if (request.cancellationToken?.isCancellationRequested) { @@ -88,35 +114,76 @@ export class ResourceDebugService implements vscode.Disposable { return { outcome: 'appHostNotFound' }; } - const appHost = matchingAppHosts[0]; - const resources = (appHost.resources ?? []).filter(resource => resource.name === request.resourceName); - if (resources.length !== 1) { - return { outcome: 'resourceNotFound' }; + return matchingAppHosts[0]; + } + + private async _debugSerialized( + request: ResourceDebugRequest, + resolvedTarget: ResourceDebugAppHostTarget, + ): Promise { + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; } - const resource = resources[0]; - if (resource.state !== 'Running') { - return { outcome: 'resourceNotRunning' }; + let resources: readonly ResourceJson[]; + try { + resources = await this._dependencies.appHostRepository.fetchAppHostResourcesOnce( + resolvedTarget.absolutePath, + request.cancellationToken); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('fetching the selected AppHost resource snapshot', error); + return { outcome: 'error', errorKind: 'resourceSnapshotFailed' }; } + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + const matchingResources = resources.filter(resource => resource.name === request.resourceName); + if (matchingResources.length !== 1) { + return { outcome: 'resourceNotFound' }; + } + + const resource = matchingResources[0]; let provider: ResourceAttachProvider | undefined; - let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; try { provider = this._dependencies.attachProviders.getKnownProviderForResource(resource); - missingDebuggerExtensions = provider - ? this._dependencies.attachProviders.getMissingDebuggerExtensions(provider) - : []; } catch (error) { - return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested - ? { outcome: 'cancelled' } - : { outcome: 'error', errorKind: 'providerResolutionFailed' }; + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the resource attach provider', error); + return { outcome: 'error', errorKind: 'providerResolutionFailed' }; } if (!provider) { return { outcome: 'unsupportedResource' }; } + if (resource.state !== 'Running') { + return { outcome: 'resourceNotRunning' }; + } + + let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + try { + missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(provider); + } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('checking required debugger extensions', error); + return { outcome: 'error', errorKind: 'providerResolutionFailed' }; + } + if (missingDebuggerExtensions.length > 0) { return { outcome: 'debuggerExtensionMissing', @@ -127,10 +194,6 @@ export class ResourceDebugService implements vscode.Disposable { }; } - const resolvedTarget: ResourceDebugAppHostTarget = { - absolutePath: appHost.appHostPath, - displayPath: request.appHost.displayPath, - }; return await this._attach(request, resolvedTarget, resource, provider); } @@ -155,9 +218,12 @@ export class ResourceDebugService implements vscode.Disposable { missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(knownProvider); } catch (error) { - return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested - ? { outcome: 'cancelled' } - : { outcome: 'error', errorKind: 'providerResolutionFailed' }; + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('resolving the installed resource attach provider', error); + return { outcome: 'error', errorKind: 'providerResolutionFailed' }; } if (!provider) { @@ -176,16 +242,19 @@ export class ResourceDebugService implements vscode.Disposable { let configuration: vscode.DebugConfiguration; try { - configuration = await provider.createDebugConfiguration(resource); + configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); } catch (error) { - if (error instanceof ResourceAttachConfigurationError) { - return { outcome: 'unsupportedResource' }; + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; } - return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested - ? { outcome: 'cancelled' } - : { outcome: 'error', errorKind: 'configurationFailed' }; + this._logFailure( + error instanceof ResourceAttachConfigurationError + ? 'creating an attach configuration for an ineligible resource' + : 'creating the resource attach configuration', + error); + return { outcome: 'error', errorKind: 'configurationFailed' }; } if (request.cancellationToken?.isCancellationRequested) { @@ -205,9 +274,16 @@ export class ResourceDebugService implements vscode.Disposable { } catch (error) { attempt.abandon(); - return isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested - ? { outcome: 'cancelled' } - : { outcome: 'error', errorKind: 'debuggerStartFailed' }; + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + this._logFailure('starting the resource debugger', error); + return { outcome: 'error', errorKind: 'debuggerStartFailed' }; } } + + private _logFailure(operation: string, error: unknown): void { + extensionLogOutputChannel.error(`Resource debugger failed while ${operation}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + } } diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 69f8eb55d22..07a09400156 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -15,10 +15,15 @@ export interface ResourceDebugSessionAttempt { abandon(): void; } +export interface ResourceDebugSessionRegistryOptions { + readonly pendingStartTimeoutMs?: number; +} + interface TrackedAttachAttempt { readonly marker: number; readonly resourceKey: string; readonly sessionIds: Set; + pendingStartTimeout: ReturnType | undefined; startAccepted: boolean; terminated: boolean; } @@ -29,13 +34,17 @@ interface TrackedAttachAttempt { * resource attach serialization or lifecycle state. */ export class ResourceDebugSessionRegistry implements vscode.Disposable { + private static readonly _defaultPendingStartTimeoutMs = 10_000; + private readonly _attempts = new Map(); private readonly _attemptsByResource = new Map>(); private readonly _resourceLocks = new Map>(); private readonly _subscriptions: vscode.Disposable; + private readonly _pendingStartTimeoutMs: number; private _nextMarker = 0; - constructor(events: ResourceDebugSessionEvents = vscode.debug) { + constructor(events: ResourceDebugSessionEvents = vscode.debug, options: ResourceDebugSessionRegistryOptions = {}) { + this._pendingStartTimeoutMs = options.pendingStartTimeoutMs ?? ResourceDebugSessionRegistry._defaultPendingStartTimeoutMs; this._subscriptions = vscode.Disposable.from( events.onDidStartDebugSession(session => this._onDidStartDebugSession(session)), events.onDidTerminateDebugSession(session => this._onDidTerminateDebugSession(session))); @@ -43,6 +52,9 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { dispose(): void { this._subscriptions.dispose(); + for (const attempt of this._attempts.values()) { + this._clearPendingStartExpiry(attempt); + } this._attempts.clear(); this._attemptsByResource.clear(); this._resourceLocks.clear(); @@ -60,7 +72,13 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { }); } - async runSerialized(appHost: ResourceDebugAppHostTarget, resourceName: string, operation: () => Promise): Promise { + async runSerialized( + appHost: ResourceDebugAppHostTarget, + resourceName: string, + cancellationToken: vscode.CancellationToken | undefined, + operation: () => Promise, + getCancelledResult: () => T, + ): Promise { const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); const precedingOperation = this._resourceLocks.get(resourceKey); let releaseCurrentOperation: (() => void) | undefined; @@ -69,8 +87,11 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { }); this._resourceLocks.set(resourceKey, currentOperation); - await precedingOperation?.catch(() => undefined); try { + if (!await this._waitForLock(precedingOperation, cancellationToken)) { + return getCancelledResult(); + } + return await operation(); } finally { @@ -88,6 +109,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { marker, resourceKey, sessionIds: new Set(), + pendingStartTimeout: undefined, startAccepted: false, terminated: false, }; @@ -102,12 +124,14 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { [resourceDebugSessionMarkerConfigKey]: marker, }, markStarted: () => { - if (attempt.terminated) { - this._removeAttempt(attempt); + if (this._attempts.get(attempt.marker) !== attempt || attempt.terminated) { return; } attempt.startAccepted = true; + if (attempt.sessionIds.size === 0) { + this._schedulePendingStartExpiry(attempt); + } }, abandon: () => this._removeAttempt(attempt), }; @@ -120,6 +144,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.sessionIds.add(session.id); + this._clearPendingStartExpiry(attempt); } private _onDidTerminateDebugSession(session: vscode.DebugSession): void { @@ -134,16 +159,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.terminated = true; - if (attempt.startAccepted) { - this._removeAttempt(attempt); - } - else { - const attemptMarkers = this._attemptsByResource.get(attempt.resourceKey); - attemptMarkers?.delete(attempt.marker); - if (attemptMarkers?.size === 0) { - this._attemptsByResource.delete(attempt.resourceKey); - } - } + this._removeAttempt(attempt); } private _getAttempt(session: vscode.DebugSession): TrackedAttachAttempt | undefined { @@ -152,6 +168,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } private _removeAttempt(attempt: TrackedAttachAttempt): void { + this._clearPendingStartExpiry(attempt); this._attempts.delete(attempt.marker); const attemptMarkers = this._attemptsByResource.get(attempt.resourceKey); attemptMarkers?.delete(attempt.marker); @@ -160,6 +177,54 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } } + private _schedulePendingStartExpiry(attempt: TrackedAttachAttempt): void { + this._clearPendingStartExpiry(attempt); + attempt.pendingStartTimeout = setTimeout(() => { + attempt.pendingStartTimeout = undefined; + if (this._attempts.get(attempt.marker) === attempt && attempt.sessionIds.size === 0) { + this._removeAttempt(attempt); + } + }, this._pendingStartTimeoutMs); + } + + private _clearPendingStartExpiry(attempt: TrackedAttachAttempt): void { + if (attempt.pendingStartTimeout) { + clearTimeout(attempt.pendingStartTimeout); + attempt.pendingStartTimeout = undefined; + } + } + + private async _waitForLock( + precedingOperation: Promise | undefined, + cancellationToken: vscode.CancellationToken | undefined, + ): Promise { + if (!precedingOperation) { + return !cancellationToken?.isCancellationRequested; + } + + return await new Promise(resolve => { + let settled = false; + let cancellationRegistration: vscode.Disposable | undefined; + const settle = (acquired: boolean) => { + if (settled) { + return; + } + + settled = true; + cancellationRegistration?.dispose(); + resolve(acquired); + }; + + cancellationRegistration = cancellationToken?.onCancellationRequested(() => settle(false)); + if (cancellationToken?.isCancellationRequested) { + settle(false); + return; + } + + void precedingOperation.catch(() => undefined).then(() => settle(true)); + }); + } + private _getResourceKey(appHostPath: string, resourceName: string): string { return `${getAppHostIdentityKey(appHostPath)}\u0000${resourceName}`; } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index fe81c5efbd6..82843eed32d 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -35,9 +35,10 @@ import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; -import { createResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; +import { ResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; import { ResourceDebugService } from './debugger/resourceDebugService'; import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; +import { projectResourceAttachProvider } from './debugger/languages/dotnet'; let aspireExtensionContext = new AspireExtensionContext(); @@ -113,7 +114,7 @@ export async function activate(context: vscode.ExtensionContext) { const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); const resourceDebugService = new ResourceDebugService({ appHostRepository: dataRepository, - attachProviders: createResourceAttachProviderRegistry(), + attachProviders: new ResourceAttachProviderRegistry([projectResourceAttachProvider]), sessionRegistry: new ResourceDebugSessionRegistry(), startDebugging: (workspaceFolder, configuration) => vscode.debug.startDebugging(workspaceFolder, configuration), @@ -126,7 +127,7 @@ export async function activate(context: vscode.ExtensionContext) { }); appHostLaunchService.setExternalAppHostStopper((appHostPath, token) => stopExternalAppHost(terminalProvider, appHostPath, token)); - const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, context.globalState, undefined, resourceDebugService); + const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider, appHostLaunchService, resourceDebugService, context.globalState); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.appHosts', { treeDataProvider: appHostTreeProvider, showCollapseAll: true, diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 5eab03bc98b..e61763f3202 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -148,6 +148,8 @@ export const attachDebuggerUnavailable = vscode.l10n.t('This resource is not a r export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); +export const attachingDebugger = (resource: string) => vscode.l10n.t('Attaching debugger to {0}...', resource); +export const attachDebuggerAlreadyDebugging = (resource: string) => vscode.l10n.t('A debugger is already attached to {0}.', resource); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); export const appHostCandidateDescription = (language: string, status: string) => vscode.l10n.t('{0} · {1}', language, status); export const workspaceViewSelectedSingleAppHost = (language?: string) => language diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index af912903b6d..7f1751c7192 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1222,6 +1222,27 @@ suite('AppHostDataRepository', () => { } }); + test('fetchAppHostResourcesOnce describes one AppHost with the caller cancellation token', async () => { + const describeProcess = new TestChildProcess(); + spawnStub.onFirstCall().returns(describeProcess); + const repository = new AppHostDataRepository(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + try { + const fetchPromise = repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', cancellation.token); + await waitForMicrotasks(); + + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['describe', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj']); + cancellation.cancel(); + + await assert.rejects(fetchPromise, vscode.CancellationError); + assert.strictEqual(describeProcess.killed, true); + } finally { + cancellation.dispose(); + repository.dispose(); + } + }); + test('fetchAppHostsOnce retries without nologo when an older CLI rejects it', async () => { const rejectedPsProcess = new TestChildProcess(); const psProcess = new TestChildProcess(); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index a38e59e0c76..9b21dbdd6cd 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -7,6 +7,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as capabilities from '../capabilities'; import { projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import type { ResourceDebugger, ResourceDebugRequest, ResourceDebugResult } from '../debugger/resourceDebugContracts'; import * as cliModule from '../utils/process/cliProcess'; import * as cliPathModule from '../utils/cliPath'; import * as configInfoProvider from '../utils/configInfoProvider'; @@ -71,6 +72,15 @@ function makeLaunchService(): AppHostLaunchService { return new AppHostLaunchService(); } +function makeResourceDebugger(result: ResourceDebugResult = { outcome: 'started', providerId: 'dotnet' }): ResourceDebugger { + return { + debug: async () => result, + canAttachToResource: resource => + projectResourceAttachProvider.canAttachToResource(resource) + && capabilities.isExtensionInstalled('ms-dotnettools.csharp'), + }; +} + function makeTerminalProvider(): AspireTerminalProvider { return { getAspireCliExecutablePath: async () => 'aspire', @@ -94,7 +104,12 @@ function makeClipboard(): FakeClipboard { }; } -function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: ViewMode = 'global', workspaceAppHostDescription?: string): AspireAppHostTreeProvider { +function makeTreeProvider( + appHosts: readonly AppHostDisplayInfo[], + viewMode: ViewMode = 'global', + workspaceAppHostDescription?: string, + resourceDebugger: ResourceDebugger = makeResourceDebugger(), +): AspireAppHostTreeProvider { const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); const repository = { viewMode, @@ -108,7 +123,7 @@ function makeTreeProvider(appHosts: readonly AppHostDisplayInfo[], viewMode: Vie fetchAppHostsOnce: async () => appHosts, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), resourceDebugger); } function getFirstResourceItem(provider: AspireAppHostTreeProvider): any { @@ -142,7 +157,7 @@ function makeTreeProviderWithLaunchService(appHosts: readonly AppHostDisplayInfo onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); } function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireAppHostTreeProvider { @@ -158,7 +173,7 @@ function makeWorkspaceTreeProvider(workspaceAppHostDescription: string): AspireA onDidChangeData, } as unknown as AppHostDataRepository; - return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + return new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); } interface ShellProof { @@ -490,7 +505,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -519,7 +534,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); @@ -546,7 +561,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath, false); @@ -582,7 +597,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh: sandbox.stub(), onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { provider.notifyAppHostStopping(upperCasePath); @@ -617,7 +632,7 @@ suite('AspireAppHostTreeProvider', () => { sandbox.stub(launchService, 'stopAppHost').returns(new Promise(resolve => { resolveStop = () => resolve({ outcome: 'stopped', controller: 'external' }); })); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); const stopTask = provider.stopAppHost(item as any); @@ -655,7 +670,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); sandbox.stub(launchService, 'stopAppHost').resolves(result); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); await provider.stopAppHost(item as any); @@ -684,7 +699,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); @@ -713,7 +728,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(workspaceRoot); @@ -743,7 +758,7 @@ suite('AspireAppHostTreeProvider', () => { requestAppHostStopRefresh, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.notifyAppHostStopping(appHostPath); provider.notifyAppHostStopping(unknownAppHostPath); @@ -774,7 +789,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -804,7 +819,7 @@ suite('AspireAppHostTreeProvider', () => { } as unknown as AppHostDataRepository; const launchService = makeLaunchService(); const stopStub = sandbox.stub(launchService, 'stopAppHost').resolves({ outcome: 'stopped', controller: 'external' }); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -840,7 +855,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -884,7 +899,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -923,7 +938,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, proofTerminalProvider.terminalProvider, makeLaunchService(), makeResourceDebugger()); try { const [workspaceItem] = provider.getChildren(); @@ -962,7 +977,7 @@ suite('AspireAppHostTreeProvider', () => { workspaceAppHostDescription: undefined, onDidChangeData: changeEmitter.event, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [item] = provider.getChildren(); provider.stopAppHost(item as any); @@ -1344,7 +1359,7 @@ suite('AspireAppHostTreeProvider', () => { return { stdout: '', stderr: '' }; }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const infoStub = sandbox.stub(vscode.window, 'showInformationMessage'); const [commandItem] = getResourceCommandItems(provider); @@ -1386,7 +1401,7 @@ suite('AspireAppHostTreeProvider', () => { throw new Error('resource command failed'); }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const errorStub = sandbox.stub(vscode.window, 'showErrorMessage'); const [commandItem] = getResourceCommandItems(provider); @@ -1428,7 +1443,7 @@ suite('AspireAppHostTreeProvider', () => { throw new Error(`${commandName} failed`); }, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const [appHostItem] = provider.getChildren(); @@ -2233,7 +2248,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const result = provider.findAppHostElement(hostPath); @@ -2253,7 +2268,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const result = provider.findAppHostElement('/repo/AppHost/AppHost.cs'); @@ -2272,7 +2287,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); // A single AppHost is surfaced directly at the root with no "Workspace AppHosts" // grouping node (https://github.com/microsoft/aspire/issues/18420). @@ -2326,7 +2341,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevel = provider.getChildren(); assert.strictEqual(topLevel.length, 1); @@ -2359,7 +2374,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const [group] = provider.getChildren(); @@ -2394,7 +2409,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); try { const topLevelItems = provider.getChildren(); @@ -2424,7 +2439,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); assert.deepStrictEqual(provider.getChildren(), []); provider.dispose(); @@ -2455,7 +2470,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); // A single launching AppHost is surfaced directly at the root with no grouping node. const [item] = provider.getChildren(); @@ -2484,7 +2499,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2519,7 +2534,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2550,7 +2565,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const topLevelItems = provider.getChildren(); @@ -2593,7 +2608,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); // A single candidate is surfaced directly at the root (no grouping node); pass it to runAppHost. const [item] = provider.getChildren(); @@ -2625,7 +2640,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), launchService, makeResourceDebugger()); const [item] = provider.getChildren(); await assert.rejects(provider.runAppHost(item as any, false), /startDebugging blew up/); @@ -2637,7 +2652,15 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); - test('attachDebuggerToResource starts CoreCLR with the resource attach configuration', async () => { + test('attachDebuggerToResource delegates to the injected debug service', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; const provider = makeTreeProvider([ makeAppHost({ resources: [ @@ -2650,67 +2673,59 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }), - ]); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ - type: 'coreclr', - request: 'attach', - name: 'Attach debugger: API', - processName: 'Api', - }); - const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + ], 'global', undefined, resourceDebugger); await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); - const configuration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; - assert.strictEqual(configuration.type, 'coreclr'); - assert.strictEqual(configuration.request, 'attach'); - assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processId, undefined); - assert.strictEqual(configuration.processName, 'Api'); + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.source, 'tree'); + assert.strictEqual(debugRequest.appHost.absolutePath, '/test/AppHost.csproj'); + assert.strictEqual(debugRequest.resourceName, 'api'); provider.dispose(); }); - test('attachDebuggerToResource creates the configuration from the latest resource snapshot', async () => { - const appHost = makeAppHost({ - resources: [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties(), - }), - ], + test('attachDebuggerToResource shows cancellable progress and reports an active debugger', async () => { + const progressToken = new vscode.CancellationTokenSource(); + const withProgressStub = sandbox.stub(vscode.window, 'withProgress').callsFake(async (options, task) => { + assert.strictEqual(options.cancellable, true); + await task({ report: () => { } }, progressToken.token); }); - const provider = makeTreeProvider([appHost]); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - const createConfigurationStub = sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ - type: 'coreclr', - request: 'attach', - name: 'Attach debugger: API', - processName: 'Api', - }); - const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); - const resourceItem = getFirstResourceItem(provider); - appHost.resources = [ - makeResource({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': '5252' }), + const informationStub = sandbox.stub(vscode.window, 'showInformationMessage'); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], }), - ]; + ], 'global', undefined, makeResourceDebugger({ outcome: 'alreadyDebugging' })); - await (provider as any).attachDebuggerToResource(resourceItem); + try { + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); - assert.ok(startDebuggingStub.calledOnce); - assert.strictEqual(createConfigurationStub.firstCall.args[0].properties?.['executable.pid'], '5252'); - provider.dispose(); + assert.ok(withProgressStub.calledOnce); + assert.ok(informationStub.calledOnce); + } + finally { + progressToken.dispose(); + provider.dispose(); + } }); - test('attachDebuggerToResource refreshes duplicate resource names from the owning AppHost', async () => { + test('attachDebuggerToResource preserves the owning AppHost for duplicate resource names', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; const appHosts = [ makeAppHost({ appHostPath: '/repo/first/AppHost.csproj', @@ -2739,15 +2754,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]; - const provider = makeTreeProvider(appHosts); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - const createConfigurationStub = sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ - type: 'coreclr', - request: 'attach', - name: 'Attach debugger: Second API', - processName: 'SecondApi', - }); - sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const provider = makeTreeProvider(appHosts, 'global', undefined, resourceDebugger); const secondAppHostItem = provider.getChildren()[1]; const resourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); assert.ok(resourcesGroup, 'Expected resources group for the second AppHost'); @@ -2755,8 +2762,9 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { await (provider as any).attachDebuggerToResource(secondResourceItem); - assert.strictEqual(createConfigurationStub.firstCall.args[0].displayName, 'Second API'); - assert.strictEqual(createConfigurationStub.firstCall.args[0].properties?.['executable.pid'], '222'); + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.appHost.absolutePath, '/repo/second/AppHost.csproj'); + assert.strictEqual(debugRequest.resourceName, 'api'); provider.dispose(); }); @@ -2774,9 +2782,11 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { ], }), ]; - const provider = makeTreeProvider(appHosts); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const provider = makeTreeProvider( + appHosts, + 'global', + undefined, + makeResourceDebugger({ outcome: 'resourceNotFound' })); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); const resourceItem = getFirstResourceItem(provider); appHosts.length = 0; @@ -2784,7 +2794,6 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const outcome = await (provider as any).attachDebuggerToResource(resourceItem); assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotFound' }); - assert.ok(startDebuggingStub.notCalled); assert.ok(warningStub.calledOnce); provider.dispose(); }); @@ -2801,9 +2810,11 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }); - const provider = makeTreeProvider([appHost]); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + const provider = makeTreeProvider( + [appHost], + 'global', + undefined, + makeResourceDebugger({ outcome: 'resourceNotRunning' })); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); const resourceItem = getFirstResourceItem(provider); appHost.resources = [ @@ -2819,7 +2830,6 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const outcome = await (provider as any).attachDebuggerToResource(resourceItem); assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); - assert.ok(startDebuggingStub.notCalled); assert.ok(warningStub.calledOnce); provider.dispose(); }); @@ -2837,15 +2847,15 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }), - ]); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(false); - const startDebuggingStub = sandbox.stub(vscode.debug, 'startDebugging').resolves(true); + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + })); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); assert.deepStrictEqual(outcome, { success: false, errorKind: 'CSharpExtensionMissing' }); - assert.ok(startDebuggingStub.notCalled); assert.ok(warningStub.calledOnce); provider.dispose(); }); @@ -2863,15 +2873,10 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), ], }), - ]); - sandbox.stub(capabilities, 'isExtensionInstalled').returns(true); - sandbox.stub(projectResourceAttachProvider, 'createDebugConfiguration').resolves({ - type: 'coreclr', - request: 'attach', - name: 'Attach debugger: API', - processName: 'Api', - }); - sandbox.stub(vscode.debug, 'startDebugging').resolves(false); + ], 'global', undefined, makeResourceDebugger({ + outcome: 'error', + errorKind: 'debuggerStartDeclined', + })); await assert.rejects( (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)), @@ -2901,7 +2906,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostCandidatePaths: [hostPath], onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -2931,7 +2936,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const appHostItems = provider.getChildren(); @@ -2971,7 +2976,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const otherAppHostItem = provider.getChildren()[1]; const resourcesGroup = provider.getChildren(otherAppHostItem).find(child => child.label === 'Resources'); @@ -3020,7 +3025,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const [runningAppHostItem] = provider.getChildren(); const resourceItem = provider.getChildren(runningAppHostItem)[0]; @@ -3066,7 +3071,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), } as unknown as AspireTerminalProvider; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService(), makeResourceDebugger()); const [workspaceItem] = provider.getChildren(); const [resourceItem] = provider.getChildren(workspaceItem); @@ -3095,7 +3100,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -3123,7 +3128,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: 'Workspace view selected because aspire ls found 2 buildable AppHosts.', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [selectedAppHostItem] = provider.getChildren(); const selectedChildren = provider.getChildren(selectedAppHostItem); @@ -3156,7 +3161,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const appHostChildren = provider.getChildren(appHostItem); @@ -3316,7 +3321,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -3343,7 +3348,7 @@ suite('LogFileItem in tree', () => { workspaceAppHostName: 'AppHost.csproj', onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const [appHostItem] = provider.getChildren(); const children = provider.getChildren(appHostItem); @@ -3474,7 +3479,7 @@ suite('copyAppHostPath', () => { onDidChangeData, } as unknown as AppHostDataRepository; const clipboard = makeClipboard(); - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), undefined, clipboard); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger(), undefined, clipboard); try { const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); @@ -3502,7 +3507,7 @@ suite('copyAppHostPath', () => { workspaceAppHostDescription: undefined, onDidChangeData: (() => ({ dispose: () => { } })) as vscode.Event, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), undefined, clipboard); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger(), undefined, clipboard); try { const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); const warningStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined as any); @@ -3541,7 +3546,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); @@ -3573,7 +3578,7 @@ suite('viewAppHostSource', () => { workspaceAppHostName: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); const registerStub = sandbox.stub(vscode.workspace, 'registerTextDocumentContentProvider').returns({ dispose: () => { } }); const fakeDoc = { uri: vscode.Uri.parse('aspire-source:AppHost-999.json') } as vscode.TextDocument; sandbox.stub(vscode.workspace, 'openTextDocument').resolves(fakeDoc); diff --git a/extension/src/test/aspireCodeLensProvider.test.ts b/extension/src/test/aspireCodeLensProvider.test.ts index a7c275e4820..cf09b0c8fa4 100644 --- a/extension/src/test/aspireCodeLensProvider.test.ts +++ b/extension/src/test/aspireCodeLensProvider.test.ts @@ -14,6 +14,7 @@ import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; // Import parsers so they self-register before the provider consults them. import '../editor/parsers/csharpAppHostParser'; import '../editor/parsers/jsTsAppHostParser'; @@ -126,7 +127,11 @@ function createHarness(opts: { const subs: vscode.Disposable[] = []; const terminalProvider = new AspireTerminalProvider(subs); const repository = new AppHostDataRepository(terminalProvider); - const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService()); + const resourceDebugger: ResourceDebugger = { + debug: async () => ({ outcome: 'unsupportedResource' }), + canAttachToResource: () => false, + }; + const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService(), resourceDebugger); const appHostsStub = sinon.stub(repository, 'appHosts').get(() => opts.appHosts ?? []); const workspaceResourcesStub = sinon.stub(repository, 'workspaceResources').get(() => opts.workspaceResources ?? []); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 5f1d6adc8d4..77ad3ffaf9d 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -8,7 +8,7 @@ import { createProjectDebuggerExtension, createProjectResourceAttachProvider, Do import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; -import type { ResourceAttachProvider } from '../debugger/resourceAttachProviders'; +import type { ResourceAttachProvider } from '../debugger/resourceDebugContracts'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 22c317e5648..24ac4c3129b 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -3,10 +3,11 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; import { projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; -import { ResourceAttachProvider, ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; +import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService } from '../debugger/resourceDebugService'; import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; -import type { ResourceDebugAppHostTarget, ResourceDebugRequest, ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugRequest, type ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; +import { extensionLogOutputChannel } from '../utils/logging'; const target: ResourceDebugAppHostTarget = { absolutePath: '/repo/AppHost.csproj', @@ -117,7 +118,9 @@ function createService(options: { events: TestDebugSessionEvents; } { const repository: ResourceDebugAppHostRepository = { - fetchAppHostsOnce: async () => options.appHosts ?? [createAppHost()], + fetchRunningAppHostsOnce: async () => options.appHosts ?? [createAppHost()], + fetchAppHostResourcesOnce: async appHostPath => + (options.appHosts ?? [createAppHost()]).find(appHost => appHost.appHostPath === appHostPath)?.resources ?? [], }; const events = new TestDebugSessionEvents(); const sessions = new ResourceDebugSessionRegistry(events); @@ -168,15 +171,16 @@ suite('Resource debug service', () => { let fetchCount = 0; let configuredResource: ResourceDebugResourceSnapshot | undefined; const repository: ResourceDebugAppHostRepository = { - fetchAppHostsOnce: async () => { + fetchRunningAppHostsOnce: async () => { + return [createAppHost({ resources: null })]; + }, + fetchAppHostResourcesOnce: async () => { fetchCount++; - return [createAppHost({ - resources: [createResource({ - properties: { - 'project.path': '/repo/api/Api.csproj', - 'executable.path': `dotnet-${fetchCount}`, - }, - })], + return [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': `dotnet-${fetchCount}`, + }, })]; }, }; @@ -203,6 +207,72 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('resolves the running AppHost before fetching only its resource snapshot', async () => { + const cancellation = new vscode.CancellationTokenSource(); + const fetchedPaths: string[] = []; + const receivedTokens: Array = []; + const repository: ResourceDebugAppHostRepository = { + fetchRunningAppHostsOnce: async token => { + assert.strictEqual(token, cancellation.token); + return [ + createAppHost({ appHostPath: '/repo/other/AppHost.csproj', resources: null }), + createAppHost({ appHostPath: '/repo/resolved/AppHost.csproj', resources: null }), + ]; + }, + fetchAppHostResourcesOnce: async (appHostPath, token) => { + fetchedPaths.push(appHostPath); + receivedTokens.push(token); + return [createResource()]; + }, + }; + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const service = new ResourceDebugService({ + appHostRepository: repository, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + compareAppHostIdentity: (requestedPath, appHostPath) => + requestedPath === '/repo/alias/AppHost.csproj' && appHostPath === '/repo/resolved/AppHost.csproj' + ? 'same' + : 'different', + }); + + try { + const result = await service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias/AppHost.csproj', displayPath: 'alias/AppHost.csproj' }, + cancellationToken: cancellation.token, + })); + + assert.deepStrictEqual(result, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(fetchedPaths, ['/repo/resolved/AppHost.csproj']); + assert.deepStrictEqual(receivedTokens, [cancellation.token]); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('returns a snapshot failure when the selected AppHost cannot be described', async () => { + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions, repository } = createService(); + repository.fetchAppHostResourcesOnce = async () => { + throw new Error('process 1234 at /repo/private/AppHost.csproj'); + }; + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'resourceSnapshotFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|AppHost\.csproj/); + assert.ok(logError.calledOnce); + } + finally { + sessions.dispose(); + } + }); + test('resolves duplicate resource names only within the requested AppHost', async () => { let configuredResource: ResourceDebugResourceSnapshot | undefined; const { service, sessions } = createService({ @@ -231,6 +301,28 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('keeps a typed configuration failure when the provider rejects an unattached resource', async () => { + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new ResourceAttachConfigurationError('resourceNotAttachable', 'process 1234 at /repo/private/Api.dll'); + }, + }), + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|Api\.dll/); + assert.ok(logError.calledOnce); + } + finally { + sessions.dispose(); + } + }); + test('fails closed when the AppHost identity is ambiguous', async () => { const { service, sessions } = createService({ compareAppHostIdentity: () => 'ambiguous', @@ -284,13 +376,19 @@ suite('Resource debug service', () => { const unsupported = createService({ provider: createProvider({ canAttachToResource: () => false }), }); + const unsupportedStopped = createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + provider: createProvider({ canAttachToResource: () => false }), + }); const stopped = createService({ appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], }); assert.deepStrictEqual(await unsupported.service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.deepStrictEqual(await unsupportedStopped.service.debug(createRequest()), { outcome: 'unsupportedResource' }); assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); unsupported.sessions.dispose(); + unsupportedStopped.sessions.dispose(); stopped.sessions.dispose(); }); @@ -325,7 +423,7 @@ suite('Resource debug service', () => { }); const { service, repository, sessions } = createService({ startDebugging }); let fetchCount = 0; - repository.fetchAppHostsOnce = async () => { + repository.fetchRunningAppHostsOnce = async () => { fetchCount++; return [createAppHost()]; }; @@ -334,7 +432,7 @@ suite('Resource debug service', () => { const second = service.debug(createRequest()); await startCalled; assert.strictEqual(startDebugging.callCount, 1); - assert.strictEqual(fetchCount, 1); + assert.strictEqual(fetchCount, 2); completeStart!(true); @@ -343,6 +441,84 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('cancels a request while it waits for the resource lock', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let signalStart: (() => void) | undefined; + let signalSecondIdentityFetch: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + signalStart = resolve; + }); + const secondIdentityFetch = new Promise(resolve => { + signalSecondIdentityFetch = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + signalStart!(); + return startRequest; + }); + const { service, repository, sessions } = createService({ startDebugging }); + let identityFetchCount = 0; + let resourceSnapshotCount = 0; + repository.fetchRunningAppHostsOnce = async () => { + identityFetchCount++; + if (identityFetchCount === 2) { + signalSecondIdentityFetch!(); + } + return [createAppHost({ resources: null })]; + }; + repository.fetchAppHostResourcesOnce = async () => { + resourceSnapshotCount++; + return [createResource()]; + }; + const cancellation = new vscode.CancellationTokenSource(); + + try { + const first = service.debug(createRequest()); + await startCalled; + + const second = service.debug(createRequest({ cancellationToken: cancellation.token })); + await secondIdentityFetch; + cancellation.cancel(); + + assert.deepStrictEqual(await second, { outcome: 'cancelled' }); + assert.strictEqual(resourceSnapshotCount, 1); + + completeStart!(true); + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('passes the request cancellation token to providers that support cancellation', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let receivedToken: vscode.CancellationToken | undefined; + const { service, sessions } = createService({ + provider: createProvider({ + createDebugConfiguration: async (_resource, token) => { + receivedToken = token; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest({ cancellationToken: cancellation.token })), { + outcome: 'started', + providerId: 'dotnet', + }); + assert.strictEqual(receivedToken, cancellation.token); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + test('returns alreadyDebugging while an independent attach session is active', async () => { const { service, sessions } = createService(); @@ -351,6 +527,123 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('expires an accepted start when the debugger session loses the private marker', async () => { + const clock = sinon.useFakeTimers(); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: 100 }); + const service = new ResourceDebugService({ + appHostRepository: { + fetchRunningAppHostsOnce: async () => [createAppHost({ resources: null })], + fetchAppHostResourcesOnce: async () => [createResource()], + }, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async () => true, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + + // A third-party debug configuration provider can resolve the session without preserving + // private properties from the launch configuration. + events.start({ type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }); + await clock.tickAsync(100); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + } + finally { + sessions.dispose(); + clock.restore(); + } + }); + + test('keeps an accepted start active when a correlated independent session starts', async () => { + const clock = sinon.useFakeTimers(); + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: 100 }); + let startedConfiguration: vscode.DebugConfiguration | undefined; + const service = new ResourceDebugService({ + appHostRepository: { + fetchRunningAppHostsOnce: async () => [createAppHost({ resources: null })], + fetchAppHostResourcesOnce: async () => [createResource()], + }, + attachProviders: new ResourceAttachProviderRegistry([createProvider()], () => true), + sessionRegistry: sessions, + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + await clock.tickAsync(100); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + } + finally { + sessions.dispose(); + clock.restore(); + } + }); + + test('does not reactivate an attempt terminated before start acceptance', () => { + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const attempt = sessions.createAttempt(target, 'api', { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }); + + try { + events.terminate(attempt.configuration); + attempt.markStarted(); + + assert.strictEqual(sessions.hasActiveSession(target, 'api'), false); + } + finally { + sessions.dispose(); + } + }); + + test('serializes aliases that resolve to the same running AppHost', async () => { + let completeStart: ((value: boolean) => void) | undefined; + let signalStart: (() => void) | undefined; + const startRequest = new Promise(resolve => { + completeStart = resolve; + }); + const startCalled = new Promise(resolve => { + signalStart = resolve; + }); + const startDebugging = sinon.stub().callsFake(() => { + signalStart!(); + return startRequest; + }); + const { service, sessions } = createService({ + startDebugging, + compareAppHostIdentity: () => 'same', + appHosts: [createAppHost({ appHostPath: '/repo/resolved/AppHost.csproj' })], + }); + const first = service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias-one/AppHost.csproj', displayPath: 'alias-one/AppHost.csproj' }, + })); + const second = service.debug(createRequest({ + appHost: { absolutePath: '/repo/alias-two/AppHost.csproj', displayPath: 'alias-two/AppHost.csproj' }, + })); + + await startCalled; + assert.strictEqual(startDebugging.callCount, 1); + + completeStart!(true); + + assert.deepStrictEqual(await first, { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await second, { outcome: 'alreadyDebugging' }); + sessions.dispose(); + }); + test('returns a bounded failure when VS Code declines to start debugging', async () => { const { service, sessions } = createService({ startDebugging: async () => false, diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 34fc67f9de4..ae9e53d917f 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -14,6 +14,8 @@ import { appHostSourceOpenFailed, logFileOpenFailed, logFilePathInvalid, + attachingDebugger, + attachDebuggerAlreadyDebugging, attachDebuggerUnavailable, attachDebuggerResourceNotFound, attachDebuggerCsharpExtensionRequired, @@ -40,9 +42,7 @@ import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { isSameFileSystemEntry } from '../utils/appHostDiscovery'; import { isAppHostSourceFile, isProjectFile } from '../utils/paths/comparison'; import { isCommandCancellation } from '../utils/telemetry'; -import { createResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; -import { ResourceDebugService } from '../debugger/resourceDebugService'; -import { ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; import { getParentResourceName, getTerminalReplicaIndex, @@ -116,8 +116,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider(); private _treeView: vscode.TreeView | undefined; - private readonly _resourceDebugService: ResourceDebugService; - private readonly _ownsResourceDebugService: boolean; + private readonly _resourceDebugService: ResourceDebugger; private _documentCloseSubscription: vscode.Disposable | undefined; @@ -125,18 +124,11 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider - vscode.debug.startDebugging(workspaceFolder, configuration), - }); + this._resourceDebugService = resourceDebugService; this._dataSubscription = this._repository.onDidChangeData(() => { this._clearLaunchingPathsForRunningAppHosts(); this._clearStoppingPathsForStoppedAppHosts(); @@ -191,9 +183,6 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); for (const resource of sortResources(topLevel)) { const hasChildren = element.resources.some(r => getParentResourceName(r) === resource.name); - items.push(new ResourceItem(resource, null, hasChildren, element.resources, element.appHostPath)); + items.push(new ResourceItem( + resource, + null, + hasChildren, + element.resources, + element.appHostPath, + this._resourceDebugService.canAttachToResource(resource))); } return items; } @@ -721,7 +716,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r)); return sortResources(topLevel).map(r => { const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name); - return new ResourceItem(r, element.appHostPid, hasChildren, element.resources); + return new ResourceItem( + r, + element.appHostPid, + hasChildren, + element.resources, + undefined, + this._resourceDebugService.canAttachToResource(r)); }); } @@ -747,7 +748,13 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider getParentResourceName(r) === element.resource.name); for (const child of sortResources(children)) { const hasChildren = allResources.some(r => getParentResourceName(r) === child.name); - items.push(new ResourceItem(child, element.appHostPid, hasChildren, allResources, element.appHostPath)); + items.push(new ResourceItem( + child, + element.appHostPid, + hasChildren, + allResources, + element.appHostPath, + this._resourceDebugService.canAttachToResource(child))); } const urls = getVisibleResourceUrls(element.resource); @@ -990,6 +997,18 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + return await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: attachingDebugger(element.resource.displayName ?? element.resource.name), + cancellable: true, + }, async (_progress, cancellationToken) => + await this._attachDebuggerToResource(element, cancellationToken)); + } + + private async _attachDebuggerToResource( + element: ResourceItem, + cancellationToken: vscode.CancellationToken, + ): Promise { // Global resource items retain the AppHost PID rather than its path. Resolve that // owner again before refreshing the resource snapshot so duplicate resource names // in different AppHosts cannot attach to whichever host happens to render first. @@ -1006,12 +1025,15 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0; @@ -144,8 +144,6 @@ export class ResourceItem extends vscode.TreeItem { this.iconPath = getResourceIcon(resource); this.description = buildResourceDescription(resource); this.tooltip = buildResourceTooltip(resource); - this.contextValue = getResourceContextValue( - resource, - getInstalledResourceAttachProviderForResource(resource) !== undefined); + this.contextValue = getResourceContextValue(resource, canAttachDebugger); } } diff --git a/extension/src/views/treePresentation.ts b/extension/src/views/treePresentation.ts index 52118c5d8e9..214bab65071 100644 --- a/extension/src/views/treePresentation.ts +++ b/extension/src/views/treePresentation.ts @@ -13,7 +13,6 @@ import { } from '../loc/strings'; import { isLinkableUrl } from '../utils/urlSchemes'; import { ResourceCommandJson, ResourceJson } from '../data/AppHostDataRepository'; -import { getKnownResourceAttachProviderForResource } from '../debugger/resourceAttachProviders'; export const integratedBrowserOpenCommand = 'workbench.action.browser.open'; export const terminalEnabledPropertyName = 'terminal.enabled'; @@ -113,7 +112,10 @@ export function getResourceContextValue(resource: ResourceJson, canAttachDebugge if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } - if (canAttachDebugger && getKnownResourceAttachProviderForResource(resource) !== undefined) { + if (canAttachDebugger && + resource.resourceType === 'Project' && + resource.state === ResourceState.Running && + typeof resource.properties?.['executable.pid'] === 'string') { parts.push('canAttachDebugger'); } return parts.join(':'); From d574d2bfd6dd55addb4226db0ed476a9527abb64 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 23:09:25 -0400 Subject: [PATCH 44/90] fix(extension): complete resource debugger hardening Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/loc/xlf/aspire-vscode.xlf | 6 + extension/package.nls.json | 2 + extension/src/debugger/languages/dotnet.ts | 144 ++++++++++++----- .../src/debugger/resourceAttachProviders.ts | 13 +- .../src/debugger/resourceDebugContracts.ts | 10 ++ .../src/debugger/resourceDebugService.ts | 54 ++----- .../debugger/resourceDebugSessionRegistry.ts | 13 +- extension/src/test/appHostTreeView.test.ts | 24 ++- extension/src/test/dotnetDebugger.test.ts | 107 ++++++++++-- .../src/test/resourceDebugService.test.ts | 153 +++++++++++++++++- extension/src/test/strings.test.ts | 17 ++ extension/src/views/treePresentation.ts | 5 +- 12 files changed, 443 insertions(+), 105 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index e376ab2f242..97bc76055e9 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ A debug session is already active for id {0}. + + A debugger is already attached to {0}. + Add an integration @@ -88,6 +91,9 @@ Attach debugger: {0} + + Attaching debugger to {0}... + Attempted to start unsupported resource type: {0}. diff --git a/extension/package.nls.json b/extension/package.nls.json index d8e98b44202..ab292eb7b2b 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -288,6 +288,8 @@ "aspire-vscode.strings.appHostPathLabel": "Path", "aspire-vscode.strings.appHostStartingDescription": "Starting...", "aspire-vscode.strings.attachDebuggerConfigurationName": "Attach debugger: {0}", + "aspire-vscode.strings.attachingDebugger": "Attaching debugger to {0}...", + "aspire-vscode.strings.attachDebuggerAlreadyDebugging": "A debugger is already attached to {0}.", "aspire-vscode.strings.attachDebuggerUnavailable": "This resource is not a running .NET project resource that can be attached with the C# debugger.", "aspire-vscode.strings.attachDebuggerResourceNotFound": "The selected resource is no longer available. Refresh the Aspire pane and try again.", "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 6a243928ac6..47e14c4ebe1 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerUnavailable } from '../../loc/strings'; -import { ChildProcessWithoutNullStreams, execFile, spawn } from 'child_process'; +import { ChildProcessWithoutNullStreams } from 'child_process'; +import * as childProcess from 'child_process'; import * as util from 'util'; import * as path from 'path'; import * as readline from 'readline'; @@ -26,11 +27,12 @@ import { import { AspireDebugSession } from '../AspireDebugSession'; import { createAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabledAdvisoryIfNeeded } from '../hotReload'; +import { terminateCliProcess } from '../../utils/process/cliProcess'; interface IDotNetService { getAndActivateDevKit(): Promise buildDotNetProject(projectFile: string): Promise; - getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise; + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise; getDotNetTargetPath(projectFile: string): Promise; getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } @@ -61,7 +63,7 @@ export class DotNetService implements IDotNetService { this._debugSession = debugSession; } - execFileAsync = util.promisify(execFile); + execFileAsync = util.promisify(childProcess.execFile); writeToDebugConsole(message: string, category: 'stdout' | 'stderr', addNewLine: boolean = false): void { this._debugSession?.sendMessage(message, addNewLine, category); @@ -88,7 +90,7 @@ export class DotNetService implements IDotNetService { extensionLogOutputChannel.info(`Building .NET project: ${projectFile} using dotnet CLI`); const args = ['build', projectFile]; - const buildProcess = spawn('dotnet', args, { + const buildProcess = childProcess.spawn('dotnet', args, { // The .NET SDK searches for global.json from the process working directory, not the // project argument. Run from the project directory so extension and CLI builds select // the same SDK and repository configuration. @@ -133,7 +135,7 @@ export class DotNetService implements IDotNetService { }); } - async getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise { + async getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise { const args = [ 'msbuild', projectFile, @@ -148,11 +150,7 @@ export class DotNetService implements IDotNetService { } try { - const { stdout } = await this.execFileAsync('dotnet', args, { - cwd: path.dirname(projectFile), - encoding: 'utf8', - env: createAspireCliPathProcessEnvironment() - }); + const stdout = await this._runDotNetMsbuild(args, path.dirname(projectFile), cancellationToken); // Multiple -getProperty switches return: // { "Properties": { "TargetPath": "/repo/bin/Release/net10.0/Api.dll", "UseAppHost": "false" } } const payload: unknown = JSON.parse(stdout); @@ -174,6 +172,10 @@ export class DotNetService implements IDotNetService { useAppHost: typeof useAppHost === 'string' && useAppHost.trim().toLowerCase() === 'true', }; } catch (err) { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + throw new Error(failedToGetTargetPath(String(err))); } } @@ -205,32 +207,32 @@ export class DotNetService implements IDotNetService { } async getDotNetRunApiOutput(projectPath: string, environment?: NodeJS.ProcessEnv): Promise { - let childProcess: ChildProcessWithoutNullStreams; + let runApiProcess: ChildProcessWithoutNullStreams; return new Promise(async (resolve, reject) => { try { const timeout = setTimeout(() => { - childProcess?.kill(); + runApiProcess?.kill(); reject(new Error('Timeout while waiting for dotnet run-api response')); }, 10_000); extensionLogOutputChannel.info('dotnet run-api - starting process'); - childProcess = spawn('dotnet', ['run-api'], { + runApiProcess = childProcess.spawn('dotnet', ['run-api'], { cwd: path.dirname(projectPath), env: createAspireCliPathProcessEnvironment({ ...process.env, ...environment }), stdio: ['pipe', 'pipe', 'pipe'] }); - childProcess.on('error', reject); - childProcess.on('exit', (code, signal) => { + runApiProcess.on('error', reject); + runApiProcess.on('exit', (code, signal) => { clearTimeout(timeout); if (code !== 0) { reject(new Error(processExitedWithCode(code?.toString() ?? "unknown"))); } }); - const rl = readline.createInterface(childProcess.stdout); + const rl = readline.createInterface(runApiProcess.stdout); rl.on('line', line => { clearTimeout(timeout); extensionLogOutputChannel.info(`dotnet run-api - received: ${line}`); @@ -239,12 +241,66 @@ export class DotNetService implements IDotNetService { const message = JSON.stringify({ ['$type']: 'GetRunCommand', ['EntryPointFileFullPath']: projectPath }); extensionLogOutputChannel.info(`dotnet run-api - sending: ${message}`); - childProcess.stdin.write(message + os.EOL); - childProcess.stdin.end(); + runApiProcess.stdin.write(message + os.EOL); + runApiProcess.stdin.end(); } catch (e) { reject(e); } - }).finally(() => childProcess.removeAllListeners()); + }).finally(() => runApiProcess.removeAllListeners()); + } + + private _runDotNetMsbuild(args: string[], workingDirectory: string, cancellationToken: vscode.CancellationToken | undefined): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let cancellationRegistration: vscode.Disposable | undefined; + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + cancellationRegistration?.dispose(); + action(); + }; + const msbuildProcess = childProcess.spawn('dotnet', args, { + cwd: workingDirectory, + env: createAspireCliPathProcessEnvironment(), + stdio: 'pipe', + }); + let stdout = ''; + + msbuildProcess.stdout.setEncoding('utf8'); + msbuildProcess.stdout.on('data', (data: string) => { + stdout += data; + }); + + msbuildProcess.on('error', error => { + complete(() => reject(error)); + }); + msbuildProcess.on('close', code => { + if (cancellationToken?.isCancellationRequested) { + complete(() => reject(new vscode.CancellationError())); + } else if (code === 0) { + complete(() => resolve(stdout)); + } else { + complete(() => reject(new Error(`dotnet msbuild exited with code ${code ?? 'unknown'}`))); + } + }); + + const cancel = () => { + // This child is a short-lived metadata probe, not the resource or AppHost. Stop only + // its known process handle so cancellation cannot affect the workload being attached. + void terminateCliProcess(msbuildProcess, 'dotnet msbuild target discovery', { + force: true, + suppressTimeoutWarning: true, + }); + complete(() => reject(new vscode.CancellationError())); + }; + cancellationRegistration = cancellationToken?.onCancellationRequested(cancel); + if (cancellationToken?.isCancellationRequested) { + cancel(); + } + }); } } @@ -459,41 +515,50 @@ function configureDotNetRunDebugConfiguration( } function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnapshot): DotNetAttachDebuggerResourceInfo | undefined { - if (resource.resourceType !== 'Project' || resource.state !== 'Running') { + if (resource.state !== 'Running' || !canRecognizeDotNetAttachDebuggerResource(resource)) { + return undefined; + } + + if (getAttachDebuggerProcessId(resource) === undefined) { return undefined; } + const projectPath = resource.properties?.[projectPathPropertyName] as string; + return { + configuration: getDotNetLaunchConfiguration(resource), + projectPath, + resourceLabel: resource.displayName ?? resource.name, + }; +} + +function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourceSnapshot): boolean { + if (resource.resourceType !== 'Project') { + return false; + } + const launchConfigurationType = getLaunchConfigurationType(resource); // Newer AppHosts identify MAUI platform resources explicitly. Older AppHosts do not emit this // property, so retain the parent fallback there rather than risking a CoreCLR attach to a device // or simulator process. Ordinary grouped projects from newer AppHosts remain attachable. if (launchConfigurationType === 'maui' || (launchConfigurationType === null && getResourceParentName(resource) !== null)) { - return undefined; - } - - if (getAttachDebuggerProcessId(resource) === undefined) { - return undefined; + return false; } if (!isDotNetExecutable(resource)) { - return undefined; + return false; } const projectPath: unknown = resource.properties?.[projectPathPropertyName]; if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { - return undefined; + return false; } if (!dotNetProjectFileExtensions.has(path.extname(projectPath).toLowerCase())) { - return undefined; + return false; } - return { - configuration: getDotNetLaunchConfiguration(resource), - projectPath, - resourceLabel: resource.displayName ?? resource.name, - }; + return true; } function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): string | undefined { @@ -565,7 +630,11 @@ function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -export async function createDotNetAttachDebugSessionConfiguration(resource: ResourceDebugResourceSnapshot, dotNetService: IDotNetService): Promise { +export async function createDotNetAttachDebugSessionConfiguration( + resource: ResourceDebugResourceSnapshot, + dotNetService: IDotNetService, + cancellationToken?: vscode.CancellationToken, +): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { throw new ResourceAttachConfigurationError('resourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); @@ -573,7 +642,7 @@ export async function createDotNetAttachDebugSessionConfiguration(resource: Reso let targetInfo: DotNetAttachTargetInfo; try { - targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration); + targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration, cancellationToken); } catch (error) { throw new ResourceAttachConfigurationError( @@ -852,9 +921,10 @@ export function createProjectResourceAttachProvider(dotNetServiceProducer: () => id: 'ms-dotnettools.csharp', label: 'C#', }], + canRecognizeResource: resource => canRecognizeDotNetAttachDebuggerResource(resource), canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, - createDebugConfiguration: async resource => - await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer()), + createDebugConfiguration: async (resource, cancellationToken) => + await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), cancellationToken), }; } diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts index bd61a913aff..4904a3602b6 100644 --- a/extension/src/debugger/resourceAttachProviders.ts +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -12,14 +12,19 @@ export class ResourceAttachProviderRegistry { ) { } - getKnownProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + getRecognizedProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { + return this._knownProviders.find(provider => provider.canRecognizeResource(resource)); + } + + getAttachableProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { return this._knownProviders.find(provider => provider.canAttachToResource(resource)); } getInstalledProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { - return this._knownProviders.find(provider => - provider.canAttachToResource(resource) && - this.getMissingDebuggerExtensions(provider).length === 0); + const provider = this.getAttachableProviderForResource(resource); + return provider && this.getMissingDebuggerExtensions(provider).length === 0 + ? provider + : undefined; } getMissingDebuggerExtensions(provider: ResourceAttachProvider): readonly ResourceDebugExtensionRequirement[] { diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index 405abddc295..d306bbbc5ad 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -46,6 +46,16 @@ export interface ResourceDebugExtensionRequirement { export interface ResourceAttachProvider { readonly id: ResourceAttachProviderId; readonly requiredDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; + /** + * Identifies resources this provider supports independently of their current state. The service + * uses this before checking whether a resource is running so stopped supported resources get a + * bounded resourceNotRunning result instead of being reported as unsupported. + */ + canRecognizeResource(resource: ResourceDebugResourceSnapshot): boolean; + /** + * Determines whether a recognized resource is ready to attach now, including runtime metadata + * and any provider-specific attach prerequisites. + */ canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; createDebugConfiguration(resource: ResourceDebugResourceSnapshot, cancellationToken?: vscode.CancellationToken): Promise; } diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 2158ba0a97b..2a8bb38d5ed 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -152,7 +152,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger const resource = matchingResources[0]; let provider: ResourceAttachProvider | undefined; try { - provider = this._dependencies.attachProviders.getKnownProviderForResource(resource); + provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); } catch (error) { if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { @@ -171,37 +171,13 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'resourceNotRunning' }; } - let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; - try { - missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(provider); - } - catch (error) { - if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { - return { outcome: 'cancelled' }; - } - - this._logFailure('checking required debugger extensions', error); - return { outcome: 'error', errorKind: 'providerResolutionFailed' }; - } - - if (missingDebuggerExtensions.length > 0) { - return { - outcome: 'debuggerExtensionMissing', - debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ - id: requirement.id, - label: requirement.label, - })), - }; - } - - return await this._attach(request, resolvedTarget, resource, provider); + return await this._attach(request, resolvedTarget, resource); } private async _attach( request: ResourceDebugRequest, appHost: ResourceDebugAppHostTarget, resource: ResourceJson, - knownProvider: ResourceAttachProvider, ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -214,8 +190,10 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger let provider: ResourceAttachProvider | undefined; let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; try { - provider = this._dependencies.attachProviders.getInstalledProviderForResource(resource); - missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(knownProvider); + provider = this._dependencies.attachProviders.getAttachableProviderForResource(resource); + missingDebuggerExtensions = provider + ? this._dependencies.attachProviders.getMissingDebuggerExtensions(provider) + : []; } catch (error) { if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { @@ -227,19 +205,19 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } if (!provider) { - if (missingDebuggerExtensions.length > 0) { - return { - outcome: 'debuggerExtensionMissing', - debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ - id: requirement.id, - label: requirement.label, - })), - }; - } - return { outcome: 'unsupportedResource' }; } + if (missingDebuggerExtensions.length > 0) { + return { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ + id: requirement.id, + label: requirement.label, + })), + }; + } + let configuration: vscode.DebugConfiguration; try { configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 07a09400156..4c3b2b6efc9 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { getAppHostIdentityKey } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; import type { ResourceDebugAppHostTarget } from './resourceDebugContracts'; const resourceDebugSessionMarkerConfigKey = '__aspireResourceDebugSessionMarker'; @@ -82,9 +83,14 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); const precedingOperation = this._resourceLocks.get(resourceKey); let releaseCurrentOperation: (() => void) | undefined; - const currentOperation = new Promise(resolve => { + const currentOperationGate = new Promise(resolve => { releaseCurrentOperation = resolve; }); + // The map stores a canonical tail, not merely this caller's completion signal. A canceled + // waiter releases its gate promptly, but its tail still waits for the predecessor so later + // callers cannot overtake an active operation. + const currentOperation = (precedingOperation?.catch(() => undefined) ?? Promise.resolve()) + .then(() => currentOperationGate); this._resourceLocks.set(resourceKey, currentOperation); try { @@ -182,6 +188,11 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { attempt.pendingStartTimeout = setTimeout(() => { attempt.pendingStartTimeout = undefined; if (this._attempts.get(attempt.marker) === attempt && attempt.sessionIds.size === 0) { + // Debug adapters can strip private configuration properties. Do not fall back to + // matching sessions by process or configuration: that could claim an unrelated + // debugger session. Expire this bounded entry and make the residual recovery risk + // diagnosable instead. + extensionLogOutputChannel.warn('Resource debugger session tracking expired before its debug session reported the private marker. A later attach may start another session.'); this._removeAttempt(attempt); } }, this._pendingStartTimeoutMs); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 9b21dbdd6cd..65e19fa231b 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -1802,12 +1802,32 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource:canAttachDebugger'); }); - test('project without a process ID does not include attach debugger context', () => { + test('running resource with a numeric process ID includes provider-approved attach debugger context', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Running, - properties: makeAttachableProjectProperties({ 'executable.pid': null }), + properties: { + ...makeAttachableProjectProperties(), + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('running non-Project resource includes provider-approved attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'GoExecutable', + state: ResourceState.Running, }), true); + assert.strictEqual(result, 'resource:canAttachDebugger'); + }); + + test('project without provider approval does not include attach debugger context', () => { + const result = getResourceContextValue(makeResource({ + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties({ 'executable.pid': null }), + }), false); assert.strictEqual(result, 'resource'); }); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 77ad3ffaf9d..cef0de7a3b9 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -41,8 +41,10 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } - getDotNetAttachTargetInfo(projectFile: string, configuration?: string): Promise<{ targetPath: string, useAppHost: boolean }> { - return this.getDotNetAttachTargetInfoStub(projectFile, configuration); + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise<{ targetPath: string, useAppHost: boolean }> { + return cancellationToken + ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken) + : this.getDotNetAttachTargetInfoStub(projectFile, configuration); } getDotNetTargetPath(projectFile: string): Promise { @@ -63,6 +65,33 @@ class TestDotNetService { } } +function createMsbuildProcess(): { + process: childProcess.ChildProcessWithoutNullStreams; + stdout: EventEmitter & { setEncoding(encoding: string): void }; + kill: sinon.SinonStub; +} { + const process = new EventEmitter() as unknown as childProcess.ChildProcessWithoutNullStreams; + const stdout = Object.assign(new EventEmitter(), { + setEncoding: (_encoding: string) => { }, + }); + const kill = sinon.stub().callsFake((signal?: NodeJS.Signals | number) => { + (process as unknown as { killed: boolean }).killed = true; + process.emit('close', null, signal); + return true; + }); + Object.assign(process, { + exitCode: null, + signalCode: null, + killed: false, + pid: 1234, + kill, + stdout, + stderr: new EventEmitter(), + }); + + return { process, stdout, kill }; +} + suite('Dotnet Debugger Extension Tests', () => { let getHotReloadDiagnostics: sinon.SinonStub; let logHotReloadDiagnostics: sinon.SinonStub; @@ -137,16 +166,72 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); + test('attach configuration passes cancellation to target discovery', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const cancellation = new vscode.CancellationTokenSource(); + + try { + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }, cancellation.token); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', + undefined, + cancellation.token)); + } + finally { + cancellation.dispose(); + } + }); + + test('target discovery cancels and terminates its specific msbuild process', async () => { + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const cancellation = new vscode.CancellationTokenSource(); + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', undefined, cancellation.token); + cancellation.cancel(); + const outcome = await Promise.race([ + targetDiscovery.then( + () => 'completed', + error => error instanceof vscode.CancellationError ? 'cancelled' : 'failed'), + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), 100)), + ]); + + assert.strictEqual(outcome, 'cancelled'); + assert.ok(msbuildProcess.kill.calledOnce); + assert.deepStrictEqual(msbuildProcess.kill.firstCall.args, ['SIGKILL']); + } + finally { + cancellation.dispose(); + } + }); + test('attach configuration rejects projects launched without an apphost', async () => { const dotNetService = new DotNetService(undefined); - const execFileAsync = sinon.stub(dotNetService, 'execFileAsync').resolves({ - stdout: JSON.stringify({ - Properties: { - TargetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', - UseAppHost: 'false', - }, - }), - stderr: '', + const msbuildProcess = createMsbuildProcess(); + const spawn = sinon.stub(childProcess, 'spawn').callsFake(() => { + queueMicrotask(() => { + msbuildProcess.stdout.emit('data', JSON.stringify({ + Properties: { + TargetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + UseAppHost: 'false', + }, + })); + msbuildProcess.process.emit('close', 0); + }); + return msbuildProcess.process; }); const attachProvider = createProjectResourceAttachProvider(() => dotNetService); @@ -167,7 +252,7 @@ suite('Dotnet Debugger Extension Tests', () => { && error.name === 'ResourceAttachConfigurationError' && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); - assert.deepStrictEqual(execFileAsync.firstCall.args[1], [ + assert.deepStrictEqual(spawn.firstCall.args[1], [ 'msbuild', '/repo/api/Api.csproj', '-nologo', diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 24ac4c3129b..0c309e41ee6 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; -import { projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { createProjectResourceAttachProvider, projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService } from '../debugger/resourceDebugService'; import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; @@ -62,6 +62,7 @@ function createProvider(overrides: Partial = {}): Resour id: 'ms-dotnettools.csharp', label: 'C#', }], + canRecognizeResource: () => true, canAttachToResource: () => true, createDebugConfiguration: async () => ({ type: 'coreclr', @@ -158,7 +159,7 @@ suite('Resource debug service', () => { test('registers .NET attach behavior independently from the launch provider', () => { const providers = new ResourceAttachProviderRegistry([projectResourceAttachProvider], () => true); - assert.strictEqual(providers.getKnownProviderForResource(createResource({ + assert.strictEqual(providers.getRecognizedProviderForResource(createResource({ properties: { 'project.path': '/repo/api/Api.csproj', 'executable.path': 'dotnet', @@ -372,23 +373,38 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('checks attach eligibility before reporting a missing debugger extension', async () => { + const { service, sessions } = createService({ + provider: createProvider({ canAttachToResource: () => false }), + isExtensionInstalled: () => false, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'unsupportedResource' }); + sessions.dispose(); + }); + test('returns typed outcomes for unsupported and stopped resources', async () => { const unsupported = createService({ provider: createProvider({ canAttachToResource: () => false }), }); - const unsupportedStopped = createService({ + const stopped = createService({ appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], provider: createProvider({ canAttachToResource: () => false }), }); + + assert.deepStrictEqual(await unsupported.service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); + unsupported.sessions.dispose(); + stopped.sessions.dispose(); + }); + + test('recognizes stopped .NET resources before checking attach readiness', async () => { const stopped = createService({ appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + provider: projectResourceAttachProvider, }); - assert.deepStrictEqual(await unsupported.service.debug(createRequest()), { outcome: 'unsupportedResource' }); - assert.deepStrictEqual(await unsupportedStopped.service.debug(createRequest()), { outcome: 'unsupportedResource' }); assert.deepStrictEqual(await stopped.service.debug(createRequest()), { outcome: 'resourceNotRunning' }); - unsupported.sessions.dispose(); - unsupportedStopped.sessions.dispose(); stopped.sessions.dispose(); }); @@ -494,6 +510,67 @@ suite('Resource debug service', () => { } }); + test('keeps a later request blocked when a canceled waiter is between it and the active request', async () => { + const sessions = new ResourceDebugSessionRegistry(); + let releaseFirst: (() => void) | undefined; + let firstEntered: (() => void) | undefined; + let firstCompleted = false; + const firstCanComplete = new Promise(resolve => { + releaseFirst = resolve; + }); + const firstHasEntered = new Promise(resolve => { + firstEntered = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + let laterWaiterStarted = false; + + try { + const first = sessions.runSerialized( + target, + 'api', + undefined, + async () => { + firstEntered!(); + await firstCanComplete; + firstCompleted = true; + return 'first'; + }, + () => 'cancelled'); + await firstHasEntered; + + const canceledWaiter = sessions.runSerialized( + target, + 'api', + cancellation.token, + async () => 'second', + () => 'cancelled'); + const laterWaiter = sessions.runSerialized( + target, + 'api', + undefined, + async () => { + laterWaiterStarted = true; + return 'third'; + }, + () => 'cancelled'); + + cancellation.cancel(); + + assert.strictEqual(await canceledWaiter, 'cancelled'); + assert.strictEqual(firstCompleted, false); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(laterWaiterStarted, false); + + releaseFirst!(); + assert.strictEqual(await first, 'first'); + assert.strictEqual(await laterWaiter, 'third'); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + test('passes the request cancellation token to providers that support cancellation', async () => { const cancellation = new vscode.CancellationTokenSource(); let receivedToken: vscode.CancellationToken | undefined; @@ -519,6 +596,63 @@ suite('Resource debug service', () => { } }); + test('returns cancelled when .NET target discovery observes request cancellation', async () => { + let receivedToken: vscode.CancellationToken | undefined; + let signalTargetDiscoveryStarted: (() => void) | undefined; + const targetDiscoveryStarted = new Promise(resolve => { + signalTargetDiscoveryStarted = resolve; + }); + const provider = createProjectResourceAttachProvider(() => ({ + getAndActivateDevKit: async () => false, + buildDotNetProject: async () => { }, + getDotNetAttachTargetInfo: async ( + _projectFile: string, + _configuration: string | undefined, + cancellationToken: vscode.CancellationToken | undefined) => { + receivedToken = cancellationToken; + signalTargetDiscoveryStarted!(); + return await new Promise((_resolve, reject) => { + cancellationToken?.onCancellationRequested(() => reject(new vscode.CancellationError())); + }); + }, + getDotNetTargetPath: async () => '', + getDotNetRunApiOutput: async () => '', + } as never)); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '42', + }, + })], + })], + provider, + startDebugging, + }); + + try { + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await targetDiscoveryStarted; + cancellation.cancel(); + + const result = await Promise.race([ + operation, + new Promise<'timedOut'>(resolve => setTimeout(() => resolve('timedOut'), 100)), + ]); + assert.deepStrictEqual(result, { outcome: 'cancelled' }); + assert.strictEqual(receivedToken, cancellation.token); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + test('returns alreadyDebugging while an independent attach session is active', async () => { const { service, sessions } = createService(); @@ -527,8 +661,9 @@ suite('Resource debug service', () => { sessions.dispose(); }); - test('expires an accepted start when the debugger session loses the private marker', async () => { + test('logs marker-loss expiry before allowing a recovery attach attempt', async () => { const clock = sinon.useFakeTimers(); + const logWarning = sinon.stub(extensionLogOutputChannel, 'warn'); const events = new TestDebugSessionEvents(); const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: 100 }); const service = new ResourceDebugService({ @@ -550,6 +685,8 @@ suite('Resource debug service', () => { await clock.tickAsync(100); assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(logWarning.calledOnceWithExactly( + 'Resource debugger session tracking expired before its debug session reported the private marker. A later attach may start another session.')); } finally { sessions.dispose(); diff --git a/extension/src/test/strings.test.ts b/extension/src/test/strings.test.ts index aef58449b60..65427a0f236 100644 --- a/extension/src/test/strings.test.ts +++ b/extension/src/test/strings.test.ts @@ -80,6 +80,23 @@ suite('utils/strings tests', () => { const missingFromXlf = names.filter(name => !xlf.includes(``)); assert.deepStrictEqual(missingFromXlf, [], 'Regenerate loc/xlf/aspire-vscode.xlf with "yarn run localize" after adding package.nls.json entries.'); }); + + test('resource debugger strings are present in package.nls.json and the generated XLF catalog', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const xlf = fs.readFileSync(path.join(extensionRoot, 'loc', 'xlf', 'aspire-vscode.xlf'), 'utf8'); + const expectedStrings = { + attachingDebugger: 'Attaching debugger to {0}...', + attachDebuggerAlreadyDebugging: 'A debugger is already attached to {0}.', + }; + + for (const [name, value] of Object.entries(expectedStrings)) { + const key = `aspire-vscode.strings.${name}`; + assert.strictEqual(packageNls[key], value); + assert.ok(xlf.includes(``)); + } + }); + }); suite('loc/strings tests', () => { diff --git a/extension/src/views/treePresentation.ts b/extension/src/views/treePresentation.ts index 214bab65071..1c2c441102a 100644 --- a/extension/src/views/treePresentation.ts +++ b/extension/src/views/treePresentation.ts @@ -112,10 +112,7 @@ export function getResourceContextValue(resource: ResourceJson, canAttachDebugge if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } - if (canAttachDebugger && - resource.resourceType === 'Project' && - resource.state === ResourceState.Running && - typeof resource.properties?.['executable.pid'] === 'string') { + if (canAttachDebugger && resource.state === ResourceState.Running) { parts.push('canAttachDebugger'); } return parts.join(':'); From c307bf5a834952a4dbfad87581ac1e188c9a05f0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 23:35:35 -0400 Subject: [PATCH 45/90] fix(extension): resolve resource debugger blockers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 56 +++++++++-- .../src/debugger/resourceDebugService.ts | 22 ++--- .../debugger/resourceDebugSessionRegistry.ts | 10 +- extension/src/test/appHostTreeView.test.ts | 4 +- extension/src/test/dotnetDebugger.test.ts | 93 ++++++++++++++++++- .../src/test/resourceDebugService.test.ts | 49 ++++++++-- extension/src/views/treePresentation.ts | 2 +- 7 files changed, 200 insertions(+), 36 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 47e14c4ebe1..0d7eafb1b3b 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as readline from 'readline'; import * as os from 'os'; import * as fs from 'fs'; +import { LimitedOutputBuffer, oneShotOutputBufferLimit } from '../../data/appHostCliRunner'; import { doesFileExist } from '../../utils/io'; import { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, ProjectLaunchConfiguration } from '../../dcp/types'; import { ResourceDebuggerExtension } from '../debuggerExtensions'; @@ -57,6 +58,8 @@ const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfiguratio const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); export class DotNetService implements IDotNetService { + private static readonly _msbuildProbeTimeoutMs = 10_000; + private _debugSession: AspireDebugSession | undefined; constructor(debugSession: AspireDebugSession | undefined) { @@ -252,6 +255,7 @@ export class DotNetService implements IDotNetService { private _runDotNetMsbuild(args: string[], workingDirectory: string, cancellationToken: vscode.CancellationToken | undefined): Promise { return new Promise((resolve, reject) => { let completed = false; + let timeout: ReturnType | undefined; let cancellationRegistration: vscode.Disposable | undefined; const complete = (action: () => void) => { if (completed) { @@ -259,6 +263,10 @@ export class DotNetService implements IDotNetService { } completed = true; + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } cancellationRegistration?.dispose(); action(); }; @@ -267,36 +275,64 @@ export class DotNetService implements IDotNetService { env: createAspireCliPathProcessEnvironment(), stdio: 'pipe', }); - let stdout = ''; + const stdout = new LimitedOutputBuffer(oneShotOutputBufferLimit); + const stderr = new LimitedOutputBuffer(oneShotOutputBufferLimit); msbuildProcess.stdout.setEncoding('utf8'); msbuildProcess.stdout.on('data', (data: string) => { - stdout += data; + stdout.append(data); + }); + // The probe normally produces JSON only on stdout, but MSBuild can write enough failure + // detail to stderr to fill the pipe. Read both streams so a failed probe can always exit. + msbuildProcess.stderr.setEncoding('utf8'); + msbuildProcess.stderr.on('data', (data: string) => { + stderr.append(data); }); msbuildProcess.on('error', error => { - complete(() => reject(error)); + complete(() => reject(createMsbuildProbeError(error.message, stdout.value, stderr.value))); }); msbuildProcess.on('close', code => { if (cancellationToken?.isCancellationRequested) { complete(() => reject(new vscode.CancellationError())); } else if (code === 0) { - complete(() => resolve(stdout)); + complete(() => resolve(stdout.value)); } else { - complete(() => reject(new Error(`dotnet msbuild exited with code ${code ?? 'unknown'}`))); + complete(() => reject(createMsbuildProbeError( + `dotnet msbuild exited with code ${code ?? 'unknown'}`, + stdout.value, + stderr.value))); } }); - const cancel = () => { + const stopProbe = (error: Error) => { + if (completed) { + return; + } + // This child is a short-lived metadata probe, not the resource or AppHost. Stop only - // its known process handle so cancellation cannot affect the workload being attached. + // its known process handle so cancellation or timeout cannot affect the workload being attached. void terminateCliProcess(msbuildProcess, 'dotnet msbuild target discovery', { force: true, suppressTimeoutWarning: true, }); - complete(() => reject(new vscode.CancellationError())); + complete(() => reject(error)); + }; + const cancel = () => { + stopProbe(new vscode.CancellationError()); }; cancellationRegistration = cancellationToken?.onCancellationRequested(cancel); + if (completed) { + cancellationRegistration?.dispose(); + return; + } + + timeout = setTimeout(() => { + stopProbe(createMsbuildProbeError( + `dotnet msbuild target discovery timed out after ${DotNetService._msbuildProbeTimeoutMs}ms`, + stdout.value, + stderr.value)); + }, DotNetService._msbuildProbeTimeoutMs); if (cancellationToken?.isCancellationRequested) { cancel(); } @@ -407,6 +443,10 @@ function createErrorWithStreamedDebugConsoleOutput(message: string): Error { return error; } +function createMsbuildProbeError(reason: string, stdout: string, stderr: string): Error { + return new Error(`${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`); +} + async function shouldLaunchProjectWithDotNetRun(outputPath: string): Promise { if (path.extname(outputPath).toLowerCase() !== '.dll') { return false; diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 2a8bb38d5ed..2b190ca1840 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -51,7 +51,10 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger canAttachToResource(resource: ResourceJson): boolean { try { - return this._dependencies.attachProviders.getInstalledProviderForResource(resource) !== undefined; + const provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); + return provider !== undefined + && provider.canAttachToResource(resource) + && this._dependencies.attachProviders.getMissingDebuggerExtensions(provider).length === 0; } catch (error) { this._logFailure('checking whether a resource can be attached', error); @@ -171,13 +174,14 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'resourceNotRunning' }; } - return await this._attach(request, resolvedTarget, resource); + return await this._attach(request, resolvedTarget, resource, provider); } private async _attach( request: ResourceDebugRequest, appHost: ResourceDebugAppHostTarget, resource: ResourceJson, + provider: ResourceAttachProvider, ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -187,13 +191,13 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'alreadyDebugging' }; } - let provider: ResourceAttachProvider | undefined; let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; try { - provider = this._dependencies.attachProviders.getAttachableProviderForResource(resource); - missingDebuggerExtensions = provider - ? this._dependencies.attachProviders.getMissingDebuggerExtensions(provider) - : []; + if (!provider.canAttachToResource(resource)) { + return { outcome: 'unsupportedResource' }; + } + + missingDebuggerExtensions = this._dependencies.attachProviders.getMissingDebuggerExtensions(provider); } catch (error) { if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { @@ -204,10 +208,6 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'error', errorKind: 'providerResolutionFailed' }; } - if (!provider) { - return { outcome: 'unsupportedResource' }; - } - if (missingDebuggerExtensions.length > 0) { return { outcome: 'debuggerExtensionMissing', diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 4c3b2b6efc9..3830a5a10c6 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -102,9 +102,13 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } finally { releaseCurrentOperation!(); - if (this._resourceLocks.get(resourceKey) === currentOperation) { - this._resourceLocks.delete(resourceKey); - } + // A canceled waiter returns before its tail settles behind the active operation. + // Defer deletion until that canonical tail settles so a later request cannot overtake it. + void currentOperation.then(() => { + if (this._resourceLocks.get(resourceKey) === currentOperation) { + this._resourceLocks.delete(resourceKey); + } + }); } } diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 65e19fa231b..704167ffa3a 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -1831,13 +1831,13 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource'); }); - test('stopped project does not include attach debugger context', () => { + test('uses provider attachment approval without checking resource state', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', state: ResourceState.Finished, properties: makeAttachableProjectProperties(), }), true); - assert.strictEqual(result, 'resource'); + assert.strictEqual(result, 'resource:canAttachDebugger'); }); test('running .NET project excludes attach debugger context without C# support', () => { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index cef0de7a3b9..df7c126d4f2 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -11,6 +11,7 @@ import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../d import type { ResourceAttachProvider } from '../debugger/resourceDebugContracts'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; +import * as cliProcess from '../utils/process/cliProcess'; class TestDotNetService { private _hasDevKit: boolean; @@ -68,12 +69,16 @@ class TestDotNetService { function createMsbuildProcess(): { process: childProcess.ChildProcessWithoutNullStreams; stdout: EventEmitter & { setEncoding(encoding: string): void }; + stderr: EventEmitter & { setEncoding(encoding: string): void }; kill: sinon.SinonStub; } { const process = new EventEmitter() as unknown as childProcess.ChildProcessWithoutNullStreams; const stdout = Object.assign(new EventEmitter(), { setEncoding: (_encoding: string) => { }, }); + const stderr = Object.assign(new EventEmitter(), { + setEncoding: (_encoding: string) => { }, + }); const kill = sinon.stub().callsFake((signal?: NodeJS.Signals | number) => { (process as unknown as { killed: boolean }).killed = true; process.emit('close', null, signal); @@ -86,10 +91,10 @@ function createMsbuildProcess(): { pid: 1234, kill, stdout, - stderr: new EventEmitter(), + stderr, }); - return { process, stdout, kill }; + return { process, stdout, stderr, kill }; } suite('Dotnet Debugger Extension Tests', () => { @@ -197,6 +202,7 @@ suite('Dotnet Debugger Extension Tests', () => { const dotNetService = new DotNetService(undefined); const msbuildProcess = createMsbuildProcess(); sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); const cancellation = new vscode.CancellationTokenSource(); try { @@ -210,14 +216,93 @@ suite('Dotnet Debugger Extension Tests', () => { ]); assert.strictEqual(outcome, 'cancelled'); - assert.ok(msbuildProcess.kill.calledOnce); - assert.deepStrictEqual(msbuildProcess.kill.firstCall.args, ['SIGKILL']); + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); } finally { cancellation.dispose(); } }); + test('target discovery drains bounded stderr and includes probe output on failure', async () => { + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').callsFake(() => { + queueMicrotask(() => { + msbuildProcess.stdout.emit('data', 'stdout-marker'); + msbuildProcess.stderr.emit('data', 'x'.repeat(128 * 1024)); + msbuildProcess.stderr.emit('data', 'stderr-marker'); + msbuildProcess.process.emit('close', 1); + }); + return msbuildProcess.process; + }); + + await assert.rejects( + dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj'), + (error: unknown) => error instanceof Error + && error.message.includes('stdout-marker') + && error.message.includes('stderr-marker') + && error.message.length < 132 * 1024); + }); + + test('target discovery terminates its specific msbuild probe when it times out', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj'); + const completion = targetDiscovery.then( + () => 'completed', + error => error instanceof Error && /timed out/.test(error.message) ? 'timedOut' : 'failed'); + + await clock.tickAsync(10_000); + + assert.strictEqual(await Promise.race([completion, Promise.resolve('pending')]), 'timedOut'); + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); + } + finally { + clock.restore(); + } + }); + + test('target discovery does not arm a timeout after it has been cancelled', async () => { + const clock = sinon.useFakeTimers(); + const dotNetService = new DotNetService(undefined); + const msbuildProcess = createMsbuildProcess(); + sinon.stub(childProcess, 'spawn').returns(msbuildProcess.process); + const terminate = sinon.stub(cliProcess, 'terminateCliProcess').resolves(); + const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: true, + onCancellationRequested: listener => { + listener(undefined); + return new vscode.Disposable(() => { }); + }, + }; + + try { + const targetDiscovery = dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', undefined, cancellationToken); + + await assert.rejects(targetDiscovery, error => error instanceof vscode.CancellationError); + await clock.tickAsync(10_000); + + assert.ok(terminate.calledOnceWithExactly( + msbuildProcess.process, + 'dotnet msbuild target discovery', + { force: true, suppressTimeoutWarning: true })); + } + finally { + clock.restore(); + } + }); + test('attach configuration rejects projects launched without an apphost', async () => { const dotNetService = new DotNetService(undefined); const msbuildProcess = createMsbuildProcess(); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 0c309e41ee6..d08fce4aad7 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -109,6 +109,7 @@ class TestDebugSessionEvents implements ResourceDebugSessionEvents { function createService(options: { appHosts?: readonly AppHostDisplayInfo[]; provider?: ResourceAttachProvider; + providers?: readonly ResourceAttachProvider[]; isExtensionInstalled?: (extensionId: string) => boolean; startDebugging?: (folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; @@ -126,7 +127,7 @@ function createService(options: { const events = new TestDebugSessionEvents(); const sessions = new ResourceDebugSessionRegistry(events); const providers = new ResourceAttachProviderRegistry( - [options.provider ?? createProvider()], + options.providers ?? [options.provider ?? createProvider()], options.isExtensionInstalled ?? (() => true)); const service = new ResourceDebugService({ appHostRepository: repository, @@ -168,6 +169,39 @@ suite('Resource debug service', () => { }))?.id, 'dotnet'); }); + test('uses the first recognized provider for readiness and configuration', async () => { + const firstProvider = createProvider({ + canAttachToResource: sinon.stub().returns(false), + createDebugConfiguration: sinon.stub().rejects(new Error('first provider should not configure')), + }); + const secondProvider = createProvider({ + canAttachToResource: sinon.stub().returns(true), + createDebugConfiguration: sinon.stub().resolves({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: second provider', + }), + }); + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + providers: [firstProvider, secondProvider], + startDebugging, + }); + + try { + assert.strictEqual(service.canAttachToResource(createResource()), false); + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'unsupportedResource' }); + assert.strictEqual((firstProvider.canAttachToResource as sinon.SinonStub).callCount, 2); + assert.strictEqual((firstProvider.createDebugConfiguration as sinon.SinonStub).callCount, 0); + assert.strictEqual((secondProvider.canAttachToResource as sinon.SinonStub).callCount, 0); + assert.strictEqual((secondProvider.createDebugConfiguration as sinon.SinonStub).callCount, 0); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + sessions.dispose(); + } + }); + test('uses a fresh AppHost snapshot instead of a tree resource', async () => { let fetchCount = 0; let configuredResource: ResourceDebugResourceSnapshot | undefined; @@ -510,7 +544,7 @@ suite('Resource debug service', () => { } }); - test('keeps a later request blocked when a canceled waiter is between it and the active request', async () => { + test('keeps a later request blocked when a canceled waiter has already completed', async () => { const sessions = new ResourceDebugSessionRegistry(); let releaseFirst: (() => void) | undefined; let firstEntered: (() => void) | undefined; @@ -544,6 +578,12 @@ suite('Resource debug service', () => { cancellation.token, async () => 'second', () => 'cancelled'); + + cancellation.cancel(); + + assert.strictEqual(await canceledWaiter, 'cancelled'); + assert.strictEqual(firstCompleted, false); + const laterWaiter = sessions.runSerialized( target, 'api', @@ -553,11 +593,6 @@ suite('Resource debug service', () => { return 'third'; }, () => 'cancelled'); - - cancellation.cancel(); - - assert.strictEqual(await canceledWaiter, 'cancelled'); - assert.strictEqual(firstCompleted, false); await new Promise(resolve => setImmediate(resolve)); assert.strictEqual(laterWaiterStarted, false); diff --git a/extension/src/views/treePresentation.ts b/extension/src/views/treePresentation.ts index 1c2c441102a..846833db9d5 100644 --- a/extension/src/views/treePresentation.ts +++ b/extension/src/views/treePresentation.ts @@ -112,7 +112,7 @@ export function getResourceContextValue(resource: ResourceJson, canAttachDebugge if (isTerminalEnabled(resource)) { parts.push('canOpenTerminal'); } - if (canAttachDebugger && resource.state === ResourceState.Running) { + if (canAttachDebugger) { parts.push('canAttachDebugger'); } return parts.join(':'); From bf3e42ffb199e51a4b7f47c0dbd90b7a945df334 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 00:08:53 -0400 Subject: [PATCH 46/90] feat(extension): attach debugger to Go resources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/package.nls.json | 3 +- extension/src/debugger/languages/go.ts | 115 +++++- .../debugger/languages/goProcessDiscovery.ts | 372 ++++++++++++++++++ .../src/debugger/resourceAttachProviders.ts | 7 + .../src/debugger/resourceDebugContracts.ts | 2 +- .../src/debugger/resourceDebugService.ts | 3 +- extension/src/extension.ts | 5 +- extension/src/loc/strings.ts | 3 +- extension/src/test/appHostTreeView.test.ts | 57 +++ extension/src/test/goDebugger.test.ts | 136 ++++++- extension/src/test/goProcessDiscovery.test.ts | 231 +++++++++++ .../src/test/resourceDebugService.test.ts | 61 +++ .../src/views/AspireAppHostTreeProvider.ts | 4 +- 13 files changed, 988 insertions(+), 11 deletions(-) create mode 100644 extension/src/debugger/languages/goProcessDiscovery.ts create mode 100644 extension/src/test/goProcessDiscovery.test.ts diff --git a/extension/package.nls.json b/extension/package.nls.json index ab292eb7b2b..4c84a39a4b5 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -290,9 +290,10 @@ "aspire-vscode.strings.attachDebuggerConfigurationName": "Attach debugger: {0}", "aspire-vscode.strings.attachingDebugger": "Attaching debugger to {0}...", "aspire-vscode.strings.attachDebuggerAlreadyDebugging": "A debugger is already attached to {0}.", - "aspire-vscode.strings.attachDebuggerUnavailable": "This resource is not a running .NET project resource that can be attached with the C# debugger.", + "aspire-vscode.strings.attachDebuggerUnavailable": "This resource cannot be attached to a debugger.", "aspire-vscode.strings.attachDebuggerResourceNotFound": "The selected resource is no longer available. Refresh the Aspire pane and try again.", "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", + "aspire-vscode.strings.attachDebuggerExtensionsRequired": "Install {0} to attach the debugger to this resource.", "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", "aspire-vscode.strings.resourceCountDescription": "({0} resources)", "aspire-vscode.strings.appHostCandidateDescription": "{0} \u00b7 {1}", diff --git a/extension/src/debugger/languages/go.ts b/extension/src/debugger/languages/go.ts index 96dc5e934d4..5d9f245bf25 100644 --- a/extension/src/debugger/languages/go.ts +++ b/extension/src/debugger/languages/go.ts @@ -1,8 +1,19 @@ import * as vscode from 'vscode'; import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isGoLaunchConfiguration } from "../../dcp/types"; -import { goDisplayName, goLabel, invalidLaunchConfiguration } from "../../loc/strings"; +import { attachDebuggerConfigurationName, attachDebuggerUnavailable, goDisplayName, goLabel, invalidLaunchConfiguration } from "../../loc/strings"; import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; +import { goRunApplicationProcessResolver, type GoApplicationProcessResolver } from './goProcessDiscovery'; + +const executablePidPropertyName = 'executable.pid'; +const executablePathPropertyName = 'executable.path'; +const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; + +interface GoAttachDebuggerResourceInfo { + readonly parentPid: number; + readonly resourceLabel: string; +} function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string { if (isGoLaunchConfiguration(launchConfig)) { @@ -54,3 +65,105 @@ export const goDebuggerExtension: ResourceDebuggerExtension = { debugConfiguration.args = args ?? []; } }; + +export function createGoResourceAttachProvider(processResolver: GoApplicationProcessResolver): ResourceAttachProvider { + return { + id: 'go', + requiredDebuggerExtensions: [{ + id: 'golang.go', + label: goLabel, + }], + canRecognizeResource: resource => canRecognizeGoAttachDebuggerResource(resource), + canAttachToResource: resource => getGoAttachDebuggerResourceInfo(resource) !== undefined, + createDebugConfiguration: async (resource, cancellationToken) => + await createGoAttachDebugConfiguration(resource, processResolver, cancellationToken), + }; +} + +export const goResourceAttachProvider: ResourceAttachProvider = + createGoResourceAttachProvider(goRunApplicationProcessResolver); + +function canRecognizeGoAttachDebuggerResource(resource: ResourceDebugResourceSnapshot): boolean { + return getLaunchConfigurationType(resource) === 'go' && isGoExecutable(resource); +} + +function getGoAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnapshot): GoAttachDebuggerResourceInfo | undefined { + if (resource.state !== 'Running' || !canRecognizeGoAttachDebuggerResource(resource)) { + return undefined; + } + + const parentPid = getProcessId(resource); + if (parentPid === undefined) { + return undefined; + } + + return { + parentPid, + resourceLabel: resource.displayName ?? resource.name, + }; +} + +async function createGoAttachDebugConfiguration( + resource: ResourceDebugResourceSnapshot, + processResolver: GoApplicationProcessResolver, + cancellationToken?: vscode.CancellationToken, +): Promise { + const attachInfo = getGoAttachDebuggerResourceInfo(resource); + if (!attachInfo) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + let applicationPid: number; + try { + applicationPid = await processResolver.resolveApplicationPid(attachInfo.parentPid, cancellationToken); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + if (!Number.isInteger(applicationPid) || applicationPid <= 0) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); + } + + return { + type: 'go', + request: 'attach', + mode: 'local', + debugAdapter: 'dlv-dap', + name: attachDebuggerConfigurationName(attachInfo.resourceLabel), + processId: applicationPid, + }; +} + +function getLaunchConfigurationType(resource: ResourceDebugResourceSnapshot): string | undefined { + const value = resource.properties?.[resourceLaunchConfigurationTypePropertyName]; + return typeof value === 'string' ? value : undefined; +} + +function isGoExecutable(resource: ResourceDebugResourceSnapshot): boolean { + const executablePath = resource.properties?.[executablePathPropertyName]; + if (typeof executablePath !== 'string') { + return false; + } + + const executableName = executablePath.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; +} + +function getProcessId(resource: ResourceDebugResourceSnapshot): number | undefined { + const value = resource.properties?.[executablePidPropertyName]; + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const processId = Number(value); + return Number.isInteger(processId) && processId > 0 ? processId : undefined; +} diff --git a/extension/src/debugger/languages/goProcessDiscovery.ts b/extension/src/debugger/languages/goProcessDiscovery.ts new file mode 100644 index 00000000000..a147408719f --- /dev/null +++ b/extension/src/debugger/languages/goProcessDiscovery.ts @@ -0,0 +1,372 @@ +import * as childProcess from 'child_process'; +import * as vscode from 'vscode'; + +export interface GoProcessInfo { + readonly pid: number; + readonly parentPid: number; + readonly executable: string; + readonly command: string; +} + +export interface GoProcessQuery { + listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; +} + +export interface GoProcessDiscoveryClock { + now(): number; + sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise; +} + +export interface GoApplicationProcessResolver { + resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise; +} + +export interface GoProcessCommandRunner { + run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; +} + +const goBuildExecutablePattern = /(?:^|[\\/])go-build[^\\/\s]*(?:[\\/][^\\/\s]+)*[\\/]exe[\\/][^\\/\s]+(?:\.exe)?(?:\s|$)/i; +const maxProcessListingLength = 1024 * 1024; +const windowsProcessQuery = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress'; + +export function parsePosixProcessList(output: string): readonly GoProcessInfo[] { + const processes: GoProcessInfo[] = []; + + for (const line of output.split(/\r?\n/)) { + // `ps -axo pid=,ppid=,comm=,args=` produces rows such as: + // 42 10 /private/.../go-build123/b001/exe/api /private/.../go-build123/b001/exe/api --port 8080 + // The command can contain spaces, so only split the first three fixed fields. + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?\s*$/.exec(line); + if (!match) { + continue; + } + + const process = createProcessInfo(match[1], match[2], match[3], match[4] ?? match[3]); + if (process) { + processes.push(process); + } + } + + return processes; +} + +export function parseWindowsProcessList(output: string): readonly GoProcessInfo[] { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } + catch { + throw createProcessDiscoveryError(); + } + + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const processes: GoProcessInfo[] = []; + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + continue; + } + + const values = row as Record; + const process = createProcessInfo( + values.ProcessId, + values.ParentProcessId, + typeof values.ExecutablePath === 'string' && values.ExecutablePath.length > 0 + ? values.ExecutablePath + : values.Name, + values.CommandLine); + if (process) { + processes.push(process); + } + } + + return processes; +} + +export class GoRunApplicationProcessResolver implements GoApplicationProcessResolver { + private static readonly _defaultTimeoutMs = 5_000; + private static readonly _defaultRetryDelayMs = 100; + + constructor( + private readonly _processQuery: GoProcessQuery, + private readonly _clock: GoProcessDiscoveryClock = systemGoProcessDiscoveryClock, + options: { readonly timeoutMs?: number; readonly retryDelayMs?: number } = {}, + ) { + this._timeoutMs = options.timeoutMs ?? GoRunApplicationProcessResolver._defaultTimeoutMs; + this._retryDelayMs = options.retryDelayMs ?? GoRunApplicationProcessResolver._defaultRetryDelayMs; + } + + private readonly _timeoutMs: number; + private readonly _retryDelayMs: number; + + async resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise { + if (!isValidPid(goProcessId)) { + throw createProcessDiscoveryError(); + } + + const timeoutMs = Math.max(1, this._timeoutMs); + const retryDelayMs = Math.max(1, this._retryDelayMs); + const deadline = this._clock.now() + timeoutMs; + const maximumAttempts = Math.max(2, Math.ceil(timeoutMs / retryDelayMs) + 1); + let previousCandidate: number | undefined; + + for (let attempt = 0; attempt < maximumAttempts; attempt++) { + throwIfCancelled(cancellationToken); + + let processes: readonly GoProcessInfo[]; + try { + processes = await this._processQuery.listProcesses( + cancellationToken, + Math.max(1, deadline - this._clock.now())); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw createProcessDiscoveryError(); + } + + throwIfCancelled(cancellationToken); + + const candidate = findGoBuildApplicationDescendant(goProcessId, processes); + if (candidate !== undefined && candidate === previousCandidate) { + return candidate; + } + + previousCandidate = candidate; + const remainingTimeMs = deadline - this._clock.now(); + if (remainingTimeMs <= 0 || attempt === maximumAttempts - 1) { + break; + } + + try { + await this._clock.sleep(Math.min(retryDelayMs, remainingTimeMs), cancellationToken); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + throw createProcessDiscoveryError(); + } + } + + throw createProcessDiscoveryError(); + } +} + +export class SystemGoProcessQuery implements GoProcessQuery { + constructor( + private readonly _platform: NodeJS.Platform = process.platform, + private readonly _commandRunner: GoProcessCommandRunner = new SystemGoProcessCommandRunner(), + ) { + } + + async listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { + const output = this._platform === 'win32' + ? await this._commandRunner.run( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', windowsProcessQuery], + cancellationToken, + timeoutMs) + : await this._commandRunner.run( + 'ps', + ['-axo', 'pid=,ppid=,comm=,args='], + cancellationToken, + timeoutMs); + + return this._platform === 'win32' + ? parseWindowsProcessList(output) + : parsePosixProcessList(output); + } +} + +class SystemGoProcessCommandRunner implements GoProcessCommandRunner { + run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs = 1_000): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let cancellationRegistration: vscode.Disposable | undefined; + let timeout: ReturnType | undefined; + let output = ''; + const process = childProcess.spawn(command, args, { + stdio: 'pipe', + windowsHide: true, + }); + + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + if (timeout) { + clearTimeout(timeout); + } + cancellationRegistration?.dispose(); + action(); + }; + const stop = () => { + // Discovery owns this short-lived `ps` or PowerShell child only. Never signal the + // resource's `go run` process or any descendant while resolving an attach target. + if (!process.killed) { + process.kill(); + } + }; + const fail = () => { + stop(); + complete(() => reject(createProcessDiscoveryError())); + }; + + process.stdout.setEncoding('utf8'); + process.stdout.on('data', (chunk: string) => { + if (output.length + chunk.length > maxProcessListingLength) { + fail(); + return; + } + + output += chunk; + }); + // Drain stderr so a failed fixed query cannot block on a full pipe. Its contents may + // include command text and are intentionally neither logged nor returned. + process.stderr.resume(); + process.on('error', fail); + process.on('close', exitCode => { + if (exitCode === 0) { + complete(() => resolve(output)); + } + else { + complete(() => reject(createProcessDiscoveryError())); + } + }); + + cancellationRegistration = cancellationToken?.onCancellationRequested(fail); + timeout = setTimeout(fail, Math.max(1, timeoutMs)); + if (cancellationToken?.isCancellationRequested) { + fail(); + } + }); + } +} + +const systemGoProcessDiscoveryClock: GoProcessDiscoveryClock = { + now: () => Date.now(), + sleep: (milliseconds, cancellationToken) => new Promise((resolve, reject) => { + if (cancellationToken?.isCancellationRequested) { + reject(new vscode.CancellationError()); + return; + } + + let cancellationRegistration: vscode.Disposable | undefined; + const timeout = setTimeout(() => { + cancellationRegistration?.dispose(); + resolve(); + }, milliseconds); + cancellationRegistration = cancellationToken?.onCancellationRequested(() => { + clearTimeout(timeout); + cancellationRegistration?.dispose(); + reject(new vscode.CancellationError()); + }); + }), +}; + +export const goRunApplicationProcessResolver: GoApplicationProcessResolver = + new GoRunApplicationProcessResolver(new SystemGoProcessQuery()); + +function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executableValue: unknown, commandValue: unknown): GoProcessInfo | undefined { + const pid = parsePid(pidValue); + const parentPid = parseParentPid(parentPidValue); + const executable = typeof executableValue === 'string' ? executableValue.trim() : ''; + const command = typeof commandValue === 'string' ? commandValue.trim() : ''; + if (pid === undefined || parentPid === undefined || executable.length === 0) { + return undefined; + } + + return { + pid, + parentPid, + executable, + command: command.length > 0 ? command : executable, + }; +} + +function findGoBuildApplicationDescendant(goProcessId: number, processes: readonly GoProcessInfo[]): number | undefined { + const processById = new Map(); + const childrenByParentId = new Map(); + for (const process of processes) { + if (!isValidPid(process.pid) || !Number.isInteger(process.parentPid) || process.parentPid < 0 || processById.has(process.pid)) { + return undefined; + } + + processById.set(process.pid, process); + const children = childrenByParentId.get(process.parentPid) ?? []; + children.push(process); + childrenByParentId.set(process.parentPid, children); + } + + const goProcess = processById.get(goProcessId); + if (!goProcess || !isGoToolProcess(goProcess)) { + return undefined; + } + + const candidates: number[] = []; + const descendants = [...(childrenByParentId.get(goProcessId) ?? [])]; + for (let index = 0; index < descendants.length; index++) { + const descendant = descendants[index]; + if (isGoBuildApplication(descendant)) { + candidates.push(descendant.pid); + } + + descendants.push(...(childrenByParentId.get(descendant.pid) ?? [])); + } + + return candidates.length === 1 ? candidates[0] : undefined; +} + +function isGoBuildApplication(process: GoProcessInfo): boolean { + return goBuildExecutablePattern.test(process.executable) || goBuildExecutablePattern.test(process.command); +} + +function isGoToolProcess(process: GoProcessInfo): boolean { + const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; +} + +function parsePid(value: unknown): number | undefined { + if (typeof value === 'number' && isValidPid(value)) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const pid = Number(value); + return isValidPid(pid) ? pid : undefined; +} + +function parseParentPid(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const pid = Number(value); + return Number.isInteger(pid) && pid >= 0 ? pid : undefined; +} + +function isValidPid(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function throwIfCancelled(cancellationToken?: vscode.CancellationToken): void { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } +} + +function createProcessDiscoveryError(): Error { + return new Error('Unable to resolve the running Go application process.'); +} diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts index 4904a3602b6..8dc974398de 100644 --- a/extension/src/debugger/resourceAttachProviders.ts +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -1,10 +1,17 @@ import { isExtensionInstalled } from '../capabilities'; +import { projectResourceAttachProvider } from './languages/dotnet'; +import { goResourceAttachProvider } from './languages/go'; import { type ResourceAttachProvider, type ResourceDebugExtensionRequirement, type ResourceDebugResourceSnapshot, } from './resourceDebugContracts'; +export const extensionResourceAttachProviders: readonly ResourceAttachProvider[] = [ + projectResourceAttachProvider, + goResourceAttachProvider, +]; + export class ResourceAttachProviderRegistry { constructor( private readonly _knownProviders: readonly ResourceAttachProvider[], diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index d306bbbc5ad..50c22e88506 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -2,7 +2,7 @@ import type * as vscode from 'vscode'; export type ResourceDebugSource = 'tree' | 'languageModelTool'; -export type ResourceAttachProviderId = 'dotnet'; +export type ResourceAttachProviderId = 'dotnet' | 'go'; /** * An AppHost selected by a caller. The absolute path remains internal to the editor diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 2b190ca1840..c36cb7e9bbc 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -53,8 +53,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger try { const provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); return provider !== undefined - && provider.canAttachToResource(resource) - && this._dependencies.attachProviders.getMissingDebuggerExtensions(provider).length === 0; + && provider.canAttachToResource(resource); } catch (error) { this._logFailure('checking whether a resource can be attached', error); diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 82843eed32d..99320ea380f 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -35,10 +35,9 @@ import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; -import { ResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; +import { extensionResourceAttachProviders, ResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; import { ResourceDebugService } from './debugger/resourceDebugService'; import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; -import { projectResourceAttachProvider } from './debugger/languages/dotnet'; let aspireExtensionContext = new AspireExtensionContext(); @@ -114,7 +113,7 @@ export async function activate(context: vscode.ExtensionContext) { const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); const resourceDebugService = new ResourceDebugService({ appHostRepository: dataRepository, - attachProviders: new ResourceAttachProviderRegistry([projectResourceAttachProvider]), + attachProviders: new ResourceAttachProviderRegistry(extensionResourceAttachProviders), sessionRegistry: new ResourceDebugSessionRegistry(), startDebugging: (workspaceFolder, configuration) => vscode.debug.startDebugging(workspaceFolder, configuration), diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index e61763f3202..d2863ae9459 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -144,9 +144,10 @@ export const appHostStartingDescription = vscode.l10n.t('Starting...'); export const appHostStoppingDescription = vscode.l10n.t('Stopping...'); export const appHostDiscoveryProgress = vscode.l10n.t('Discovering AppHosts...'); export const attachDebuggerConfigurationName = (resource: string) => vscode.l10n.t('Attach debugger: {0}', resource); -export const attachDebuggerUnavailable = vscode.l10n.t('This resource is not a running .NET project resource that can be attached with the C# debugger.'); +export const attachDebuggerUnavailable = vscode.l10n.t('This resource cannot be attached to a debugger.'); export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); +export const attachDebuggerExtensionsRequired = (labels: string) => vscode.l10n.t('Install {0} to attach the debugger to this resource.', labels); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); export const attachingDebugger = (resource: string) => vscode.l10n.t('Attaching debugger to {0}...', resource); export const attachDebuggerAlreadyDebugging = (resource: string) => vscode.l10n.t('A debugger is already attached to {0}.', resource); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 704167ffa3a..128b0940cec 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -7,6 +7,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as capabilities from '../capabilities'; import { projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { goResourceAttachProvider } from '../debugger/languages/go'; import type { ResourceDebugger, ResourceDebugRequest, ResourceDebugResult } from '../debugger/resourceDebugContracts'; import * as cliModule from '../utils/process/cliProcess'; import * as cliPathModule from '../utils/cliPath'; @@ -1822,6 +1823,32 @@ suite('getResourceContextValue', () => { assert.strictEqual(result, 'resource:canAttachDebugger'); }); + test('running provider-approved Go resource includes attach debugger tree context', () => { + const resource = makeResource({ + resourceType: 'Executable', + state: ResourceState.Running, + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '4242', + }, + }); + const resourceDebugger: ResourceDebugger = { + debug: async () => ({ outcome: 'started', providerId: 'go' }), + canAttachToResource: candidate => goResourceAttachProvider.canAttachToResource(candidate), + }; + const provider = makeTreeProvider([ + makeAppHost({ resources: [resource] }), + ], 'global', undefined, resourceDebugger); + + try { + assert.strictEqual(getFirstResourceItem(provider).contextValue, 'resource:canAttachDebugger'); + } + finally { + provider.dispose(); + } + }); + test('project without provider approval does not include attach debugger context', () => { const result = getResourceContextValue(makeResource({ resourceType: 'Project', @@ -2880,6 +2907,36 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource reports missing Go debugger support without .NET-specific text', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Executable', + state: ResourceState.Running, + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '4242', + }, + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'golang.go', label: 'Go' }], + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('Install Go to attach the debugger to this resource.')); + provider.dispose(); + }); + test('attachDebuggerToResource reports when VS Code declines the attach session', async () => { const provider = makeTreeProvider([ makeAppHost({ diff --git a/extension/src/test/goDebugger.test.ts b/extension/src/test/goDebugger.test.ts index 770db293e16..2f527ee4ecc 100644 --- a/extension/src/test/goDebugger.test.ts +++ b/extension/src/test/goDebugger.test.ts @@ -4,14 +4,148 @@ import * as vscode from 'vscode'; import { getSupportedCapabilities } from '../capabilities'; import { AspireDebugSession } from '../debugger/AspireDebugSession'; import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; -import { goDebuggerExtension } from '../debugger/languages/go'; +import { createGoResourceAttachProvider, goDebuggerExtension, goResourceAttachProvider } from '../debugger/languages/go'; +import { extensionResourceAttachProviders } from '../debugger/resourceAttachProviders'; +import type { ResourceAttachProvider, ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; import { AspireResourceExtendedDebugConfiguration, GoLaunchConfiguration } from '../dcp/types'; +interface GoProcessResolver { + resolveApplicationPid(parentPid: number, cancellationToken?: vscode.CancellationToken): Promise; +} + +function createGoResource(overrides: Partial = {}): ResourceDebugResourceSnapshot { + return { + name: 'api', + displayName: 'API', + resourceType: 'Executable', + state: 'Running', + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': 123, + }, + ...overrides, + }; +} + +function createGoAttachProvider(resolvedProcessId = 456): { + provider: ResourceAttachProvider; + resolver: GoProcessResolver & { parentPids: number[] }; +} { + const resolver: GoProcessResolver & { parentPids: number[] } = { + parentPids: [], + async resolveApplicationPid(parentPid: number): Promise { + this.parentPids.push(parentPid); + return resolvedProcessId; + }, + }; + + return { + provider: createGoResourceAttachProvider(resolver), + resolver, + }; +} + suite('Go Debugger Extension Tests', () => { const fakeAspireDebugSession = {} as AspireDebugSession; teardown(() => sinon.restore()); + test('exposes an attach provider independently from the Go launch provider', () => { + assert.notStrictEqual(goResourceAttachProvider, goDebuggerExtension); + }); + + test('keeps Go after .NET in the extension resource attach provider registry', () => { + assert.deepStrictEqual(extensionResourceAttachProviders.map(provider => provider.id), ['dotnet', 'go']); + }); + + test('recognizes only Go launch-configuration resources with Go executable metadata', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canRecognizeResource(createGoResource()), true); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'node', + 'executable.path': 'node', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'python', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'bun', + 'executable.pid': 123, + }, + })), false); + assert.strictEqual(provider.canRecognizeResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'GO', + 'executable.path': 'go', + 'executable.pid': 123, + }, + })), false); + }); + + test('accepts numeric and numeric-string Go parent process IDs', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canAttachToResource(createGoResource()), true); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go.exe', + 'executable.pid': '123', + }, + })), true); + }); + + test('requires a running Go resource with a valid parent process ID', () => { + const { provider } = createGoAttachProvider(); + + assert.strictEqual(provider.canRecognizeResource(createGoResource({ state: 'Finished' })), true); + assert.strictEqual(provider.canAttachToResource(createGoResource({ state: 'Finished' })), false); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '12.5', + }, + })), false); + assert.strictEqual(provider.canAttachToResource(createGoResource({ + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': 0, + }, + })), false); + }); + + test('creates the exact Go attach configuration for the resolved application process', async () => { + const { provider, resolver } = createGoAttachProvider(456); + + const configuration = await provider.createDebugConfiguration(createGoResource({ + displayName: null, + name: 'api', + })); + + assert.deepStrictEqual(configuration, { + type: 'go', + request: 'attach', + mode: 'local', + debugAdapter: 'dlv-dap', + name: 'Attach debugger: api', + processId: 456, + }); + assert.deepStrictEqual(resolver.parentPids, [123]); + }); + test('advertises Go support when the Go extension is installed', () => { sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => { return extensionId === 'golang.go' ? { id: extensionId } as vscode.Extension : undefined; diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts new file mode 100644 index 00000000000..dd18e53abea --- /dev/null +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -0,0 +1,231 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { + GoRunApplicationProcessResolver, + parsePosixProcessList, + parseWindowsProcessList, + SystemGoProcessQuery, + type GoProcessCommandRunner, + type GoProcessDiscoveryClock, + type GoProcessInfo, + type GoProcessQuery, +} from '../debugger/languages/goProcessDiscovery'; + +class TestClock implements GoProcessDiscoveryClock { + private _now = 0; + + now(): number { + return this._now; + } + + async sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + this._now += milliseconds; + } +} + +class SequenceProcessQuery implements GoProcessQuery { + private _index = 0; + + constructor(private readonly _snapshots: readonly (readonly GoProcessInfo[] | Error)[]) { + } + + async listProcesses(): Promise { + const snapshot = this._snapshots[Math.min(this._index, this._snapshots.length - 1)]; + this._index++; + if (snapshot instanceof Error) { + throw snapshot; + } + + return snapshot; + } +} + +function process(pid: number, parentPid: number, executable: string, command = executable): GoProcessInfo { + return { pid, parentPid, executable, command }; +} + +function goRunProcessTree(applicationPid = 42): readonly GoProcessInfo[] { + return [ + process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), + process(22, 10, '/usr/local/go/pkg/tool/darwin_arm64/compile'), + process(33, 22, '/usr/local/go/pkg/tool/darwin_arm64/link'), + process(applicationPid, 33, `/private/var/folders/x/go-build123/b001/exe/api`, `/private/var/folders/x/go-build123/b001/exe/api --port 8080`), + ]; +} + +suite('Go process discovery', () => { + teardown(() => sinon.restore()); + + test('parses POSIX process listings without retaining incomplete rows', () => { + assert.deepStrictEqual(parsePosixProcessList([ + ' 10 1 /usr/local/go/bin/go go run ./cmd/api', + ' 42 10 /private/var/folders/x/go-build123/b001/exe/api /private/var/folders/x/go-build123/b001/exe/api --port 8080', + 'not a process row', + ].join('\n')), [ + process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), + process(42, 10, '/private/var/folders/x/go-build123/b001/exe/api', '/private/var/folders/x/go-build123/b001/exe/api --port 8080'), + ]); + }); + + test('parses Windows CIM process listings', () => { + assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify([ + { + ProcessId: 10, + ParentProcessId: 1, + Name: 'go.exe', + ExecutablePath: 'C:\\Go\\bin\\go.exe', + CommandLine: 'go run .\\cmd\\api', + }, + { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', + CommandLine: 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', + }, + ])), [ + process(10, 1, 'C:\\Go\\bin\\go.exe', 'go run .\\cmd\\api'), + process(42, 10, 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe', 'C:\\Users\\me\\AppData\\Local\\Temp\\go-build123\\b001\\exe\\api.exe'), + ]); + }); + + test('uses fixed platform-specific process discovery commands', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const commandRunner: GoProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + return command === 'ps' + ? '10 1 /usr/local/go/bin/go go run ./cmd/api' + : JSON.stringify({ + ProcessId: 10, + ParentProcessId: 1, + Name: 'go.exe', + ExecutablePath: 'C:\\Go\\bin\\go.exe', + CommandLine: 'go run .\\cmd\\api', + }); + }, + }; + + await new SystemGoProcessQuery('linux', commandRunner).listProcesses(); + await new SystemGoProcessQuery('win32', commandRunner).listProcesses(); + + assert.deepStrictEqual(calls, [ + { + command: 'ps', + args: ['-axo', 'pid=,ppid=,comm=,args='], + }, + { + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ], + }, + ]); + }); + + test('traverses nested children and ignores Go toolchain processes', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([goRunProcessTree(), goRunProcessTree()]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 42); + }); + + test('waits for the same Go build application candidate twice', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([ + goRunProcessTree(42), + goRunProcessTree(43), + goRunProcessTree(43), + ]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 43); + }); + + test('fails closed when no Go build application process exists', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api')], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails closed when Go build application candidates are ambiguous', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [ + ...goRunProcessTree(42), + process(43, 10, '/private/var/folders/x/go-build456/b001/exe/worker'), + ], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails closed when the reported Go parent process was reused', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([ + [ + process(10, 1, '/bin/bash', 'bash build.sh'), + process(42, 10, '/private/var/folders/x/go-build123/b001/exe/api'), + ], + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('fails within its bounded timeout without a stable candidate', async () => { + const resolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([ + goRunProcessTree(42), + goRunProcessTree(43), + goRunProcessTree(42), + ]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveApplicationPid(10)); + }); + + test('propagates cancellation and process-query failures without process details', async () => { + const cancellation = new vscode.CancellationTokenSource(); + const failedResolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([new Error('/private/go-build123/b001/exe/api 4242')]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + const cancelledResolver = new GoRunApplicationProcessResolver( + new SequenceProcessQuery([goRunProcessTree()]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + try { + await assert.rejects( + failedResolver.resolveApplicationPid(10), + error => error instanceof Error && !/go-build|4242/.test(error.message)); + cancellation.cancel(); + await assert.rejects(cancelledResolver.resolveApplicationPid(10, cancellation.token), vscode.CancellationError); + } + finally { + cancellation.dispose(); + } + }); +}); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index d08fce4aad7..7062b0a47e8 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -3,6 +3,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; import { createProjectResourceAttachProvider, projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; +import { createGoResourceAttachProvider } from '../debugger/languages/go'; import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService } from '../debugger/resourceDebugService'; import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; @@ -35,6 +36,18 @@ function createResource(overrides: Partial = {}): ResourceJson { }; } +function createGoResource(overrides: Partial = {}): ResourceJson { + return createResource({ + resourceType: 'Executable', + properties: { + 'resource.launchConfigurationType': 'go', + 'executable.path': 'go', + 'executable.pid': '1234', + }, + ...overrides, + }); +} + function createAppHost(overrides: Partial = {}): AppHostDisplayInfo { return { appHostPath: target.absolutePath, @@ -407,6 +420,54 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('reports the missing Go debugger extension using only its requirement metadata', async () => { + const resolver = { + resolveApplicationPid: sinon.stub().rejects(new Error('/private/go-build123/b001/exe/api 4567')), + }; + const { service, sessions } = createService({ + appHosts: [createAppHost({ resources: [createGoResource()] })], + provider: createGoResourceAttachProvider(resolver), + isExtensionInstalled: () => false, + }); + + try { + assert.strictEqual(service.canAttachToResource(createGoResource()), true); + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'golang.go', label: 'Go' }], + }); + assert.doesNotMatch(JSON.stringify(result), /1234|4567|go-build|\/private/); + assert.strictEqual(resolver.resolveApplicationPid.called, false); + } + finally { + sessions.dispose(); + } + }); + + test('normalizes Go process discovery failures without exposing process details', async () => { + const resolver = { + resolveApplicationPid: async () => { + throw new Error('/private/go-build123/b001/exe/api --port 8080 4567'); + }, + }; + const { service, sessions } = createService({ + appHosts: [createAppHost({ resources: [createGoResource()] })], + provider: createGoResourceAttachProvider(resolver), + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'configurationFailed' }); + assert.doesNotMatch(JSON.stringify(result), /1234|4567|go-build|\/private|8080/); + } + finally { + sessions.dispose(); + } + }); + test('checks attach eligibility before reporting a missing debugger extension', async () => { const { service, sessions } = createService({ provider: createProvider({ canAttachToResource: () => false }), diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index ae9e53d917f..29f6ebb8aa5 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -19,6 +19,7 @@ import { attachDebuggerUnavailable, attachDebuggerResourceNotFound, attachDebuggerCsharpExtensionRequired, + attachDebuggerExtensionsRequired, attachDebuggerDeclined, dashboardUrlNotFound, dashboardUrlUnsupported, @@ -1044,7 +1045,8 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider extension.label).join(', '))); return { success: false, errorKind: 'ResourceNotAttachable' }; case 'resourceNotRunning': case 'unsupportedResource': From 83bbecbf2c82638b13661e04d91a1b454217b717 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 00:53:35 -0400 Subject: [PATCH 47/90] fix: resolve debugger child processes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/loc/xlf/aspire-vscode.xlf | 5 +- extension/src/debugger/languages/dotnet.ts | 137 ++++++- extension/src/debugger/languages/go.ts | 46 ++- ...ry.ts => launchedChildProcessDiscovery.ts} | 118 ++++--- extension/src/test/dotnetDebugger.test.ts | 334 ++++++++++++++++-- extension/src/test/goProcessDiscovery.test.ts | 82 ++++- .../launchedChildProcessDiscovery.test.ts | 211 +++++++++++ 7 files changed, 823 insertions(+), 110 deletions(-) rename extension/src/debugger/{languages/goProcessDiscovery.ts => launchedChildProcessDiscovery.ts} (76%) create mode 100644 extension/src/test/launchedChildProcessDiscovery.test.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 97bc76055e9..b5d78fc35cf 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -394,6 +394,9 @@ Install the C# extension to attach the debugger to .NET project resources. + + Install {0} to attach the debugger to this resource. + Invalid launch configuration for {0}. @@ -833,7 +836,7 @@ This field is required. - This resource is not a running .NET project resource that can be attached with the C# debugger. + This resource cannot be attached to a debugger. This setting has been renamed to aspire.appHostsPollingInterval. diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 0d7eafb1b3b..8e9ff9254e5 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -29,6 +29,12 @@ import { AspireDebugSession } from '../AspireDebugSession'; import { createAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabledAdvisoryIfNeeded } from '../hotReload'; import { terminateCliProcess } from '../../utils/process/cliProcess'; +import { + getProcessCommandProgram, + launchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessIdentity, +} from '../launchedChildProcessDiscovery'; interface IDotNetService { getAndActivateDevKit(): Promise @@ -45,10 +51,19 @@ interface DotNetAttachTargetInfo { interface DotNetAttachDebuggerResourceInfo { configuration?: string; + launcherPid: number; projectPath: string; resourceLabel: string; } +interface LaunchedChildProcessResolver { + resolveProcessId( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + const executableArgsPropertyName = 'executable.args'; const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; @@ -559,13 +574,15 @@ function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnap return undefined; } - if (getAttachDebuggerProcessId(resource) === undefined) { + const launcherPid = getAttachDebuggerProcessId(resource); + if (launcherPid === undefined) { return undefined; } const projectPath = resource.properties?.[projectPathPropertyName] as string; return { configuration: getDotNetLaunchConfiguration(resource), + launcherPid, projectPath, resourceLabel: resource.displayName ?? resource.name, }; @@ -670,9 +687,94 @@ function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } +function createDotNetProcessIdentity(targetInfo: DotNetAttachTargetInfo): LaunchedChildProcessIdentity { + return { + isLauncher: process => isDotNetProcess(process), + isCandidate: process => targetInfo.useAppHost + ? isAppHostProcessForTarget(process, targetInfo.targetPath) + : isFrameworkDependentProcessForTarget(process, targetInfo.targetPath), + }; +} + +function isDotNetProcess(process: LaunchedChildProcess): boolean { + const executableName = getProcessCommandProgram(process.command)?.split(/[\\/]/).pop()?.toLowerCase() + ?? process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; +} + +function isAppHostProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { + const [program] = parseProcessCommandArguments(process.command); + return getAppHostPaths(targetPath).some(appHostPath => + areProcessPathsEqual(process.executable, appHostPath) || + (program !== undefined && areProcessPathsEqual(program, appHostPath))); +} + +function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { + return isDotNetProcess(process) && + parseProcessCommandArguments(process.command).some(argument => areProcessPathsEqual(argument, targetPath)); +} + +function areProcessPathsEqual(left: string, right: string): boolean { + const normalizedLeft = left.replace(/\\/g, '/'); + const normalizedRight = right.replace(/\\/g, '/'); + const isWindowsPath = /^[a-z]:\//i.test(normalizedLeft) || /^[a-z]:\//i.test(normalizedRight); + return isWindowsPath + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function getAppHostPaths(targetPath: string): readonly string[] { + if (path.extname(targetPath).toLowerCase() !== '.dll') { + return [targetPath]; + } + + const appHostPath = targetPath.slice(0, -'.dll'.length); + return [appHostPath, `${appHostPath}.exe`]; +} + +function parseProcessCommandArguments(command: string): readonly string[] { + const arguments_: string[] = []; + let currentArgument = ''; + let quote: '"' | "'" | undefined; + + // `ps` and CIM report command lines such as: + // dotnet exec "/repo/bin/Debug/net10.0/Api.dll" --urls http://localhost:5000 + // Keep only argument boundaries and quotes needed to identify the launched target; callers never + // receive this command text, which may contain application arguments. + for (const character of command) { + if (quote !== undefined) { + if (character === quote) { + quote = undefined; + } + else { + currentArgument += character; + } + } + else if (character === '"' || character === "'") { + quote = character; + } + else if (/\s/.test(character)) { + if (currentArgument.length > 0) { + arguments_.push(currentArgument); + currentArgument = ''; + } + } + else { + currentArgument += character; + } + } + + if (currentArgument.length > 0) { + arguments_.push(currentArgument); + } + + return arguments_; +} + export async function createDotNetAttachDebugSessionConfiguration( resource: ResourceDebugResourceSnapshot, dotNetService: IDotNetService, + childProcessResolver: LaunchedChildProcessResolver, cancellationToken?: vscode.CancellationToken, ): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); @@ -690,26 +792,30 @@ export async function createDotNetAttachDebugSessionConfiguration( error instanceof Error ? error.message : String(error)); } - // Without an apphost, dotnet run starts the target DLL under another process named "dotnet". - // That name is not unique enough to identify this resource without introducing process-tree discovery. - if (!targetInfo.useAppHost) { + let applicationPid: number; + try { + applicationPid = await childProcessResolver.resolveProcessId( + attachInfo.launcherPid, + createDotNetProcessIdentity(targetInfo), + cancellationToken); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); } - // `executable.pid` is the DCP launcher (`dotnet run`), not necessarily the managed - // application process. Apphost-backed projects have a unique process name derived from - // TargetPath, which the C# debugger can select without a second process-discovery subsystem. - const fileName = targetInfo.targetPath.split(/[\\/]/).pop() ?? ''; - const processName = fileName.replace(/\.(dll|exe)$/i, ''); - if (processName.length === 0) { - throw new ResourceAttachConfigurationError('resourceNotAttachable', noOutputFromMsbuild); + if (!Number.isInteger(applicationPid) || applicationPid <= 0) { + throw new ResourceAttachConfigurationError('resourceNotAttachable', attachDebuggerUnavailable); } return { type: 'coreclr', request: 'attach', name: attachDebuggerConfigurationName(attachInfo.resourceLabel), - processName, + processId: applicationPid, }; } @@ -954,7 +1060,10 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess export const projectDebuggerExtension: ResourceDebuggerExtension = createProjectDebuggerExtension(debugSession => new DotNetService(debugSession)); -export function createProjectResourceAttachProvider(dotNetServiceProducer: () => IDotNetService): ResourceAttachProvider { +export function createProjectResourceAttachProvider( + dotNetServiceProducer: () => IDotNetService, + childProcessResolver: LaunchedChildProcessResolver = launchedChildProcessResolver, +): ResourceAttachProvider { return { id: 'dotnet', requiredDebuggerExtensions: [{ @@ -964,7 +1073,7 @@ export function createProjectResourceAttachProvider(dotNetServiceProducer: () => canRecognizeResource: resource => canRecognizeDotNetAttachDebuggerResource(resource), canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, createDebugConfiguration: async (resource, cancellationToken) => - await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), cancellationToken), + await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), childProcessResolver, cancellationToken), }; } diff --git a/extension/src/debugger/languages/go.ts b/extension/src/debugger/languages/go.ts index 5d9f245bf25..29c08808028 100644 --- a/extension/src/debugger/languages/go.ts +++ b/extension/src/debugger/languages/go.ts @@ -4,17 +4,28 @@ import { attachDebuggerConfigurationName, attachDebuggerUnavailable, goDisplayNa import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; -import { goRunApplicationProcessResolver, type GoApplicationProcessResolver } from './goProcessDiscovery'; +import { + getProcessCommandProgram, + launchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessIdentity, +} from '../launchedChildProcessDiscovery'; const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; +const goBuildExecutablePattern = /(?:^|[\\/])go-build[^\\/\s]*(?:[\\/][^\\/\s]+)*[\\/]exe[\\/][^\\/\s]+(?:\.exe)?$/i; +const cachedGoRunExecutablePattern = /(?:^|[\\/])[0-9a-f]{2}[\\/][0-9a-f]{16,}-d[\\/][^\\/\s]+(?:\.exe)?$/i; interface GoAttachDebuggerResourceInfo { readonly parentPid: number; readonly resourceLabel: string; } +interface GoApplicationProcessResolver { + resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise; +} + function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string { if (isGoLaunchConfiguration(launchConfig)) { return launchConfig.program || launchConfig.working_directory || ''; @@ -81,7 +92,20 @@ export function createGoResourceAttachProvider(processResolver: GoApplicationPro } export const goResourceAttachProvider: ResourceAttachProvider = - createGoResourceAttachProvider(goRunApplicationProcessResolver); + createGoResourceAttachProvider({ + resolveApplicationPid: async (goProcessId, cancellationToken) => + await launchedChildProcessResolver.resolveProcessId( + goProcessId, + createGoRunProcessIdentity(), + cancellationToken), + }); + +export function createGoRunProcessIdentity(): LaunchedChildProcessIdentity { + return { + isLauncher: process => isGoToolProcess(process), + isCandidate: process => isGoBuildApplication(process), + }; +} function canRecognizeGoAttachDebuggerResource(resource: ResourceDebugResourceSnapshot): boolean { return getLaunchConfigurationType(resource) === 'go' && isGoExecutable(resource); @@ -167,3 +191,21 @@ function getProcessId(resource: ResourceDebugResourceSnapshot): number | undefin const processId = Number(value); return Number.isInteger(processId) && processId > 0 ? processId : undefined; } + +function isGoBuildApplication(process: LaunchedChildProcess): boolean { + return isGoRunApplicationPath(process.executable) || + isGoRunApplicationPath(getProcessCommandProgram(process.command)); +} + +function isGoToolProcess(process: LaunchedChildProcess): boolean { + const executableName = getProcessCommandProgram(process.command)?.split(/[\\/]/).pop()?.toLowerCase() + ?? process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || + executableName === 'go.exe' || + /(?:^|[\\/\s])go(?:\.exe)?\s+run(?:\s|$)/i.test(process.command); +} + +function isGoRunApplicationPath(path: string | undefined): boolean { + return path !== undefined && + (goBuildExecutablePattern.test(path) || cachedGoRunExecutablePattern.test(path)); +} diff --git a/extension/src/debugger/languages/goProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts similarity index 76% rename from extension/src/debugger/languages/goProcessDiscovery.ts rename to extension/src/debugger/launchedChildProcessDiscovery.ts index a147408719f..58a31051201 100644 --- a/extension/src/debugger/languages/goProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -1,40 +1,40 @@ import * as childProcess from 'child_process'; import * as vscode from 'vscode'; -export interface GoProcessInfo { +export interface LaunchedChildProcess { readonly pid: number; readonly parentPid: number; readonly executable: string; readonly command: string; } -export interface GoProcessQuery { - listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; +export interface LaunchedChildProcessQuery { + listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } -export interface GoProcessDiscoveryClock { +export interface LaunchedChildProcessClock { now(): number; sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise; } -export interface GoApplicationProcessResolver { - resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise; +export interface LaunchedChildProcessIdentity { + isLauncher(process: LaunchedChildProcess): boolean; + isCandidate(process: LaunchedChildProcess): boolean; } -export interface GoProcessCommandRunner { +export interface LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } -const goBuildExecutablePattern = /(?:^|[\\/])go-build[^\\/\s]*(?:[\\/][^\\/\s]+)*[\\/]exe[\\/][^\\/\s]+(?:\.exe)?(?:\s|$)/i; const maxProcessListingLength = 1024 * 1024; const windowsProcessQuery = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress'; -export function parsePosixProcessList(output: string): readonly GoProcessInfo[] { - const processes: GoProcessInfo[] = []; +export function parsePosixProcessList(output: string): readonly LaunchedChildProcess[] { + const processes: LaunchedChildProcess[] = []; for (const line of output.split(/\r?\n/)) { // `ps -axo pid=,ppid=,comm=,args=` produces rows such as: - // 42 10 /private/.../go-build123/b001/exe/api /private/.../go-build123/b001/exe/api --port 8080 + // 42 10 /private/.../app /private/.../app --port 8080 // The command can contain spaces, so only split the first three fixed fields. const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?\s*$/.exec(line); if (!match) { @@ -50,7 +50,7 @@ export function parsePosixProcessList(output: string): readonly GoProcessInfo[] return processes; } -export function parseWindowsProcessList(output: string): readonly GoProcessInfo[] { +export function parseWindowsProcessList(output: string): readonly LaunchedChildProcess[] { let parsed: unknown; try { parsed = JSON.parse(output); @@ -60,7 +60,7 @@ export function parseWindowsProcessList(output: string): readonly GoProcessInfo[ } const rows = Array.isArray(parsed) ? parsed : [parsed]; - const processes: GoProcessInfo[] = []; + const processes: LaunchedChildProcess[] = []; for (const row of rows) { if (typeof row !== 'object' || row === null) { continue; @@ -82,24 +82,33 @@ export function parseWindowsProcessList(output: string): readonly GoProcessInfo[ return processes; } -export class GoRunApplicationProcessResolver implements GoApplicationProcessResolver { +export function getProcessCommandProgram(command: string): string | undefined { + const match = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command); + return match?.[1] ?? match?.[2] ?? match?.[3]; +} + +export class LaunchedChildProcessResolver { private static readonly _defaultTimeoutMs = 5_000; private static readonly _defaultRetryDelayMs = 100; constructor( - private readonly _processQuery: GoProcessQuery, - private readonly _clock: GoProcessDiscoveryClock = systemGoProcessDiscoveryClock, + private readonly _processQuery: LaunchedChildProcessQuery, + private readonly _clock: LaunchedChildProcessClock = systemLaunchedChildProcessClock, options: { readonly timeoutMs?: number; readonly retryDelayMs?: number } = {}, ) { - this._timeoutMs = options.timeoutMs ?? GoRunApplicationProcessResolver._defaultTimeoutMs; - this._retryDelayMs = options.retryDelayMs ?? GoRunApplicationProcessResolver._defaultRetryDelayMs; + this._timeoutMs = options.timeoutMs ?? LaunchedChildProcessResolver._defaultTimeoutMs; + this._retryDelayMs = options.retryDelayMs ?? LaunchedChildProcessResolver._defaultRetryDelayMs; } private readonly _timeoutMs: number; private readonly _retryDelayMs: number; - async resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise { - if (!isValidPid(goProcessId)) { + async resolveProcessId( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise { + if (!isValidPid(launcherPid)) { throw createProcessDiscoveryError(); } @@ -112,7 +121,7 @@ export class GoRunApplicationProcessResolver implements GoApplicationProcessReso for (let attempt = 0; attempt < maximumAttempts; attempt++) { throwIfCancelled(cancellationToken); - let processes: readonly GoProcessInfo[]; + let processes: readonly LaunchedChildProcess[]; try { processes = await this._processQuery.listProcesses( cancellationToken, @@ -128,7 +137,14 @@ export class GoRunApplicationProcessResolver implements GoApplicationProcessReso throwIfCancelled(cancellationToken); - const candidate = findGoBuildApplicationDescendant(goProcessId, processes); + let candidate: number | undefined; + try { + candidate = findMatchingDescendant(launcherPid, identity, processes); + } + catch { + throw createProcessDiscoveryError(); + } + if (candidate !== undefined && candidate === previousCandidate) { return candidate; } @@ -155,14 +171,14 @@ export class GoRunApplicationProcessResolver implements GoApplicationProcessReso } } -export class SystemGoProcessQuery implements GoProcessQuery { +export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuery { constructor( private readonly _platform: NodeJS.Platform = process.platform, - private readonly _commandRunner: GoProcessCommandRunner = new SystemGoProcessCommandRunner(), + private readonly _commandRunner: LaunchedChildProcessCommandRunner = new SystemLaunchedChildProcessCommandRunner(), ) { } - async listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { + async listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { const output = this._platform === 'win32' ? await this._commandRunner.run( 'powershell.exe', @@ -181,7 +197,7 @@ export class SystemGoProcessQuery implements GoProcessQuery { } } -class SystemGoProcessCommandRunner implements GoProcessCommandRunner { +class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs = 1_000): Promise { return new Promise((resolve, reject) => { let completed = false; @@ -205,15 +221,12 @@ class SystemGoProcessCommandRunner implements GoProcessCommandRunner { cancellationRegistration?.dispose(); action(); }; - const stop = () => { + const fail = () => { // Discovery owns this short-lived `ps` or PowerShell child only. Never signal the - // resource's `go run` process or any descendant while resolving an attach target. + // launched workload or any descendant while resolving an attach target. if (!process.killed) { process.kill(); } - }; - const fail = () => { - stop(); complete(() => reject(createProcessDiscoveryError())); }; @@ -248,7 +261,7 @@ class SystemGoProcessCommandRunner implements GoProcessCommandRunner { } } -const systemGoProcessDiscoveryClock: GoProcessDiscoveryClock = { +const systemLaunchedChildProcessClock: LaunchedChildProcessClock = { now: () => Date.now(), sleep: (milliseconds, cancellationToken) => new Promise((resolve, reject) => { if (cancellationToken?.isCancellationRequested) { @@ -269,10 +282,10 @@ const systemGoProcessDiscoveryClock: GoProcessDiscoveryClock = { }), }; -export const goRunApplicationProcessResolver: GoApplicationProcessResolver = - new GoRunApplicationProcessResolver(new SystemGoProcessQuery()); +export const launchedChildProcessResolver = new LaunchedChildProcessResolver( + new SystemLaunchedChildProcessQuery()); -function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executableValue: unknown, commandValue: unknown): GoProcessInfo | undefined { +function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executableValue: unknown, commandValue: unknown): LaunchedChildProcess | undefined { const pid = parsePid(pidValue); const parentPid = parseParentPid(parentPidValue); const executable = typeof executableValue === 'string' ? executableValue.trim() : ''; @@ -289,9 +302,13 @@ function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executabl }; } -function findGoBuildApplicationDescendant(goProcessId: number, processes: readonly GoProcessInfo[]): number | undefined { - const processById = new Map(); - const childrenByParentId = new Map(); +function findMatchingDescendant( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + processes: readonly LaunchedChildProcess[], +): number | undefined { + const processById = new Map(); + const childrenByParentId = new Map(); for (const process of processes) { if (!isValidPid(process.pid) || !Number.isInteger(process.parentPid) || process.parentPid < 0 || processById.has(process.pid)) { return undefined; @@ -303,16 +320,22 @@ function findGoBuildApplicationDescendant(goProcessId: number, processes: readon childrenByParentId.set(process.parentPid, children); } - const goProcess = processById.get(goProcessId); - if (!goProcess || !isGoToolProcess(goProcess)) { + const launcher = processById.get(launcherPid); + if (!launcher || !identity.isLauncher(launcher)) { return undefined; } const candidates: number[] = []; - const descendants = [...(childrenByParentId.get(goProcessId) ?? [])]; + const descendants = [...(childrenByParentId.get(launcherPid) ?? [])]; + const visitedProcessIds = new Set([launcherPid]); for (let index = 0; index < descendants.length; index++) { const descendant = descendants[index]; - if (isGoBuildApplication(descendant)) { + if (visitedProcessIds.has(descendant.pid)) { + return undefined; + } + + visitedProcessIds.add(descendant.pid); + if (identity.isCandidate(descendant)) { candidates.push(descendant.pid); } @@ -322,15 +345,6 @@ function findGoBuildApplicationDescendant(goProcessId: number, processes: readon return candidates.length === 1 ? candidates[0] : undefined; } -function isGoBuildApplication(process: GoProcessInfo): boolean { - return goBuildExecutablePattern.test(process.executable) || goBuildExecutablePattern.test(process.command); -} - -function isGoToolProcess(process: GoProcessInfo): boolean { - const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); - return executableName === 'go' || executableName === 'go.exe'; -} - function parsePid(value: unknown): number | undefined { if (typeof value === 'number' && isValidPid(value)) { return value; @@ -368,5 +382,5 @@ function throwIfCancelled(cancellationToken?: vscode.CancellationToken): void { } function createProcessDiscoveryError(): Error { - return new Error('Unable to resolve the running Go application process.'); + return new Error('Unable to resolve the running application process.'); } diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index df7c126d4f2..7f9f78a88b5 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -12,6 +12,12 @@ import type { ResourceAttachProvider } from '../debugger/resourceDebugContracts' import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; import * as cliProcess from '../utils/process/cliProcess'; +import { + LaunchedChildProcessResolver, + type LaunchedChildProcess, + type LaunchedChildProcessClock, + type LaunchedChildProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; class TestDotNetService { private _hasDevKit: boolean; @@ -97,6 +103,44 @@ function createMsbuildProcess(): { return { process, stdout, stderr, kill }; } +interface TestLaunchedChildProcess { + readonly pid: number; + readonly parentPid: number; + readonly executable: string; + readonly command: string; +} + +interface TestLaunchedChildProcessIdentity { + isLauncher(process: TestLaunchedChildProcess): boolean; + isCandidate(process: TestLaunchedChildProcess): boolean; +} + +interface TestLaunchedChildProcessResolver { + resolveProcessId( + launcherPid: number, + identity: TestLaunchedChildProcessIdentity, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + +class StaticLaunchedChildProcessQuery implements LaunchedChildProcessQuery { + constructor(private readonly _processes: readonly LaunchedChildProcess[]) { + } + + async listProcesses(): Promise { + return this._processes; + } +} + +const immediateProcessClock: LaunchedChildProcessClock = { + now: () => 0, + sleep: async () => { }, +}; + +function createLaunchedProcess(pid: number, parentPid: number, executable: string, command = executable): LaunchedChildProcess { + return { pid, parentPid, executable, command }; +} + suite('Dotnet Debugger Extension Tests', () => { let getHotReloadDiagnostics: sinon.SinonStub; let logHotReloadDiagnostics: sinon.SinonStub; @@ -118,15 +162,256 @@ suite('Dotnet Debugger Extension Tests', () => { function createDebuggerExtension(outputPath: string, rejectBuild: Error | null, hasDevKit: boolean, doesOutputFileExist: boolean): { dotNetService: TestDotNetService, extension: ResourceDebuggerExtension, attachProvider: ResourceAttachProvider, doesFileExistStub: sinon.SinonStub } { const fakeDotNetService = new TestDotNetService(outputPath, rejectBuild, hasDevKit); + const childProcessResolver: TestLaunchedChildProcessResolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; return { dotNetService: fakeDotNetService, extension: createProjectDebuggerExtension(() => fakeDotNetService), - attachProvider: createProjectResourceAttachProvider(() => fakeDotNetService), + attachProvider: createProjectResourceAttachProvider(() => fakeDotNetService, childProcessResolver), doesFileExistStub: sinon.stub(io, 'doesFileExist').resolves(doesOutputFileExist), }; } - test('attach configuration uses the project TargetPath process name instead of the launcher process ID', async () => { + test('attach configuration resolves the evaluated framework-dependent TargetPath child PID', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider(() => dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.deepStrictEqual(configuration, { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 4321, + }); + assert.strictEqual(resolver.resolveProcessId.firstCall.args[0], 1234); + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isLauncher({ + pid: 1234, + parentPid: 1, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet run --project /repo/api/Api.csproj', + }), true); + assert.strictEqual(processIdentity.isLauncher({ + pid: 1234, + parentPid: 1, + executable: '/usr/local/share', + command: '/usr/local/share/dotnet/dotnet run --project /repo/api/Api.csproj', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Debug/net10.0/EvaluatedAssemblyName.dll', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Debug/net10.0/Api.dll', + }), false); + }); + + test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/EvaluatedAppHost', null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider(() => dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/EvaluatedAppHost', + command: '/repo/bin/Debug/net10.0/EvaluatedAppHost --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/EvaluatedAppHostWorker', + command: '/repo/bin/Debug/net10.0/EvaluatedAppHostWorker', + }), false); + }); + + test('attach configuration derives the default apphost identity from TargetPath', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider(() => dotNetService, resolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/Api', + command: '/repo/bin/Debug/net10.0/Api --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/bin/Debug/net10.0/Api.dll', + command: '/repo/bin/Debug/net10.0/Api.dll', + }), false); + }); + + test('attach configuration scopes replicas with the same TargetPath to their launcher PIDs', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Replica.dll', null, true, true); + const resolver = { + resolveProcessId: sinon.stub() + .onFirstCall().resolves(4321) + .onSecondCall().resolves(4322), + }; + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider(() => dotNetService, resolver); + const createResource = (pid: number): Parameters[0] => ({ + name: `api-${pid}`, + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const firstConfiguration = await attachProvider.createDebugConfiguration(createResource(1234)); + const secondConfiguration = await attachProvider.createDebugConfiguration(createResource(5678)); + + assert.strictEqual(firstConfiguration.processId, 4321); + assert.strictEqual(secondConfiguration.processId, 4322); + assert.deepStrictEqual(resolver.resolveProcessId.firstCall.args.slice(0, 1), [1234]); + assert.deepStrictEqual(resolver.resolveProcessId.secondCall.args.slice(0, 1), [5678]); + }); + + test('attach configuration fails closed for missing and ambiguous framework-dependent children', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const createProvider = (processes: readonly LaunchedChildProcess[]) => { + const dotNetService = new TestDotNetService(targetPath, null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery(processes), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + return createProjectResourceAttachProvider(() => dotNetService, resolver); + }; + const resource = { + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }; + const noChild = createProvider([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Debug/net10.0/Other.dll'), + ]); + const ambiguousChildren = createProvider([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + createLaunchedProcess(4322, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + ]); + + await assert.rejects(noChild.createDebugConfiguration(resource)); + await assert.rejects(ambiguousChildren.createDebugConfiguration(resource)); + }); + + test('attach configuration resolves same-name framework-dependent replicas within each launcher tree', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + createLaunchedProcess(5678, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(8765, 5678, '/usr/local/share/dotnet/dotnet', `dotnet exec ${targetPath}`), + ]), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + const attachProvider = createProjectResourceAttachProvider(() => dotNetService, resolver); + const createResource = (pid: number): Parameters[0] => ({ + name: `api-${pid}`, + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.strictEqual((await attachProvider.createDebugConfiguration(createResource(1234))).processId, 4321); + assert.strictEqual((await attachProvider.createDebugConfiguration(createResource(5678))).processId, 8765); + }); + + test('attach configuration uses the resolved project TargetPath child process ID', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); const configuration = await attachProvider.createDebugConfiguration({ @@ -144,8 +429,8 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(configuration.type, 'coreclr'); assert.strictEqual(configuration.request, 'attach'); assert.strictEqual(configuration.name, 'Attach debugger: API'); - assert.strictEqual(configuration.processId, undefined); - assert.strictEqual(configuration.processName, 'FromTargetPath'); + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); @@ -166,7 +451,8 @@ suite('Dotnet Debugger Extension Tests', () => { }, }); - assert.strictEqual(configuration.processName, 'ReleaseApi'); + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', 'Release')); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); @@ -303,7 +589,7 @@ suite('Dotnet Debugger Extension Tests', () => { } }); - test('attach configuration rejects projects launched without an apphost', async () => { + test('attach configuration supports framework-dependent projects', async () => { const dotNetService = new DotNetService(undefined); const msbuildProcess = createMsbuildProcess(); const spawn = sinon.stub(childProcess, 'spawn').callsFake(() => { @@ -318,25 +604,24 @@ suite('Dotnet Debugger Extension Tests', () => { }); return msbuildProcess.process; }); - const attachProvider = createProjectResourceAttachProvider(() => dotNetService); + const attachProvider = createProjectResourceAttachProvider(() => dotNetService, { + resolveProcessId: async () => 4321, + }); - await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], - 'project.path': '/repo/api/Api.csproj', - }, - }), - (error: unknown) => error instanceof Error - && error.name === 'ResourceAttachConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); + const configuration = await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], + 'project.path': '/repo/api/Api.csproj', + }, + }); + assert.strictEqual(configuration.processId, 4321); assert.deepStrictEqual(spawn.firstCall.args[1], [ 'msbuild', '/repo/api/Api.csproj', @@ -388,7 +673,8 @@ suite('Dotnet Debugger Extension Tests', () => { }, }); - assert.strictEqual(configuration.processName, 'FromTargetPath'); + assert.strictEqual(configuration.processId, 4321); + assert.strictEqual(configuration.processName, undefined); assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', undefined)); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts index dd18e53abea..35ea9a9415a 100644 --- a/extension/src/test/goProcessDiscovery.test.ts +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -1,16 +1,17 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { createGoRunProcessIdentity } from '../debugger/languages/go'; import { - GoRunApplicationProcessResolver, + LaunchedChildProcessResolver, parsePosixProcessList, parseWindowsProcessList, - SystemGoProcessQuery, - type GoProcessCommandRunner, - type GoProcessDiscoveryClock, - type GoProcessInfo, - type GoProcessQuery, -} from '../debugger/languages/goProcessDiscovery'; + SystemLaunchedChildProcessQuery, + type LaunchedChildProcess as GoProcessInfo, + type LaunchedChildProcessClock as GoProcessDiscoveryClock, + type LaunchedChildProcessCommandRunner as GoProcessCommandRunner, + type LaunchedChildProcessQuery as GoProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; class TestClock implements GoProcessDiscoveryClock { private _now = 0; @@ -58,6 +59,19 @@ function goRunProcessTree(applicationPid = 42): readonly GoProcessInfo[] { ]; } +function createGoRunApplicationProcessResolver( + processQuery: GoProcessQuery, + clock?: GoProcessDiscoveryClock, + options?: { readonly timeoutMs?: number; readonly retryDelayMs?: number }, +): { resolveApplicationPid(goProcessId: number, cancellationToken?: vscode.CancellationToken): Promise } { + const resolver = new LaunchedChildProcessResolver(processQuery, clock, options); + const identity = createGoRunProcessIdentity(); + return { + resolveApplicationPid: async (goProcessId, cancellationToken) => + await resolver.resolveProcessId(goProcessId, identity, cancellationToken), + }; +} + suite('Go process discovery', () => { teardown(() => sinon.restore()); @@ -111,8 +125,8 @@ suite('Go process discovery', () => { }, }; - await new SystemGoProcessQuery('linux', commandRunner).listProcesses(); - await new SystemGoProcessQuery('win32', commandRunner).listProcesses(); + await new SystemLaunchedChildProcessQuery('linux', commandRunner).listProcesses(); + await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); assert.deepStrictEqual(calls, [ { @@ -133,7 +147,7 @@ suite('Go process discovery', () => { }); test('traverses nested children and ignores Go toolchain processes', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([goRunProcessTree(), goRunProcessTree()]), new TestClock(), { timeoutMs: 100, retryDelayMs: 10 }); @@ -141,8 +155,42 @@ suite('Go process discovery', () => { assert.strictEqual(await resolver.resolveApplicationPid(10), 42); }); + test('resolves a cached Go run application and ignores linker output paths', async () => { + const cachedApplication = process( + 42, + 10, + '/Users/me/Library/Caches/go-build/8a/8a26e5d38d9d4f6e7c8b0a1d2e3f4c5b6a7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f-d/api', + '/Users/me/Library/Caches/go-build/8a/8a26e5d38d9d4f6e7c8b0a1d2e3f4c5b6a7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f-d/api --port 8080'); + const linker = process( + 33, + 10, + '/usr/local/go/pkg/tool/darwin_arm64/link', + '/usr/local/go/pkg/tool/darwin_arm64/link -o /private/var/folders/x/go-build123/b001/exe/api'); + const processTree = [ + process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), + linker, + cachedApplication, + ]; + const resolver = createGoRunApplicationProcessResolver( + new SequenceProcessQuery([processTree, processTree]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveApplicationPid(10), 42); + }); + + test('recognizes a Go launcher from its command when macOS truncates comm', () => { + const identity = createGoRunProcessIdentity(); + + assert.strictEqual(identity.isLauncher(process( + 10, + 1, + '/Users/me/Very', + '/Users/me/Very Long Go Installation/bin/go run ./cmd/api')), true); + }); + test('waits for the same Go build application candidate twice', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([ goRunProcessTree(42), goRunProcessTree(43), @@ -155,7 +203,7 @@ suite('Go process discovery', () => { }); test('fails closed when no Go build application process exists', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([ [process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api')], ]), @@ -166,7 +214,7 @@ suite('Go process discovery', () => { }); test('fails closed when Go build application candidates are ambiguous', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([ [ ...goRunProcessTree(42), @@ -180,7 +228,7 @@ suite('Go process discovery', () => { }); test('fails closed when the reported Go parent process was reused', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([ [ process(10, 1, '/bin/bash', 'bash build.sh'), @@ -194,7 +242,7 @@ suite('Go process discovery', () => { }); test('fails within its bounded timeout without a stable candidate', async () => { - const resolver = new GoRunApplicationProcessResolver( + const resolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([ goRunProcessTree(42), goRunProcessTree(43), @@ -208,11 +256,11 @@ suite('Go process discovery', () => { test('propagates cancellation and process-query failures without process details', async () => { const cancellation = new vscode.CancellationTokenSource(); - const failedResolver = new GoRunApplicationProcessResolver( + const failedResolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([new Error('/private/go-build123/b001/exe/api 4242')]), new TestClock(), { timeoutMs: 20, retryDelayMs: 10 }); - const cancelledResolver = new GoRunApplicationProcessResolver( + const cancelledResolver = createGoRunApplicationProcessResolver( new SequenceProcessQuery([goRunProcessTree()]), new TestClock(), { timeoutMs: 20, retryDelayMs: 10 }); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts new file mode 100644 index 00000000000..1434e199e60 --- /dev/null +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -0,0 +1,211 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { + LaunchedChildProcessResolver, + parsePosixProcessList, + parseWindowsProcessList, + SystemLaunchedChildProcessQuery, + type LaunchedChildProcess, + type LaunchedChildProcessClock, + type LaunchedChildProcessCommandRunner, + type LaunchedChildProcessIdentity, + type LaunchedChildProcessQuery, +} from '../debugger/launchedChildProcessDiscovery'; + +class TestClock implements LaunchedChildProcessClock { + private _now = 0; + + now(): number { + return this._now; + } + + async sleep(milliseconds: number, cancellationToken?: vscode.CancellationToken): Promise { + if (cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + this._now += milliseconds; + } +} + +class SequenceProcessQuery implements LaunchedChildProcessQuery { + private _index = 0; + + constructor(private readonly _snapshots: readonly (readonly LaunchedChildProcess[] | Error)[]) { + } + + async listProcesses(): Promise { + const snapshot = this._snapshots[Math.min(this._index, this._snapshots.length - 1)]; + this._index++; + if (snapshot instanceof Error) { + throw snapshot; + } + + return snapshot; + } +} + +function process(pid: number, parentPid: number, executable: string, command = executable): LaunchedChildProcess { + return { pid, parentPid, executable, command }; +} + +const identity: LaunchedChildProcessIdentity = { + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable.includes('/target/'), +}; + +suite('Launched child process discovery', () => { + teardown(() => sinon.restore()); + + test('parses POSIX process listings without retaining incomplete rows', () => { + assert.deepStrictEqual(parsePosixProcessList([ + ' 10 1 /tool/launcher launcher --run', + ' 42 10 /target/api /target/api --port 8080', + 'not a process row', + ].join('\n')), [ + process(10, 1, '/tool/launcher', 'launcher --run'), + process(42, 10, '/target/api', '/target/api --port 8080'), + ]); + }); + + test('parses Windows CIM process listings', () => { + assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: 'C:\\target\\api.exe', + })), [ + process(42, 10, 'C:\\target\\api.exe', 'C:\\target\\api.exe'), + ]); + }); + + test('uses fixed platform-specific process discovery commands', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + return command === 'ps' + ? '10 1 /tool/launcher launcher --run' + : JSON.stringify({ + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: 'C:\\tool\\launcher.exe', + CommandLine: 'launcher --run', + }); + }, + }; + + await new SystemLaunchedChildProcessQuery('linux', commandRunner).listProcesses(); + await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); + + assert.deepStrictEqual(calls, [ + { + command: 'ps', + args: ['-axo', 'pid=,ppid=,comm=,args='], + }, + { + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + ], + }, + ]); + }); + + test('resolves a stable nested child only beneath its launcher', async () => { + const resolver = new LaunchedChildProcessResolver( + new SequenceProcessQuery([ + [ + process(10, 1, '/tool/launcher'), + process(22, 10, '/tool/intermediate'), + process(42, 22, '/target/api'), + process(43, 1, '/target/unrelated'), + ], + [ + process(10, 1, '/tool/launcher'), + process(22, 10, '/tool/intermediate'), + process(42, 22, '/target/api'), + process(43, 1, '/target/unrelated'), + ], + ]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); + }); + + test('waits for the same matching child twice', async () => { + const resolver = new LaunchedChildProcessResolver( + new SequenceProcessQuery([ + [process(10, 1, '/tool/launcher'), process(42, 10, '/target/old')], + [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], + [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], + ]), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 43); + }); + + test('fails closed for a missing or ambiguous matching child', async () => { + const noCandidate = new LaunchedChildProcessResolver( + new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + const ambiguous = new LaunchedChildProcessResolver( + new SequenceProcessQuery([[ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + process(43, 10, '/target/worker'), + ]]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(noCandidate.resolveProcessId(10, identity)); + await assert.rejects(ambiguous.resolveProcessId(10, identity)); + }); + + test('fails closed for a cyclic process listing', async () => { + const cyclic = new LaunchedChildProcessResolver( + new SequenceProcessQuery([[ + process(10, 42, '/tool/launcher'), + process(42, 10, '/target/api'), + ]]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(cyclic.resolveProcessId(10, identity)); + }); + + test('normalizes query failures and supports cancellation', async () => { + const failedResolver = new LaunchedChildProcessResolver( + new SequenceProcessQuery([new Error('/private/target/api 4242')]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + const cancellation = new vscode.CancellationTokenSource(); + const cancelledResolver = new LaunchedChildProcessResolver( + new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + try { + await assert.rejects( + failedResolver.resolveProcessId(10, identity), + error => error instanceof Error && !/target|4242/.test(error.message)); + cancellation.cancel(); + await assert.rejects( + cancelledResolver.resolveProcessId(10, identity, cancellation.token), + vscode.CancellationError); + } + finally { + cancellation.dispose(); + } + }); +}); From 207ec7fd0e4b32ca5f3ce046565f89bc9af8f20d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 01:42:32 -0400 Subject: [PATCH 48/90] fix(extension): harden resource debugger child discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 107 ++++++++-------- extension/src/debugger/languages/go.ts | 9 +- .../debugger/launchedChildProcessDiscovery.ts | 115 ++++++++++++++++-- .../src/debugger/resourceAttachProviders.ts | 11 -- .../src/debugger/resourceDebugContracts.ts | 1 + .../src/debugger/resourceDebugService.ts | 7 +- extension/src/test/appHostTreeView.test.ts | 2 +- extension/src/test/dotnetDebugger.test.ts | 74 ++++++++++- extension/src/test/goProcessDiscovery.test.ts | 2 +- .../launchedChildProcessDiscovery.test.ts | 35 +++++- .../src/views/AspireAppHostTreeProvider.ts | 13 +- 11 files changed, 277 insertions(+), 99 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 8e9ff9254e5..77406098505 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { extensionLogOutputChannel } from '../../utils/logging'; -import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerUnavailable } from '../../loc/strings'; +import { noCsharpBuildTask, buildFailedWithExitCode, noOutputFromMsbuild, failedToGetTargetPath, invalidLaunchConfiguration, buildFailedForProjectWithError, processExitedWithCode, lookingForDevkitBuildTask, csharpDevKitNotInstalled, failedToInspectRuntimeConfig, dotNetRunFallbackDisablesDebugger, dotNetRunFileBasedExecutableProfileFallback, executableLaunchProfileMissingExecutablePath, attachDebuggerConfigurationName, attachDebuggerCsharpExtensionRequired, attachDebuggerUnavailable } from '../../loc/strings'; import { ChildProcessWithoutNullStreams } from 'child_process'; import * as childProcess from 'child_process'; import * as util from 'util'; @@ -39,7 +39,7 @@ import { interface IDotNetService { getAndActivateDevKit(): Promise buildDotNetProject(projectFile: string): Promise; - getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise; + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise; getDotNetTargetPath(projectFile: string): Promise; getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } @@ -51,6 +51,7 @@ interface DotNetAttachTargetInfo { interface DotNetAttachDebuggerResourceInfo { configuration?: string; + framework?: string; launcherPid: number; projectPath: string; resourceLabel: string; @@ -153,7 +154,7 @@ export class DotNetService implements IDotNetService { }); } - async getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise { + async getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise { const args = [ 'msbuild', projectFile, @@ -166,6 +167,9 @@ export class DotNetService implements IDotNetService { if (configuration) { args.push(`-property:Configuration=${configuration}`); } + if (framework) { + args.push(`-property:TargetFramework=${framework}`); + } try { const stdout = await this._runDotNetMsbuild(args, path.dirname(projectFile), cancellationToken); @@ -581,7 +585,7 @@ function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnap const projectPath = resource.properties?.[projectPathPropertyName] as string; return { - configuration: getDotNetLaunchConfiguration(resource), + ...getDotNetLaunchConfiguration(resource), launcherPid, projectPath, resourceLabel: resource.displayName ?? resource.name, @@ -618,16 +622,18 @@ function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourc return true; } -function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): string | undefined { +function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): Pick { const executableArgs: unknown = resource.properties?.[executableArgsPropertyName]; if (!Array.isArray(executableArgs)) { - return undefined; + return {}; } // Project launcher arguments have the shape: // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--no-launch-profile", "--", ...appArgs] // Stop at the application-argument separator so an app's own --configuration value is not mistaken // for the MSBuild configuration DCP used to launch the project. + let configuration: string | undefined; + let framework: string | undefined; for (let index = 0; index < executableArgs.length; index++) { const argument = executableArgs[index]; if (typeof argument !== 'string') { @@ -638,15 +644,31 @@ function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): break; } - if (argument === '--configuration') { - const configuration = executableArgs[index + 1]; - return typeof configuration === 'string' && configuration.trim().length > 0 - ? configuration.trim() - : undefined; + if (argument === '--configuration' || argument === '-c') { + const nextConfiguration = executableArgs[index + 1]; + if (typeof nextConfiguration === 'string' && nextConfiguration.trim().length > 0) { + configuration = nextConfiguration.trim(); + } + } + + if (argument === '--framework' || argument === '-f') { + const nextFramework = executableArgs[index + 1]; + if (typeof nextFramework === 'string' && nextFramework.trim().length > 0) { + framework = nextFramework.trim(); + } + } + + const [option, value] = argument.split('=', 2); + if ((option === '--configuration' || option === '-c') && value?.trim()) { + configuration = value.trim(); + } + + if ((option === '--framework' || option === '-f') && value?.trim()) { + framework = value.trim(); } } - return undefined; + return { configuration, framework }; } function getResourceParentName(resource: ResourceDebugResourceSnapshot): string | null { @@ -697,21 +719,21 @@ function createDotNetProcessIdentity(targetInfo: DotNetAttachTargetInfo): Launch } function isDotNetProcess(process: LaunchedChildProcess): boolean { - const executableName = getProcessCommandProgram(process.command)?.split(/[\\/]/).pop()?.toLowerCase() - ?? process.executable.split(/[\\/]/).pop()?.toLowerCase(); - return executableName === 'dotnet' || executableName === 'dotnet.exe'; + return [getProcessCommandProgram(process.command), process.executable].some(value => { + const executableName = value?.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; + }); } function isAppHostProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { - const [program] = parseProcessCommandArguments(process.command); return getAppHostPaths(targetPath).some(appHostPath => areProcessPathsEqual(process.executable, appHostPath) || - (program !== undefined && areProcessPathsEqual(program, appHostPath))); + commandStartsWithPath(process.command, appHostPath)); } function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { return isDotNetProcess(process) && - parseProcessCommandArguments(process.command).some(argument => areProcessPathsEqual(argument, targetPath)); + commandContainsPathArgument(process.command, targetPath); } function areProcessPathsEqual(left: string, right: string): boolean { @@ -732,43 +754,19 @@ function getAppHostPaths(targetPath: string): readonly string[] { return [appHostPath, `${appHostPath}.exe`]; } -function parseProcessCommandArguments(command: string): readonly string[] { - const arguments_: string[] = []; - let currentArgument = ''; - let quote: '"' | "'" | undefined; - - // `ps` and CIM report command lines such as: - // dotnet exec "/repo/bin/Debug/net10.0/Api.dll" --urls http://localhost:5000 - // Keep only argument boundaries and quotes needed to identify the launched target; callers never - // receive this command text, which may contain application arguments. - for (const character of command) { - if (quote !== undefined) { - if (character === quote) { - quote = undefined; - } - else { - currentArgument += character; - } - } - else if (character === '"' || character === "'") { - quote = character; - } - else if (/\s/.test(character)) { - if (currentArgument.length > 0) { - arguments_.push(currentArgument); - currentArgument = ''; - } - } - else { - currentArgument += character; - } - } +function commandStartsWithPath(command: string, targetPath: string): boolean { + return new RegExp(`^\\s*(?:"|')?${escapeRegularExpression(targetPath)}(?:"|')?(?=\\s|$)`).test(command); +} - if (currentArgument.length > 0) { - arguments_.push(currentArgument); - } +function commandContainsPathArgument(command: string, targetPath: string): boolean { + // Do not tokenize command text: POSIX `ps` flattens argv, and valid paths can contain + // whitespace (for example, "/repo/OneDrive - Microsoft/Api.dll"). Match the exact evaluated + // TargetPath in the original command with argument boundaries instead. + return new RegExp(`(?:^|\\s|["'])${escapeRegularExpression(targetPath)}(?=$|\\s|["'])`).test(command); +} - return arguments_; +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export async function createDotNetAttachDebugSessionConfiguration( @@ -784,7 +782,7 @@ export async function createDotNetAttachDebugSessionConfiguration( let targetInfo: DotNetAttachTargetInfo; try { - targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration, cancellationToken); + targetInfo = await dotNetService.getDotNetAttachTargetInfo(attachInfo.projectPath, attachInfo.configuration, cancellationToken, attachInfo.framework); } catch (error) { throw new ResourceAttachConfigurationError( @@ -1069,6 +1067,7 @@ export function createProjectResourceAttachProvider( requiredDebuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#', + installMessage: attachDebuggerCsharpExtensionRequired, }], canRecognizeResource: resource => canRecognizeDotNetAttachDebuggerResource(resource), canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, diff --git a/extension/src/debugger/languages/go.ts b/extension/src/debugger/languages/go.ts index 29c08808028..01913f93276 100644 --- a/extension/src/debugger/languages/go.ts +++ b/extension/src/debugger/languages/go.ts @@ -198,10 +198,11 @@ function isGoBuildApplication(process: LaunchedChildProcess): boolean { } function isGoToolProcess(process: LaunchedChildProcess): boolean { - const executableName = getProcessCommandProgram(process.command)?.split(/[\\/]/).pop()?.toLowerCase() - ?? process.executable.split(/[\\/]/).pop()?.toLowerCase(); - return executableName === 'go' || - executableName === 'go.exe' || + const programs = [getProcessCommandProgram(process.command), process.executable]; + return programs.some(program => { + const executableName = program?.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; + }) || /(?:^|[\\/\s])go(?:\.exe)?\s+run(?:\s|$)/i.test(process.command); } diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 58a31051201..7d4d1b169e5 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -10,6 +10,7 @@ export interface LaunchedChildProcess { export interface LaunchedChildProcessQuery { listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; + getProcess?(processId: number, cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } export interface LaunchedChildProcessClock { @@ -26,8 +27,9 @@ export interface LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } -const maxProcessListingLength = 1024 * 1024; -const windowsProcessQuery = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress'; +const maxProcessListingLength = 16 * 1024 * 1024; +const windowsProcessProperties = 'ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine'; +const windowsProcessQuery = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`; export function parsePosixProcessList(output: string): readonly LaunchedChildProcess[] { const processes: LaunchedChildProcess[] = []; @@ -53,7 +55,9 @@ export function parsePosixProcessList(output: string): readonly LaunchedChildPro export function parseWindowsProcessList(output: string): readonly LaunchedChildProcess[] { let parsed: unknown; try { - parsed = JSON.parse(output); + // Windows PowerShell can still prepend U+FEFF despite setting OutputEncoding. JSON.parse + // rejects that marker, so remove it before parsing the machine-readable response. + parsed = JSON.parse(output.replace(/^\uFEFF/, '')); } catch { throw createProcessDiscoveryError(); @@ -88,8 +92,9 @@ export function getProcessCommandProgram(command: string): string | undefined { } export class LaunchedChildProcessResolver { - private static readonly _defaultTimeoutMs = 5_000; + private static readonly _defaultTimeoutMs = 30_000; private static readonly _defaultRetryDelayMs = 100; + private static readonly _maximumRetryDelayMs = 1_000; constructor( private readonly _processQuery: LaunchedChildProcessQuery, @@ -113,12 +118,13 @@ export class LaunchedChildProcessResolver { } const timeoutMs = Math.max(1, this._timeoutMs); - const retryDelayMs = Math.max(1, this._retryDelayMs); const deadline = this._clock.now() + timeoutMs; - const maximumAttempts = Math.max(2, Math.ceil(timeoutMs / retryDelayMs) + 1); let previousCandidate: number | undefined; + let retryDelayMs = Math.max(1, this._retryDelayMs); + const maximumAttempts = Math.max(2, Math.ceil(timeoutMs / retryDelayMs) + 1); + let attempts = 0; - for (let attempt = 0; attempt < maximumAttempts; attempt++) { + while (this._clock.now() <= deadline && attempts++ < maximumAttempts) { throwIfCancelled(cancellationToken); let processes: readonly LaunchedChildProcess[]; @@ -132,7 +138,7 @@ export class LaunchedChildProcessResolver { throw new vscode.CancellationError(); } - throw createProcessDiscoveryError(); + processes = []; } throwIfCancelled(cancellationToken); @@ -145,18 +151,22 @@ export class LaunchedChildProcessResolver { throw createProcessDiscoveryError(); } - if (candidate !== undefined && candidate === previousCandidate) { + if (candidate !== undefined && candidate === previousCandidate && + await this._verifyCandidateLineage(candidate, launcherPid, identity, cancellationToken, deadline)) { return candidate; } previousCandidate = candidate; const remainingTimeMs = deadline - this._clock.now(); - if (remainingTimeMs <= 0 || attempt === maximumAttempts - 1) { + if (remainingTimeMs <= 0) { break; } try { await this._clock.sleep(Math.min(retryDelayMs, remainingTimeMs), cancellationToken); + retryDelayMs = Math.min( + LaunchedChildProcessResolver._maximumRetryDelayMs, + retryDelayMs * 2); } catch (error) { if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { @@ -169,6 +179,65 @@ export class LaunchedChildProcessResolver { throw createProcessDiscoveryError(); } + + private async _verifyCandidateLineage( + candidatePid: number, + launcherPid: number, + identity: LaunchedChildProcessIdentity, + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + ): Promise { + if (!this._processQuery.getProcess) { + return true; + } + + let processId = candidatePid; + const visited = new Set(); + + // `ps` renders command arguments verbatim, including newlines. A malicious command can + // therefore forge a plausible extra row in an all-process listing. Re-query every PID in + // the selected ancestry immediately before returning so topology and command identity come + // from the kernel's actual process record rather than a synthetic line. + while (true) { + if (visited.has(processId) || this._clock.now() > deadline) { + return false; + } + + visited.add(processId); + let process: LaunchedChildProcess | undefined; + try { + process = await this._processQuery.getProcess( + processId, + cancellationToken, + Math.max(1, deadline - this._clock.now())); + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + return false; + } + + if (!process || process.pid !== processId) { + return false; + } + + if (processId === candidatePid && !identity.isCandidate(process)) { + return false; + } + + if (processId === launcherPid) { + return identity.isLauncher(process); + } + + if (!isValidPid(process.parentPid)) { + return false; + } + + processId = process.parentPid; + } + } } export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuery { @@ -195,9 +264,33 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer ? parseWindowsProcessList(output) : parsePosixProcessList(output); } + + async getProcess(processId: number, cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { + if (!isValidPid(processId)) { + return undefined; + } + + const output = this._platform === 'win32' + ? await this._commandRunner.run( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process -Filter "ProcessId = ${processId}" | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`], + cancellationToken, + timeoutMs) + : await this._commandRunner.run( + 'ps', + ['-p', String(processId), '-o', 'pid=,ppid=,comm=,args='], + cancellationToken, + timeoutMs); + + const processes = this._platform === 'win32' + ? parseWindowsProcessList(output) + : parsePosixProcessList(output); + return processes.find(process => process.pid === processId); + } } -class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { +export class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs = 1_000): Promise { return new Promise((resolve, reject) => { let completed = false; diff --git a/extension/src/debugger/resourceAttachProviders.ts b/extension/src/debugger/resourceAttachProviders.ts index 8dc974398de..fe52ed40b71 100644 --- a/extension/src/debugger/resourceAttachProviders.ts +++ b/extension/src/debugger/resourceAttachProviders.ts @@ -23,17 +23,6 @@ export class ResourceAttachProviderRegistry { return this._knownProviders.find(provider => provider.canRecognizeResource(resource)); } - getAttachableProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { - return this._knownProviders.find(provider => provider.canAttachToResource(resource)); - } - - getInstalledProviderForResource(resource: ResourceDebugResourceSnapshot): ResourceAttachProvider | undefined { - const provider = this.getAttachableProviderForResource(resource); - return provider && this.getMissingDebuggerExtensions(provider).length === 0 - ? provider - : undefined; - } - getMissingDebuggerExtensions(provider: ResourceAttachProvider): readonly ResourceDebugExtensionRequirement[] { return provider.requiredDebuggerExtensions.filter(requirement => !(this._isDebuggerExtensionInstalled?.(requirement.id) ?? isExtensionInstalled(requirement.id))); diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index 50c22e88506..176eaaa0e04 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -36,6 +36,7 @@ export interface ResourceDebugResourceSnapshot { export interface ResourceDebugExtensionRequirement { readonly id: string; readonly label: string; + readonly installMessage?: string; } /** diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index c36cb7e9bbc..0cc085abf31 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -210,10 +210,9 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger if (missingDebuggerExtensions.length > 0) { return { outcome: 'debuggerExtensionMissing', - debuggerExtensions: missingDebuggerExtensions.map(requirement => ({ - id: requirement.id, - label: requirement.label, - })), + debuggerExtensions: missingDebuggerExtensions.map(requirement => requirement.installMessage + ? { id: requirement.id, label: requirement.label, installMessage: requirement.installMessage } + : { id: requirement.id, label: requirement.label }), }; } diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 128b0940cec..9f446546f8a 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -2902,7 +2902,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); - assert.deepStrictEqual(outcome, { success: false, errorKind: 'CSharpExtensionMissing' }); + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); assert.ok(warningStub.calledOnce); provider.dispose(); }); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 7f9f78a88b5..4fee435c016 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -48,10 +48,12 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } - getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken): Promise<{ targetPath: string, useAppHost: boolean }> { - return cancellationToken - ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken) - : this.getDotNetAttachTargetInfoStub(projectFile, configuration); + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise<{ targetPath: string, useAppHost: boolean }> { + return framework + ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken, framework) + : cancellationToken + ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken) + : this.getDotNetAttachTargetInfoStub(projectFile, configuration); } getDotNetTargetPath(projectFile: string): Promise { @@ -232,6 +234,50 @@ suite('Dotnet Debugger Extension Tests', () => { executable: '/usr/local/share/dotnet/dotnet', command: 'dotnet exec /repo/bin/Debug/net10.0/Api.dll', }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll "" --urls http://localhost:5000', + }), false); + }); + + test('matches a spaced evaluated TargetPath from the raw framework-dependent command', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath} "" --urls http://localhost:5000`, + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath}.bak`, + }), false); }); test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { @@ -457,6 +503,26 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); + test('attach configuration evaluates TargetPath with the launched target framework', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Release', '--framework', 'net10.0', '--', '--framework', 'not-a-tfm'], + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); + }); + test('attach configuration passes cancellation to target discovery', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); const cancellation = new vscode.CancellationTokenSource(); diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts index 35ea9a9415a..8ac753edd7e 100644 --- a/extension/src/test/goProcessDiscovery.test.ts +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -140,7 +140,7 @@ suite('Go process discovery', () => { '-NoProfile', '-NonInteractive', '-Command', - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + '$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', ], }, ]); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 1434e199e60..7ddecfb4706 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -81,6 +81,18 @@ suite('Launched child process discovery', () => { ]); }); + test('parses UTF-8 BOM-prefixed Windows CIM output with non-ASCII command text', () => { + assert.deepStrictEqual(parseWindowsProcessList(`\uFEFF${JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\über api.exe', + CommandLine: '"C:\\target\\über api.exe" --name "日本語"', + })}`), [ + process(42, 10, 'C:\\target\\über api.exe', '"C:\\target\\über api.exe" --name "日本語"'), + ]); + }); + test('uses fixed platform-specific process discovery commands', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; const commandRunner: LaunchedChildProcessCommandRunner = { @@ -113,7 +125,7 @@ suite('Launched child process discovery', () => { '-NoProfile', '-NonInteractive', '-Command', - 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', + '$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress', ], }, ]); @@ -184,6 +196,27 @@ suite('Launched child process discovery', () => { await assert.rejects(cyclic.resolveProcessId(10, identity)); }); + test('re-verifies selected PID ancestry before accepting a process-list candidate', async () => { + const injectedCandidate = process(42, 10, '/target/api', '/target/api'); + const query: LaunchedChildProcessQuery = { + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + injectedCandidate, + ], + // A newline in another process's command can forge the row above. The direct PID + // query exposes the real parent and must prevent attaching to that unrelated process. + getProcess: async processId => processId === 42 + ? process(42, 99, '/target/api', '/target/api') + : process(10, 1, '/tool/launcher'), + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + }); + test('normalizes query failures and supports cancellation', async () => { const failedResolver = new LaunchedChildProcessResolver( new SequenceProcessQuery([new Error('/private/target/api 4242')]), diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 29f6ebb8aa5..b7516bcb3f1 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -18,7 +18,6 @@ import { attachDebuggerAlreadyDebugging, attachDebuggerUnavailable, attachDebuggerResourceNotFound, - attachDebuggerCsharpExtensionRequired, attachDebuggerExtensionsRequired, attachDebuggerDeclined, dashboardUrlNotFound, @@ -78,7 +77,7 @@ type TreeElement = AppHostItem | EndpointUrlItem | ResourcesGroupItem | Resource interface AttachDebuggerHandledFailure { success: false; - errorKind: 'ResourceNotFound' | 'ResourceNotAttachable' | 'CSharpExtensionMissing'; + errorKind: 'ResourceNotFound' | 'ResourceNotAttachable'; } function isSamePath(left: string, right: string): boolean { @@ -1040,12 +1039,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider extension.id === 'ms-dotnettools.csharp')) { - vscode.window.showWarningMessage(attachDebuggerCsharpExtensionRequired); - return { success: false, errorKind: 'CSharpExtensionMissing' }; - } - - vscode.window.showWarningMessage(attachDebuggerExtensionsRequired( + const installMessage = result.debuggerExtensions.length === 1 + ? result.debuggerExtensions[0].installMessage + : undefined; + vscode.window.showWarningMessage(installMessage ?? attachDebuggerExtensionsRequired( result.debuggerExtensions.map(extension => extension.label).join(', '))); return { success: false, errorKind: 'ResourceNotAttachable' }; case 'resourceNotRunning': From 5705d188a97c49e4de9a187694322dd42146e20a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 01:45:29 -0400 Subject: [PATCH 49/90] test(extension): cover process discovery command runner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../debugger/launchedChildProcessDiscovery.ts | 14 ++++- .../launchedChildProcessDiscovery.test.ts | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 7d4d1b169e5..7c823c94668 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -27,6 +27,12 @@ export interface LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } +export type LaunchedChildProcessSpawner = ( + command: string, + args: readonly string[], + options: childProcess.SpawnOptions, +) => childProcess.ChildProcessWithoutNullStreams; + const maxProcessListingLength = 16 * 1024 * 1024; const windowsProcessProperties = 'ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine'; const windowsProcessQuery = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`; @@ -291,13 +297,19 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer } export class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { + constructor( + private readonly _spawn: LaunchedChildProcessSpawner = + childProcess.spawn as unknown as LaunchedChildProcessSpawner, + ) { + } + run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs = 1_000): Promise { return new Promise((resolve, reject) => { let completed = false; let cancellationRegistration: vscode.Disposable | undefined; let timeout: ReturnType | undefined; let output = ''; - const process = childProcess.spawn(command, args, { + const process = this._spawn(command, args, { stdio: 'pipe', windowsHide: true, }); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 7ddecfb4706..ab02634e0c3 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -1,4 +1,6 @@ import * as assert from 'assert'; +import type * as childProcess from 'child_process'; +import { EventEmitter } from 'events'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { @@ -6,6 +8,7 @@ import { parsePosixProcessList, parseWindowsProcessList, SystemLaunchedChildProcessQuery, + SystemLaunchedChildProcessCommandRunner, type LaunchedChildProcess, type LaunchedChildProcessClock, type LaunchedChildProcessCommandRunner, @@ -50,6 +53,22 @@ function process(pid: number, parentPid: number, executable: string, command = e return { pid, parentPid, executable, command }; } +function createCommandProcess(): childProcess.ChildProcessWithoutNullStreams { + const child = new EventEmitter() as childProcess.ChildProcessWithoutNullStreams; + const stdout = Object.assign(new EventEmitter(), { setEncoding: () => { } }); + const stderr = Object.assign(new EventEmitter(), { resume: sinon.stub() }); + Object.assign(child, { + killed: false, + stdout, + stderr, + kill: sinon.stub().callsFake(() => { + (child as unknown as { killed: boolean }).killed = true; + return true; + }), + }); + return child; +} + const identity: LaunchedChildProcessIdentity = { isLauncher: candidate => candidate.executable === '/tool/launcher', isCandidate: candidate => candidate.executable.includes('/target/'), @@ -107,6 +126,7 @@ suite('Launched child process discovery', () => { ExecutablePath: 'C:\\tool\\launcher.exe', CommandLine: 'launcher --run', }); + }, }; @@ -131,6 +151,49 @@ suite('Launched child process discovery', () => { ]); }); + test('command runner returns UTF-8/BOM output after draining stderr', async () => { + const child = createCommandProcess(); + const result = new SystemLaunchedChildProcessCommandRunner(() => child).run('ps', [], undefined, 100); + + child.stdout.emit('data', '\uFEFF日本語'); + child.stderr.emit('data', 'diagnostic'); + child.emit('close', 0); + + assert.strictEqual(await result, '\uFEFF日本語'); + assert.strictEqual((child.stderr.resume as sinon.SinonStub).calledOnce, true); + }); + + test('command runner rejects and cleans up on nonzero exit, cancellation, timeout, and output cap', async () => { + const children = [createCommandProcess(), createCommandProcess(), createCommandProcess(), createCommandProcess()]; + const spawn = sinon.stub(); + spawn.onCall(0).returns(children[0]); + spawn.onCall(1).returns(children[1]); + spawn.onCall(2).returns(children[2]); + spawn.onCall(3).returns(children[3]); + const runner = new SystemLaunchedChildProcessCommandRunner(spawn); + const cancellation = new vscode.CancellationTokenSource(); + + const nonzero = runner.run('ps', [], undefined, 100); + children[0].emit('close', 1); + await assert.rejects(nonzero); + + const cancelled = runner.run('ps', [], cancellation.token, 100); + cancellation.cancel(); + await assert.rejects(cancelled); + + const capped = runner.run('ps', [], undefined, 100); + children[2].stdout.emit('data', 'x'.repeat(16 * 1024 * 1024 + 1)); + await assert.rejects(capped); + + const timedOut = runner.run('ps', [], undefined, 1); + await assert.rejects(timedOut); + assert.strictEqual((children[0].kill as sinon.SinonStub).called, false); + assert.strictEqual((children[1].kill as sinon.SinonStub).calledOnce, true); + assert.strictEqual((children[2].kill as sinon.SinonStub).calledOnce, true); + assert.strictEqual((children[3].kill as sinon.SinonStub).calledOnce, true); + cancellation.dispose(); + }); + test('resolves a stable nested child only beneath its launcher', async () => { const resolver = new LaunchedChildProcessResolver( new SequenceProcessQuery([ From 7540c1430b7a4f33be45462dbfd29a47d13d2330 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 01:47:27 -0400 Subject: [PATCH 50/90] test(extension): cover spaced apphost identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 5 ++++- extension/src/test/dotnetDebugger.test.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 77406098505..84d732776db 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -755,7 +755,10 @@ function getAppHostPaths(targetPath: string): readonly string[] { } function commandStartsWithPath(command: string, targetPath: string): boolean { - return new RegExp(`^\\s*(?:"|')?${escapeRegularExpression(targetPath)}(?:"|')?(?=\\s|$)`).test(command); + const escapedPath = escapeRegularExpression(targetPath); + // A quoted program path must close with the same quote. Without that constraint, + // `"My Attach Service Worker"` would be mistaken for the apphost `"My Attach Service"`. + return new RegExp(`^\\s*(?:"${escapedPath}"|'${escapedPath}'|${escapedPath})(?=\\s|$)`).test(command); } function commandContainsPathArgument(command: string, targetPath: string): boolean { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 4fee435c016..023592c6152 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -281,7 +281,9 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { - const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/EvaluatedAppHost', null, true, true); + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; @@ -309,14 +311,14 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(processIdentity.isCandidate({ pid: 4321, parentPid: 1234, - executable: '/repo/bin/Debug/net10.0/EvaluatedAppHost', - command: '/repo/bin/Debug/net10.0/EvaluatedAppHost --urls http://localhost:5000', + executable: '/repo/OneDrive', + command: `"${targetPath}" "" --urls http://localhost:5000`, }), true); assert.strictEqual(processIdentity.isCandidate({ pid: 4322, parentPid: 1234, - executable: '/repo/bin/Debug/net10.0/EvaluatedAppHostWorker', - command: '/repo/bin/Debug/net10.0/EvaluatedAppHostWorker', + executable: '/repo/OneDrive', + command: `"${targetPath} Worker"`, }), false); }); From 1b7de0823ad26d9c09fbd666aabd28c8012b7d97 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 02:16:36 -0400 Subject: [PATCH 51/90] fix(extension): harden child process discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 40 ++--- .../debugger/launchedChildProcessDiscovery.ts | 167 ++++++++++++------ extension/src/test/dotnetDebugger.test.ts | 105 ++++++++++- extension/src/test/goProcessDiscovery.test.ts | 12 +- .../launchedChildProcessDiscovery.test.ts | 158 +++++++++++++++-- 5 files changed, 384 insertions(+), 98 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 84d732776db..244d0a95008 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -30,7 +30,6 @@ import { createAspireCliPathProcessEnvironment } from '../../utils/cliPathEnviro import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabledAdvisoryIfNeeded } from '../hotReload'; import { terminateCliProcess } from '../../utils/process/cliProcess'; import { - getProcessCommandProgram, launchedChildProcessResolver, type LaunchedChildProcess, type LaunchedChildProcessIdentity, @@ -711,6 +710,7 @@ function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { function createDotNetProcessIdentity(targetInfo: DotNetAttachTargetInfo): LaunchedChildProcessIdentity { return { + requiresDirectChild: true, isLauncher: process => isDotNetProcess(process), isCandidate: process => targetInfo.useAppHost ? isAppHostProcessForTarget(process, targetInfo.targetPath) @@ -719,21 +719,24 @@ function createDotNetProcessIdentity(targetInfo: DotNetAttachTargetInfo): Launch } function isDotNetProcess(process: LaunchedChildProcess): boolean { - return [getProcessCommandProgram(process.command), process.executable].some(value => { - const executableName = value?.split(/[\\/]/).pop()?.toLowerCase(); - return executableName === 'dotnet' || executableName === 'dotnet.exe'; - }); + const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'dotnet' || executableName === 'dotnet.exe'; } function isAppHostProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { - return getAppHostPaths(targetPath).some(appHostPath => - areProcessPathsEqual(process.executable, appHostPath) || - commandStartsWithPath(process.command, appHostPath)); + return getAppHostPaths(targetPath).some(appHostPath => areProcessPathsEqual(process.executable, appHostPath)); } function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { - return isDotNetProcess(process) && - commandContainsPathArgument(process.command, targetPath); + if (!isDotNetProcess(process)) { + return false; + } + + // `ps` exposes a flattened command string, not argv. An unquoted TargetPath such as + // "/repo/My Attach Service.dll" cannot be reconstructed safely because the boundaries of its + // whitespace-containing argument are lost. The resolver scopes these children directly to the + // launcher and rejects ambiguity, so use the structured executable identity in that case. + return /\s/.test(targetPath) || commandContainsPathArgument(process.command, targetPath); } function areProcessPathsEqual(left: string, right: string): boolean { @@ -754,18 +757,13 @@ function getAppHostPaths(targetPath: string): readonly string[] { return [appHostPath, `${appHostPath}.exe`]; } -function commandStartsWithPath(command: string, targetPath: string): boolean { - const escapedPath = escapeRegularExpression(targetPath); - // A quoted program path must close with the same quote. Without that constraint, - // `"My Attach Service Worker"` would be mistaken for the apphost `"My Attach Service"`. - return new RegExp(`^\\s*(?:"${escapedPath}"|'${escapedPath}'|${escapedPath})(?=\\s|$)`).test(command); -} - function commandContainsPathArgument(command: string, targetPath: string): boolean { - // Do not tokenize command text: POSIX `ps` flattens argv, and valid paths can contain - // whitespace (for example, "/repo/OneDrive - Microsoft/Api.dll"). Match the exact evaluated - // TargetPath in the original command with argument boundaries instead. - return new RegExp(`(?:^|\\s|["'])${escapeRegularExpression(targetPath)}(?=$|\\s|["'])`).test(command); + const normalizedCommand = command.replace(/\\/g, '/'); + const normalizedTargetPath = targetPath.replace(/\\/g, '/'); + const isWindowsPath = /^[a-z]:\//i.test(normalizedCommand) || /^[a-z]:\//i.test(normalizedTargetPath); + return new RegExp( + `(?:^|\\s|["'])${escapeRegularExpression(normalizedTargetPath)}(?=$|\\s|["'])`, + isWindowsPath ? 'i' : undefined).test(normalizedCommand); } function escapeRegularExpression(value: string): string { diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 7c823c94668..0424c7dab26 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -19,6 +19,7 @@ export interface LaunchedChildProcessClock { } export interface LaunchedChildProcessIdentity { + readonly requiresDirectChild?: boolean; isLauncher(process: LaunchedChildProcess): boolean; isCandidate(process: LaunchedChildProcess): boolean; } @@ -41,18 +42,17 @@ export function parsePosixProcessList(output: string): readonly LaunchedChildPro const processes: LaunchedChildProcess[] = []; for (const line of output.split(/\r?\n/)) { - // `ps -axo pid=,ppid=,comm=,args=` produces rows such as: - // 42 10 /private/.../app /private/.../app --port 8080 - // The command can contain spaces, so only split the first three fixed fields. - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?\s*$/.exec(line); + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); if (!match) { continue; } - const process = createProcessInfo(match[1], match[2], match[3], match[4] ?? match[3]); - if (process) { - processes.push(process); - } + processes.push({ + pid: Number(match[1]), + parentPid: Number(match[2]), + executable: '', + command: '', + }); } return processes; @@ -151,9 +151,18 @@ export class LaunchedChildProcessResolver { let candidate: number | undefined; try { - candidate = findMatchingDescendant(launcherPid, identity, processes); + candidate = await this._findMatchingCandidate( + launcherPid, + identity, + processes, + cancellationToken, + deadline); } - catch { + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + throw createProcessDiscoveryError(); } @@ -186,6 +195,47 @@ export class LaunchedChildProcessResolver { throw createProcessDiscoveryError(); } + private async _findMatchingCandidate( + launcherPid: number, + identity: LaunchedChildProcessIdentity, + processes: readonly LaunchedChildProcess[], + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + ): Promise { + const candidatePids = findDescendantProcessIds(launcherPid, identity.requiresDirectChild === true, processes); + if (!candidatePids) { + return undefined; + } + + const launcher = await this._getProcess( + launcherPid, + processes.find(process => process.pid === launcherPid), + cancellationToken, + deadline); + if (!launcher || !identity.isLauncher(launcher)) { + return undefined; + } + + const candidates: number[] = []; + for (const candidatePid of candidatePids) { + const candidate = await this._getProcess( + candidatePid, + processes.find(process => process.pid === candidatePid), + cancellationToken, + deadline); + if (!candidate || + (identity.requiresDirectChild === true && candidate.parentPid !== launcherPid)) { + continue; + } + + if (identity.isCandidate(candidate)) { + candidates.push(candidate.pid); + } + } + + return candidates.length === 1 ? candidates[0] : undefined; + } + private async _verifyCandidateLineage( candidatePid: number, launcherPid: number, @@ -210,26 +260,14 @@ export class LaunchedChildProcessResolver { } visited.add(processId); - let process: LaunchedChildProcess | undefined; - try { - process = await this._processQuery.getProcess( - processId, - cancellationToken, - Math.max(1, deadline - this._clock.now())); - } - catch (error) { - if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { - throw new vscode.CancellationError(); - } - + const process = await this._getProcess(processId, undefined, cancellationToken, deadline); + if (!process) { return false; } - if (!process || process.pid !== processId) { - return false; - } - - if (processId === candidatePid && !identity.isCandidate(process)) { + if (processId === candidatePid && + (!identity.isCandidate(process) || + (identity.requiresDirectChild === true && process.parentPid !== launcherPid))) { return false; } @@ -244,6 +282,32 @@ export class LaunchedChildProcessResolver { processId = process.parentPid; } } + + private async _getProcess( + processId: number, + topologyProcess: LaunchedChildProcess | undefined, + cancellationToken: vscode.CancellationToken | undefined, + deadline: number, + ): Promise { + if (!this._processQuery.getProcess) { + return topologyProcess; + } + + try { + const process = await this._processQuery.getProcess( + processId, + cancellationToken, + Math.max(1, deadline - this._clock.now())); + return process?.pid === processId ? process : undefined; + } + catch (error) { + if (error instanceof vscode.CancellationError || cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + return undefined; + } + } } export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuery { @@ -262,7 +326,7 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer timeoutMs) : await this._commandRunner.run( 'ps', - ['-axo', 'pid=,ppid=,comm=,args='], + ['-axo', 'pid=,ppid='], cancellationToken, timeoutMs); @@ -276,23 +340,26 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer return undefined; } - const output = this._platform === 'win32' - ? await this._commandRunner.run( + if (this._platform === 'win32') { + const output = await this._commandRunner.run( 'powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process -Filter "ProcessId = ${processId}" | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`], cancellationToken, - timeoutMs) - : await this._commandRunner.run( - 'ps', - ['-p', String(processId), '-o', 'pid=,ppid=,comm=,args='], - cancellationToken, timeoutMs); + return parseWindowsProcessList(output).find(process => process.pid === processId); + } - const processes = this._platform === 'win32' - ? parseWindowsProcessList(output) - : parsePosixProcessList(output); - return processes.find(process => process.pid === processId); + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'comm='], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'args='], cancellationToken, timeoutMs), + ]); + return createProcessInfo( + processId, + parentPidOutput.trim(), + executableOutput.trim(), + commandOutput.trim()); } } @@ -407,11 +474,11 @@ function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executabl }; } -function findMatchingDescendant( +function findDescendantProcessIds( launcherPid: number, - identity: LaunchedChildProcessIdentity, + requiresDirectChild: boolean, processes: readonly LaunchedChildProcess[], -): number | undefined { +): readonly number[] | undefined { const processById = new Map(); const childrenByParentId = new Map(); for (const process of processes) { @@ -425,13 +492,16 @@ function findMatchingDescendant( childrenByParentId.set(process.parentPid, children); } - const launcher = processById.get(launcherPid); - if (!launcher || !identity.isLauncher(launcher)) { + if (!processById.has(launcherPid)) { return undefined; } - const candidates: number[] = []; const descendants = [...(childrenByParentId.get(launcherPid) ?? [])]; + if (requiresDirectChild) { + return descendants.map(descendant => descendant.pid); + } + + const descendantsIds: number[] = []; const visitedProcessIds = new Set([launcherPid]); for (let index = 0; index < descendants.length; index++) { const descendant = descendants[index]; @@ -440,14 +510,11 @@ function findMatchingDescendant( } visitedProcessIds.add(descendant.pid); - if (identity.isCandidate(descendant)) { - candidates.push(descendant.pid); - } - + descendantsIds.push(descendant.pid); descendants.push(...(childrenByParentId.get(descendant.pid) ?? [])); } - return candidates.length === 1 ? candidates[0] : undefined; + return descendantsIds; } function parsePid(value: unknown): number | undefined { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 023592c6152..5c1f56cce75 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -113,6 +113,7 @@ interface TestLaunchedChildProcess { } interface TestLaunchedChildProcessIdentity { + readonly requiresDirectChild?: boolean; isLauncher(process: TestLaunchedChildProcess): boolean; isCandidate(process: TestLaunchedChildProcess): boolean; } @@ -221,7 +222,8 @@ suite('Dotnet Debugger Extension Tests', () => { parentPid: 1, executable: '/usr/local/share', command: '/usr/local/share/dotnet/dotnet run --project /repo/api/Api.csproj', - }), true); + }), false); + assert.strictEqual(processIdentity.requiresDirectChild, true); assert.strictEqual(processIdentity.isCandidate({ pid: 4321, parentPid: 1234, @@ -277,7 +279,39 @@ suite('Dotnet Debugger Extension Tests', () => { parentPid: 1234, executable: '/usr/local/share/dotnet/dotnet', command: `dotnet exec ${targetPath}.bak`, - }), false); + }), true); + }); + + test('matches a uniquely scoped dotnet child when a spaced DLL path cannot be reconstructed from POSIX command text', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + }), true); }); test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { @@ -311,7 +345,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(processIdentity.isCandidate({ pid: 4321, parentPid: 1234, - executable: '/repo/OneDrive', + executable: targetPath, command: `"${targetPath}" "" --urls http://localhost:5000`, }), true); assert.strictEqual(processIdentity.isCandidate({ @@ -320,6 +354,71 @@ suite('Dotnet Debugger Extension Tests', () => { executable: '/repo/OneDrive', command: `"${targetPath} Worker"`, }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/repo/OneDrive', + command: `${targetPath} Worker`, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/repo/OneDrive', + command: `"${targetPath}"`, + }), false); + }); + + test('normalizes Windows executable and command identities before matching', async () => { + const targetPath = 'C:\\Repo\\My Attach Service.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: 'c:/repo/MY ATTACH SERVICE.EXE', + command: 'not-used-for-apphost', + }), true); + + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath: 'C:\\Repo\\Api.dll', useAppHost: false }); + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const frameworkDependentIdentity = resolver.resolveProcessId.secondCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(frameworkDependentIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: 'c:/Program Files/dotnet/DOTNET.EXE', + command: 'dotnet exec c:/REPO\\api.DLL', + }), true); }); test('attach configuration derives the default apphost identity from TargetPath', async () => { diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts index 8ac753edd7e..f2439373e4c 100644 --- a/extension/src/test/goProcessDiscovery.test.ts +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -75,14 +75,14 @@ function createGoRunApplicationProcessResolver( suite('Go process discovery', () => { teardown(() => sinon.restore()); - test('parses POSIX process listings without retaining incomplete rows', () => { + test('parses POSIX process topology without retaining incomplete rows', () => { assert.deepStrictEqual(parsePosixProcessList([ - ' 10 1 /usr/local/go/bin/go go run ./cmd/api', - ' 42 10 /private/var/folders/x/go-build123/b001/exe/api /private/var/folders/x/go-build123/b001/exe/api --port 8080', + ' 10 1', + ' 42 10', 'not a process row', ].join('\n')), [ - process(10, 1, '/usr/local/go/bin/go', 'go run ./cmd/api'), - process(42, 10, '/private/var/folders/x/go-build123/b001/exe/api', '/private/var/folders/x/go-build123/b001/exe/api --port 8080'), + process(10, 1, '', ''), + process(42, 10, '', ''), ]); }); @@ -131,7 +131,7 @@ suite('Go process discovery', () => { assert.deepStrictEqual(calls, [ { command: 'ps', - args: ['-axo', 'pid=,ppid=,comm=,args='], + args: ['-axo', 'pid=,ppid='], }, { command: 'powershell.exe', diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index ab02634e0c3..ed33696b660 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -77,14 +77,14 @@ const identity: LaunchedChildProcessIdentity = { suite('Launched child process discovery', () => { teardown(() => sinon.restore()); - test('parses POSIX process listings without retaining incomplete rows', () => { + test('parses POSIX topology listings without process identity fields', () => { assert.deepStrictEqual(parsePosixProcessList([ - ' 10 1 /tool/launcher launcher --run', - ' 42 10 /target/api /target/api --port 8080', + ' 10 1', + ' 42 10', 'not a process row', ].join('\n')), [ - process(10, 1, '/tool/launcher', 'launcher --run'), - process(42, 10, '/target/api', '/target/api --port 8080'), + process(10, 1, '', ''), + process(42, 10, '', ''), ]); }); @@ -112,31 +112,66 @@ suite('Launched child process discovery', () => { ]); }); - test('uses fixed platform-specific process discovery commands', async () => { + test('uses an identity-free POSIX topology query and fixed per-process identity queries', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; const commandRunner: LaunchedChildProcessCommandRunner = { async run(command, args): Promise { calls.push({ command, args }); - return command === 'ps' - ? '10 1 /tool/launcher launcher --run' - : JSON.stringify({ - ProcessId: 10, - ParentProcessId: 1, - Name: 'launcher.exe', - ExecutablePath: 'C:\\tool\\launcher.exe', - CommandLine: 'launcher --run', - }); - + if (command === 'ps') { + if (args.join(' ') === '-axo pid=,ppid=') { + return '10 1\n42 10'; + } + + switch (args[args.length - 1]) { + case 'ppid=': + return '10'; + case 'comm=': + return '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + case 'args=': + return '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000'; + default: + throw new Error(`Unexpected ps query: ${args.join(' ')}`); + } + } + + return JSON.stringify({ + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: 'C:\\tool\\launcher.exe', + CommandLine: 'launcher --run', + }); }, }; - await new SystemLaunchedChildProcessQuery('linux', commandRunner).listProcesses(); + const query = new SystemLaunchedChildProcessQuery('linux', commandRunner); + assert.deepStrictEqual(await query.listProcesses(), [ + process(10, 1, '', ''), + process(42, 10, '', ''), + ]); + assert.deepStrictEqual(await query.getProcess(42), process( + 42, + 10, + '/repo/OneDrive - Microsoft/über-long-path/My Attach Service', + '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000')); await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); assert.deepStrictEqual(calls, [ { command: 'ps', - args: ['-axo', 'pid=,ppid=,comm=,args='], + args: ['-axo', 'pid=,ppid='], + }, + { + command: 'ps', + args: ['-p', '42', '-o', 'ppid='], + }, + { + command: 'ps', + args: ['-p', '42', '-o', 'comm='], + }, + { + command: 'ps', + args: ['-p', '42', '-o', 'args='], }, { command: 'powershell.exe', @@ -151,6 +186,57 @@ suite('Launched child process discovery', () => { ]); }); + test('resolves a POSIX child with a spaced non-ASCII executable path from separately queried details', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const processDetails = new Map([ + [10, { parentPid: 1, executable: '/tool/launcher', command: '/tool/launcher --run' }], + [42, { + parentPid: 10, + executable: '/repo/OneDrive - Microsoft/über-long-path/My Attach Service', + command: '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000', + }], + ]); + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + calls.push({ command, args }); + assert.strictEqual(command, 'ps'); + if (args.join(' ') === '-axo pid=,ppid=') { + return '10 1\n42 10'; + } + + const processId = Number(args[1]); + const details = processDetails.get(processId); + if (!details) { + throw new Error(`Unexpected process ID: ${processId}`); + } + + switch (args[args.length - 1]) { + case 'ppid=': + return String(details.parentPid); + case 'comm=': + return details.executable; + case 'args=': + return details.command; + default: + throw new Error(`Unexpected ps query: ${args.join(' ')}`); + } + }, + }; + const resolver = new LaunchedChildProcessResolver( + new SystemLaunchedChildProcessQuery('linux', commandRunner), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + const spacedTargetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const exactPathIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === spacedTargetPath, + }; + + assert.strictEqual(await resolver.resolveProcessId(10, exactPathIdentity), 42); + assert.ok(calls.every(call => call.args.join(' ') !== '-axo pid=,ppid=,comm=,args=')); + }); + test('command runner returns UTF-8/BOM output after draining stderr', async () => { const child = createCommandProcess(); const result = new SystemLaunchedChildProcessCommandRunner(() => child).run('ps', [], undefined, 100); @@ -247,6 +333,24 @@ suite('Launched child process discovery', () => { await assert.rejects(ambiguous.resolveProcessId(10, identity)); }); + test('fails closed when scoped direct dotnet children are ambiguous', async () => { + const directDotnetIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === '/usr/local/share/dotnet/dotnet', + }; + const resolver = new LaunchedChildProcessResolver( + new SequenceProcessQuery([[ + process(10, 1, '/tool/launcher'), + process(42, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec malformed-posix-command'), + process(43, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec another-malformed-posix-command'), + ]]), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, directDotnetIdentity)); + }); + test('fails closed for a cyclic process listing', async () => { const cyclic = new LaunchedChildProcessResolver( new SequenceProcessQuery([[ @@ -304,4 +408,22 @@ suite('Launched child process discovery', () => { cancellation.dispose(); } }); + + test('propagates cancellation from a per-process identity query', async () => { + const query: LaunchedChildProcessQuery = { + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + ], + getProcess: async () => { + throw new vscode.CancellationError(); + }, + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, identity), vscode.CancellationError); + }); }); From fed2a2f73965bbe21e8eb19e78bd0fc04e5cf26c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 02:35:47 -0400 Subject: [PATCH 52/90] fix(extension): read Linux child details from procfs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 25 ++- .../debugger/launchedChildProcessDiscovery.ts | 105 +++++++++- extension/src/test/dotnetDebugger.test.ts | 27 ++- .../launchedChildProcessDiscovery.test.ts | 187 +++++++++++++++--- 4 files changed, 305 insertions(+), 39 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 244d0a95008..42929e617fc 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -732,11 +732,11 @@ function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, tar return false; } - // `ps` exposes a flattened command string, not argv. An unquoted TargetPath such as - // "/repo/My Attach Service.dll" cannot be reconstructed safely because the boundaries of its - // whitespace-containing argument are lost. The resolver scopes these children directly to the - // launcher and rejects ambiguity, so use the structured executable identity in that case. - return /\s/.test(targetPath) || commandContainsPathArgument(process.command, targetPath); + if (process.commandLineArguments) { + return commandLineArgumentsContainTargetPath(process.commandLineArguments, targetPath); + } + + return commandContainsPathArgumentAfterDotNetExec(process.command, targetPath); } function areProcessPathsEqual(left: string, right: string): boolean { @@ -757,6 +757,21 @@ function getAppHostPaths(targetPath: string): readonly string[] { return [appHostPath, `${appHostPath}.exe`]; } +function commandLineArgumentsContainTargetPath(argumentsList: readonly string[], targetPath: string): boolean { + const execIndex = argumentsList.indexOf('exec'); + return execIndex >= 1 && + argumentsList.slice(execIndex + 1).some(argument => areProcessPathsEqual(argument, targetPath)); +} + +function commandContainsPathArgumentAfterDotNetExec(command: string, targetPath: string): boolean { + const dotNetExec = /^\s*(?:"[^"]+"|'[^']+'|\S+)\s+exec(?:\s+|$)/.exec(command); + if (!dotNetExec) { + return false; + } + + return commandContainsPathArgument(command.slice(dotNetExec[0].length), targetPath); +} + function commandContainsPathArgument(command: string, targetPath: string): boolean { const normalizedCommand = command.replace(/\\/g, '/'); const normalizedTargetPath = targetPath.replace(/\\/g, '/'); diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 0424c7dab26..a2b1ac3622b 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -1,4 +1,5 @@ import * as childProcess from 'child_process'; +import * as fs from 'fs'; import * as vscode from 'vscode'; export interface LaunchedChildProcess { @@ -6,6 +7,7 @@ export interface LaunchedChildProcess { readonly parentPid: number; readonly executable: string; readonly command: string; + readonly commandLineArguments?: readonly string[]; } export interface LaunchedChildProcessQuery { @@ -28,6 +30,11 @@ export interface LaunchedChildProcessCommandRunner { run(command: string, args: readonly string[], cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } +export interface LaunchedChildProcessFileSystem { + readlink(path: string): Promise; + readFile(path: string): Promise; +} + export type LaunchedChildProcessSpawner = ( command: string, args: readonly string[], @@ -314,6 +321,7 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer constructor( private readonly _platform: NodeJS.Platform = process.platform, private readonly _commandRunner: LaunchedChildProcessCommandRunner = new SystemLaunchedChildProcessCommandRunner(), + private readonly _fileSystem: LaunchedChildProcessFileSystem = systemLaunchedChildProcessFileSystem, ) { } @@ -350,6 +358,10 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer return parseWindowsProcessList(output).find(process => process.pid === processId); } + if (this._platform === 'linux') { + return this._getLinuxProcess(processId, cancellationToken, timeoutMs); + } + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), this._commandRunner.run('ps', ['-p', String(processId), '-o', 'comm='], cancellationToken, timeoutMs), @@ -361,6 +373,36 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer executableOutput.trim(), commandOutput.trim()); } + + private async _getLinuxProcess( + processId: number, + cancellationToken: vscode.CancellationToken | undefined, + timeoutMs: number | undefined, + ): Promise { + // Procfs exposes exact details as separate kernel-owned files. `cmdline` is a NUL-separated + // byte sequence such as `dotnet\0exec\0/repo/My Service.dll\0`; do not route it through a + // shell or flatten it before identity matching. + const [executable, commandLine, status] = await awaitProcessDetails( + Promise.all([ + this._fileSystem.readlink(`/proc/${processId}/exe`), + this._fileSystem.readFile(`/proc/${processId}/cmdline`), + this._fileSystem.readFile(`/proc/${processId}/status`), + ]), + cancellationToken, + timeoutMs); + const parentPid = parseLinuxParentPid(status); + if (parentPid === undefined) { + return undefined; + } + + const commandLineArguments = parseLinuxCommandLine(commandLine); + return createProcessInfo( + processId, + parentPid, + executable, + commandLineArguments.join(' '), + commandLineArguments); + } } export class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { @@ -454,10 +496,21 @@ const systemLaunchedChildProcessClock: LaunchedChildProcessClock = { }), }; +const systemLaunchedChildProcessFileSystem: LaunchedChildProcessFileSystem = { + readlink: path => fs.promises.readlink(path), + readFile: path => fs.promises.readFile(path), +}; + export const launchedChildProcessResolver = new LaunchedChildProcessResolver( new SystemLaunchedChildProcessQuery()); -function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executableValue: unknown, commandValue: unknown): LaunchedChildProcess | undefined { +function createProcessInfo( + pidValue: unknown, + parentPidValue: unknown, + executableValue: unknown, + commandValue: unknown, + commandLineArguments?: readonly string[], +): LaunchedChildProcess | undefined { const pid = parsePid(pidValue); const parentPid = parseParentPid(parentPidValue); const executable = typeof executableValue === 'string' ? executableValue.trim() : ''; @@ -471,9 +524,59 @@ function createProcessInfo(pidValue: unknown, parentPidValue: unknown, executabl parentPid, executable, command: command.length > 0 ? command : executable, + ...(commandLineArguments ? { commandLineArguments } : {}), }; } +function parseLinuxCommandLine(commandLine: Buffer): readonly string[] { + const argumentsList = commandLine.toString('utf8').split('\0'); + if (argumentsList.at(-1) === '') { + argumentsList.pop(); + } + + return argumentsList; +} + +function parseLinuxParentPid(status: Buffer): number | undefined { + const match = /^PPid:\s*(\d+)\s*$/m.exec(status.toString('utf8')); + return match ? parseParentPid(match[1]) : undefined; +} + +function awaitProcessDetails( + details: Promise, + cancellationToken: vscode.CancellationToken | undefined, + timeoutMs: number | undefined, +): Promise { + return new Promise((resolve, reject) => { + let completed = false; + let cancellationRegistration: vscode.Disposable | undefined; + const timeout = setTimeout( + () => complete(() => reject(createProcessDiscoveryError())), + Math.max(1, timeoutMs ?? 30_000)); + const complete = (action: () => void) => { + if (completed) { + return; + } + + completed = true; + clearTimeout(timeout); + cancellationRegistration?.dispose(); + action(); + }; + + cancellationRegistration = cancellationToken?.onCancellationRequested( + () => complete(() => reject(new vscode.CancellationError()))); + if (cancellationToken?.isCancellationRequested) { + complete(() => reject(new vscode.CancellationError())); + return; + } + + details.then( + result => complete(() => resolve(result)), + error => complete(() => reject(error))); + }); +} + function findDescendantProcessIds( launcherPid: number, requiresDirectChild: boolean, diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 5c1f56cce75..e0358531110 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -110,6 +110,7 @@ interface TestLaunchedChildProcess { readonly parentPid: number; readonly executable: string; readonly command: string; + readonly commandLineArguments?: readonly string[]; } interface TestLaunchedChildProcessIdentity { @@ -244,7 +245,7 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); - test('matches a spaced evaluated TargetPath from the raw framework-dependent command', async () => { + test('matches a spaced evaluated TargetPath from the raw framework-dependent command without matching a prefix sibling', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); @@ -279,10 +280,16 @@ suite('Dotnet Debugger Extension Tests', () => { parentPid: 1234, executable: '/usr/local/share/dotnet/dotnet', command: `dotnet exec ${targetPath}.bak`, - }), true); + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet run ${targetPath}`, + }), false); }); - test('matches a uniquely scoped dotnet child when a spaced DLL path cannot be reconstructed from POSIX command text', async () => { + test('matches a structured framework-dependent TargetPath without accepting other dotnet children', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); @@ -311,7 +318,15 @@ suite('Dotnet Debugger Extension Tests', () => { parentPid: 1234, executable: '/usr/local/share/dotnet/dotnet', command: 'dotnet exec malformed-posix-command', + commandLineArguments: ['dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'], }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: ['dotnet', 'exec', `${targetPath}.bak`], + }), false); }); test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { @@ -366,6 +381,12 @@ suite('Dotnet Debugger Extension Tests', () => { executable: '/repo/OneDrive', command: `"${targetPath}"`, }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, + executable: `${targetPath} Worker`, + command: `${targetPath} Worker`, + }), false); }); test('normalizes Windows executable and command identities before matching', async () => { diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index ed33696b660..7a43852a454 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -12,6 +12,7 @@ import { type LaunchedChildProcess, type LaunchedChildProcessClock, type LaunchedChildProcessCommandRunner, + type LaunchedChildProcessFileSystem, type LaunchedChildProcessIdentity, type LaunchedChildProcessQuery, } from '../debugger/launchedChildProcessDiscovery'; @@ -49,8 +50,20 @@ class SequenceProcessQuery implements LaunchedChildProcessQuery { } } -function process(pid: number, parentPid: number, executable: string, command = executable): LaunchedChildProcess { - return { pid, parentPid, executable, command }; +function process( + pid: number, + parentPid: number, + executable: string, + command = executable, + commandLineArguments?: readonly string[], +): LaunchedChildProcess { + return { + pid, + parentPid, + executable, + command, + ...(commandLineArguments ? { commandLineArguments } : {}), + }; } function createCommandProcess(): childProcess.ChildProcessWithoutNullStreams { @@ -74,6 +87,13 @@ const identity: LaunchedChildProcessIdentity = { isCandidate: candidate => candidate.executable.includes('/target/'), }; +function createLinuxProcessQuery( + commandRunner: LaunchedChildProcessCommandRunner, + fileSystem: LaunchedChildProcessFileSystem, +): SystemLaunchedChildProcessQuery { + return new SystemLaunchedChildProcessQuery('linux', commandRunner, fileSystem); +} + suite('Launched child process discovery', () => { teardown(() => sinon.restore()); @@ -112,8 +132,11 @@ suite('Launched child process discovery', () => { ]); }); - test('uses an identity-free POSIX topology query and fixed per-process identity queries', async () => { + test('reads exact Linux process details from procfs without using truncated ps command output', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; + const fileSystemCalls: string[] = []; + const executablePath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; const commandRunner: LaunchedChildProcessCommandRunner = { async run(command, args): Promise { calls.push({ command, args }); @@ -122,16 +145,7 @@ suite('Launched child process discovery', () => { return '10 1\n42 10'; } - switch (args[args.length - 1]) { - case 'ppid=': - return '10'; - case 'comm=': - return '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; - case 'args=': - return '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000'; - default: - throw new Error(`Unexpected ps query: ${args.join(' ')}`); - } + throw new Error(`Unexpected ps query: ${args.join(' ')}`); } return JSON.stringify({ @@ -143,8 +157,26 @@ suite('Launched child process discovery', () => { }); }, }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + fileSystemCalls.push(path); + assert.strictEqual(path, '/proc/42/exe'); + return executablePath; + }, + async readFile(path): Promise { + fileSystemCalls.push(path); + switch (path) { + case '/proc/42/cmdline': + return Buffer.from(['/usr/local/share/dotnet/dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'].join('\0') + '\0'); + case '/proc/42/status': + return Buffer.from('Name:\tMy Attach Service\nPid:\t42\nPPid:\t10\nNSpid:\t42\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; - const query = new SystemLaunchedChildProcessQuery('linux', commandRunner); + const query = createLinuxProcessQuery(commandRunner, fileSystem); assert.deepStrictEqual(await query.listProcesses(), [ process(10, 1, '', ''), process(42, 10, '', ''), @@ -152,8 +184,10 @@ suite('Launched child process discovery', () => { assert.deepStrictEqual(await query.getProcess(42), process( 42, 10, - '/repo/OneDrive - Microsoft/über-long-path/My Attach Service', - '"/repo/OneDrive - Microsoft/über-long-path/My Attach Service" --urls http://localhost:5000')); + executablePath, + `/usr/local/share/dotnet/dotnet exec ${targetPath} --urls http://localhost:5000`, + ['/usr/local/share/dotnet/dotnet', 'exec', targetPath, '', '--urls', 'http://localhost:5000'])); + await query.getProcess(42); await new SystemLaunchedChildProcessQuery('win32', commandRunner).listProcesses(); assert.deepStrictEqual(calls, [ @@ -161,18 +195,6 @@ suite('Launched child process discovery', () => { command: 'ps', args: ['-axo', 'pid=,ppid='], }, - { - command: 'ps', - args: ['-p', '42', '-o', 'ppid='], - }, - { - command: 'ps', - args: ['-p', '42', '-o', 'comm='], - }, - { - command: 'ps', - args: ['-p', '42', '-o', 'args='], - }, { command: 'powershell.exe', args: [ @@ -184,9 +206,17 @@ suite('Launched child process discovery', () => { ], }, ]); + assert.deepStrictEqual(fileSystemCalls.sort(), [ + '/proc/42/cmdline', + '/proc/42/cmdline', + '/proc/42/exe', + '/proc/42/exe', + '/proc/42/status', + '/proc/42/status', + ]); }); - test('resolves a POSIX child with a spaced non-ASCII executable path from separately queried details', async () => { + test('resolves a macOS child with a spaced non-ASCII executable path from per-candidate ps details', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; const processDetails = new Map([ [10, { parentPid: 1, executable: '/tool/launcher', command: '/tool/launcher --run' }], @@ -223,7 +253,7 @@ suite('Launched child process discovery', () => { }, }; const resolver = new LaunchedChildProcessResolver( - new SystemLaunchedChildProcessQuery('linux', commandRunner), + new SystemLaunchedChildProcessQuery('darwin', commandRunner), new TestClock(), { timeoutMs: 100, retryDelayMs: 10 }); const spacedTargetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; @@ -237,6 +267,103 @@ suite('Launched child process discovery', () => { assert.ok(calls.every(call => call.args.join(' ') !== '-axo pid=,ppid=,comm=,args=')); }); + test('retries when a Linux candidate exits between topology and procfs reads', async () => { + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; + let candidateReadAttempts = 0; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + assert.strictEqual(command, 'ps'); + assert.deepStrictEqual(args, ['-axo', 'pid=,ppid=']); + return '10 1\n42 10'; + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + if (path === '/proc/42/exe') { + candidateReadAttempts++; + if (candidateReadAttempts === 1) { + throw new Error('Process exited.'); + } + + return '/usr/local/share/dotnet/dotnet'; + } + + assert.strictEqual(path, '/proc/10/exe'); + return '/tool/launcher'; + }, + async readFile(path): Promise { + switch (path) { + case '/proc/10/cmdline': + return Buffer.from('/tool/launcher\0'); + case '/proc/10/status': + return Buffer.from('Name:\tlauncher\nPPid:\t1\n'); + case '/proc/42/cmdline': + return Buffer.from(`/usr/local/share/dotnet/dotnet\0exec\0${targetPath}\0`); + case '/proc/42/status': + return Buffer.from('Name:\tdotnet\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const resolver = new LaunchedChildProcessResolver( + createLinuxProcessQuery(commandRunner, fileSystem), + new TestClock(), + { timeoutMs: 100, retryDelayMs: 10 }); + const frameworkDependentIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => candidate.executable === '/tool/launcher', + isCandidate: candidate => candidate.executable === '/usr/local/share/dotnet/dotnet' && + candidate.commandLineArguments?.includes(targetPath) === true, + }; + + assert.strictEqual(await resolver.resolveProcessId(10, frameworkDependentIdentity), 42); + assert.ok(candidateReadAttempts >= 3); + }); + + test('fails closed after repeated Linux procfs permission errors', async () => { + let candidateReadAttempts = 0; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(command, args): Promise { + assert.strictEqual(command, 'ps'); + assert.deepStrictEqual(args, ['-axo', 'pid=,ppid=']); + return '10 1\n42 10'; + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + if (path === '/proc/42/exe') { + candidateReadAttempts++; + throw new Error('EACCES'); + } + + assert.strictEqual(path, '/proc/10/exe'); + return '/tool/launcher'; + }, + async readFile(path): Promise { + switch (path) { + case '/proc/10/cmdline': + return Buffer.from('/tool/launcher\0'); + case '/proc/10/status': + return Buffer.from('Name:\tlauncher\nPPid:\t1\n'); + case '/proc/42/cmdline': + return Buffer.from('/target/api\0'); + case '/proc/42/status': + return Buffer.from('Name:\tapi\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const resolver = new LaunchedChildProcessResolver( + createLinuxProcessQuery(commandRunner, fileSystem), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + assert.ok(candidateReadAttempts >= 2); + }); + test('command runner returns UTF-8/BOM output after draining stderr', async () => { const child = createCommandProcess(); const result = new SystemLaunchedChildProcessCommandRunner(() => child).run('ps', [], undefined, 100); From ee0336e6a54e5de07c1d4c82ee2f26326f663795 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 03:03:48 -0400 Subject: [PATCH 53/90] fix(extension): normalize Linux procfs apphost identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 48 ++++++++-- .../debugger/launchedChildProcessDiscovery.ts | 22 ++++- extension/src/test/dotnetDebugger.test.ts | 93 +++++++++++++++++++ .../launchedChildProcessDiscovery.test.ts | 70 ++++++++++++++ 4 files changed, 222 insertions(+), 11 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 42929e617fc..345b049e92f 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -64,6 +64,10 @@ interface LaunchedChildProcessResolver { ): Promise; } +interface DotNetAttachFileSystem { + realpath(path: string): Promise; +} + const executableArgsPropertyName = 'executable.args'; const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; @@ -708,12 +712,18 @@ function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -function createDotNetProcessIdentity(targetInfo: DotNetAttachTargetInfo): LaunchedChildProcessIdentity { +async function createDotNetProcessIdentity( + targetInfo: DotNetAttachTargetInfo, + fileSystem: DotNetAttachFileSystem, +): Promise { + const appHostPaths = targetInfo.useAppHost + ? await getCanonicalAppHostPaths(targetInfo.targetPath, fileSystem) + : undefined; return { requiresDirectChild: true, isLauncher: process => isDotNetProcess(process), isCandidate: process => targetInfo.useAppHost - ? isAppHostProcessForTarget(process, targetInfo.targetPath) + ? isAppHostProcessForTarget(process, appHostPaths!) : isFrameworkDependentProcessForTarget(process, targetInfo.targetPath), }; } @@ -723,8 +733,8 @@ function isDotNetProcess(process: LaunchedChildProcess): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -function isAppHostProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { - return getAppHostPaths(targetPath).some(appHostPath => areProcessPathsEqual(process.executable, appHostPath)); +function isAppHostProcessForTarget(process: LaunchedChildProcess, appHostPaths: readonly string[]): boolean { + return appHostPaths.some(appHostPath => areProcessPathsEqual(process.executable, appHostPath)); } function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { @@ -757,6 +767,26 @@ function getAppHostPaths(targetPath: string): readonly string[] { return [appHostPath, `${appHostPath}.exe`]; } +async function getCanonicalAppHostPaths( + targetPath: string, + fileSystem: DotNetAttachFileSystem, +): Promise { + const appHostPaths = getAppHostPaths(targetPath); + const canonicalAppHostPaths = await Promise.all(appHostPaths.map(async appHostPath => { + try { + return await fileSystem.realpath(appHostPath); + } + catch { + // `/proc//exe` resolves symlinked directories while MSBuild TargetPath preserves + // their spelling. The apphost can disappear after launch, so retain the raw candidate + // when realpath races process shutdown rather than failing attach discovery. + return appHostPath; + } + })); + + return [...new Set([...appHostPaths, ...canonicalAppHostPaths])]; +} + function commandLineArgumentsContainTargetPath(argumentsList: readonly string[], targetPath: string): boolean { const execIndex = argumentsList.indexOf('exec'); return execIndex >= 1 && @@ -790,6 +820,7 @@ export async function createDotNetAttachDebugSessionConfiguration( dotNetService: IDotNetService, childProcessResolver: LaunchedChildProcessResolver, cancellationToken?: vscode.CancellationToken, + fileSystem: DotNetAttachFileSystem = systemDotNetAttachFileSystem, ): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { @@ -810,7 +841,7 @@ export async function createDotNetAttachDebugSessionConfiguration( try { applicationPid = await childProcessResolver.resolveProcessId( attachInfo.launcherPid, - createDotNetProcessIdentity(targetInfo), + await createDotNetProcessIdentity(targetInfo, fileSystem), cancellationToken); } catch (error) { @@ -1077,6 +1108,7 @@ export const projectDebuggerExtension: ResourceDebuggerExtension = createProject export function createProjectResourceAttachProvider( dotNetServiceProducer: () => IDotNetService, childProcessResolver: LaunchedChildProcessResolver = launchedChildProcessResolver, + fileSystem: DotNetAttachFileSystem = systemDotNetAttachFileSystem, ): ResourceAttachProvider { return { id: 'dotnet', @@ -1088,9 +1120,13 @@ export function createProjectResourceAttachProvider( canRecognizeResource: resource => canRecognizeDotNetAttachDebuggerResource(resource), canAttachToResource: resource => getDotNetAttachDebuggerResourceInfo(resource) !== undefined, createDebugConfiguration: async (resource, cancellationToken) => - await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), childProcessResolver, cancellationToken), + await createDotNetAttachDebugSessionConfiguration(resource, dotNetServiceProducer(), childProcessResolver, cancellationToken, fileSystem), }; } +const systemDotNetAttachFileSystem: DotNetAttachFileSystem = { + realpath: path => fs.promises.realpath(path), +}; + export const projectResourceAttachProvider: ResourceAttachProvider = createProjectResourceAttachProvider(() => new DotNetService(undefined)); diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index a2b1ac3622b..1b747d1f228 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -44,6 +44,7 @@ export type LaunchedChildProcessSpawner = ( const maxProcessListingLength = 16 * 1024 * 1024; const windowsProcessProperties = 'ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine'; const windowsProcessQuery = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-CimInstance Win32_Process | Select-Object ${windowsProcessProperties} | ConvertTo-Json -Compress`; +const linuxDeletedExecutableMarker = ' (deleted)'; export function parsePosixProcessList(output: string): readonly LaunchedChildProcess[] { const processes: LaunchedChildProcess[] = []; @@ -399,7 +400,7 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer return createProcessInfo( processId, parentPid, - executable, + normalizeLinuxExecutablePath(executable), commandLineArguments.join(' '), commandLineArguments); } @@ -542,6 +543,15 @@ function parseLinuxParentPid(status: Buffer): number | undefined { return match ? parseParentPid(match[1]) : undefined; } +function normalizeLinuxExecutablePath(executable: string): string { + // `/proc//exe` reports an unlinked executable as `/path/app (deleted)`. Remove only + // the kernel's exact trailing marker so a filename that contains those characters elsewhere + // remains a distinct executable identity. + return executable.endsWith(linuxDeletedExecutableMarker) + ? executable.slice(0, -linuxDeletedExecutableMarker.length) + : executable; +} + function awaitProcessDetails( details: Promise, cancellationToken: vscode.CancellationToken | undefined, @@ -564,16 +574,18 @@ function awaitProcessDetails( action(); }; + // The procfs reads have already started. Observe both outcomes before checking + // cancellation so a cancelled caller cannot leave the aggregate promise unobserved. + details.then( + result => complete(() => resolve(result)), + error => complete(() => reject(error))); + cancellationRegistration = cancellationToken?.onCancellationRequested( () => complete(() => reject(new vscode.CancellationError()))); if (cancellationToken?.isCancellationRequested) { complete(() => reject(new vscode.CancellationError())); return; } - - details.then( - result => complete(() => resolve(result)), - error => complete(() => reject(error))); }); } diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index e0358531110..9b98996e843 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -389,6 +389,99 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); + test('matches a canonical apphost path from a symlinked TargetPath without accepting a same-name sibling', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const canonicalAppHostPath = '/workspace/physical/bin/Debug/net10.0/Api'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === appHostPath) { + return canonicalAppHostPath; + } + + throw new Error('ENOENT'); + }); + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider( + () => dotNetService, + resolver, + { realpath }); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + }, + }); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: canonicalAppHostPath, + command: canonicalAppHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/workspace/other/bin/Debug/net10.0/Api', + command: '/workspace/other/bin/Debug/net10.0/Api', + }), false); + assert.ok(realpath.calledWithExactly(appHostPath)); + }); + + test('uses the TargetPath apphost candidate when canonicalization races with process discovery', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().rejects(new Error('ENOENT')); + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider( + () => dotNetService, + resolver, + { realpath }); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + }, + }); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: appHostPath, + command: appHostPath, + }), true); + assert.ok(realpath.calledWithExactly(appHostPath)); + }); + test('normalizes Windows executable and command identities before matching', async () => { const targetPath = 'C:\\Repo\\My Attach Service.dll'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 7a43852a454..cece14873a7 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -216,6 +216,76 @@ suite('Launched child process discovery', () => { ]); }); + test('strips only the exact trailing Linux procfs deleted executable marker', async () => { + const deletedExecutable = '/repo/bin/Debug/net10.0/Api (deleted)'; + const nonMarkerSuffix = `${deletedExecutable} after-restart`; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(): Promise { + throw new Error('The process topology should not be queried.'); + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + async readlink(path): Promise { + switch (path) { + case '/proc/42/exe': + return deletedExecutable; + case '/proc/43/exe': + return nonMarkerSuffix; + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + async readFile(path): Promise { + switch (path) { + case '/proc/42/cmdline': + case '/proc/43/cmdline': + return Buffer.from('/repo/bin/Debug/net10.0/Api\0'); + case '/proc/42/status': + case '/proc/43/status': + return Buffer.from('Name:\tApi\nPPid:\t10\n'); + default: + throw new Error(`Unexpected procfs path: ${path}`); + } + }, + }; + const query = createLinuxProcessQuery(commandRunner, fileSystem); + + assert.strictEqual((await query.getProcess(42))?.executable, '/repo/bin/Debug/net10.0/Api'); + assert.strictEqual((await query.getProcess(43))?.executable, nonMarkerSuffix); + }); + + test('observes rejecting procfs reads before returning an already requested cancellation', async () => { + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + let unhandledRejection: unknown; + const captureUnhandledRejection = (reason: unknown) => { + unhandledRejection = reason; + }; + globalThis.process.once('unhandledRejection', captureUnhandledRejection); + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(): Promise { + throw new Error('The process topology should not be queried.'); + }, + }; + const fileSystem: LaunchedChildProcessFileSystem = { + readlink: () => Promise.reject(new Error('readlink failed')), + readFile: () => Promise.reject(new Error('readFile failed')), + }; + + try { + await assert.rejects( + createLinuxProcessQuery(commandRunner, fileSystem).getProcess(42, cancellation.token), + error => error instanceof vscode.CancellationError); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(unhandledRejection, undefined); + } + finally { + globalThis.process.removeListener('unhandledRejection', captureUnhandledRejection); + cancellation.dispose(); + } + }); + test('resolves a macOS child with a spaced non-ASCII executable path from per-candidate ps details', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; const processDetails = new Map([ From 5c6497151af19299de5ba1215ba74d2e2f3ec71f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 03:44:51 -0400 Subject: [PATCH 54/90] fix(extension): resolve deleted apphost identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/debugger/languages/dotnet.ts | 22 ++- extension/src/test/dotnetDebugger.test.ts | 149 ++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 345b049e92f..f96bab80547 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -777,14 +777,26 @@ async function getCanonicalAppHostPaths( return await fileSystem.realpath(appHostPath); } catch { - // `/proc//exe` resolves symlinked directories while MSBuild TargetPath preserves - // their spelling. The apphost can disappear after launch, so retain the raw candidate - // when realpath races process shutdown rather than failing attach discovery. - return appHostPath; + return undefined; } })); - return [...new Set([...appHostPaths, ...canonicalAppHostPaths])]; + let canonicalTargetDirectory: string | undefined; + if (canonicalAppHostPaths.some(appHostPath => appHostPath === undefined)) { + try { + canonicalTargetDirectory = await fileSystem.realpath(path.dirname(targetPath)); + } + catch { + // `/proc//exe` resolves symlinked directories even after its final executable was + // unlinked. Retain the raw path if neither the file nor its parent directory survives. + } + } + + const directoryCanonicalizedAppHostPaths = canonicalAppHostPaths.map((appHostPath, index) => + appHostPath ?? (canonicalTargetDirectory + ? path.join(canonicalTargetDirectory, path.basename(appHostPaths[index])) + : appHostPaths[index])); + return [...new Set([...appHostPaths, ...directoryCanonicalizedAppHostPaths])]; } function commandLineArgumentsContainTargetPath(argumentsList: readonly string[], targetPath: string): boolean { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 9b98996e843..5cc1dbe476f 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -389,10 +389,12 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); - test('matches a canonical apphost path from a symlinked TargetPath without accepting a same-name sibling', async () => { + test('preserves raw and full-realpath apphost candidates', async () => { const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; const canonicalAppHostPath = '/workspace/physical/bin/Debug/net10.0/Api'; + const canonicalAppHostExePath = `${canonicalAppHostPath}.exe`; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); const resolver = { resolveProcessId: sinon.stub().resolves(4321), @@ -401,6 +403,9 @@ suite('Dotnet Debugger Extension Tests', () => { if (candidate === appHostPath) { return canonicalAppHostPath; } + if (candidate === appHostExePath) { + return canonicalAppHostExePath; + } throw new Error('ENOENT'); }); @@ -436,15 +441,147 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(appHostIdentity.isCandidate({ pid: 4322, parentPid: 1234, + executable: appHostPath, + command: appHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: canonicalAppHostExePath, + command: canonicalAppHostExePath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: appHostExePath, + command: appHostExePath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, executable: '/workspace/other/bin/Debug/net10.0/Api', command: '/workspace/other/bin/Debug/net10.0/Api', }), false); assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(appHostExePath)); }); - test('uses the TargetPath apphost candidate when canonicalization races with process discovery', async () => { + test('matches a deleted apphost from a symlinked TargetPath directory', async () => { const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; + const targetDirectory = nodePath.dirname(targetPath); + const canonicalTargetDirectory = '/workspace/physical/bin/Debug/net10.0'; + const canonicalAppHostPath = nodePath.join(canonicalTargetDirectory, nodePath.basename(appHostPath)); + const canonicalAppHostExePath = nodePath.join(canonicalTargetDirectory, nodePath.basename(appHostExePath)); + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === targetDirectory) { + return canonicalTargetDirectory; + } + + throw new Error('ENOENT'); + }); + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider( + () => dotNetService, + resolver, + { realpath }); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + }, + }); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: canonicalAppHostPath, + command: canonicalAppHostPath, + }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: canonicalAppHostExePath, + command: canonicalAppHostExePath, + }), true); + assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(targetDirectory)); + }); + + test('does not match a same-named apphost outside the canonical TargetPath directory', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const targetDirectory = nodePath.dirname(targetPath); + const canonicalTargetDirectory = '/workspace/physical/bin/Debug/net10.0'; + const unrelatedAppHostPath = '/workspace/other/bin/Debug/net10.0/Api'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const realpath = sinon.stub().callsFake(async (candidate: string) => { + if (candidate === targetDirectory) { + return canonicalTargetDirectory; + } + + throw new Error('ENOENT'); + }); + const createAttachProvider = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + const attachProvider = createAttachProvider( + () => dotNetService, + resolver, + { realpath }); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + }, + }); + + const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: unrelatedAppHostPath, + command: unrelatedAppHostPath, + }), false); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/workspace/physical/bin/Debug/net10.0/Api Replica', + command: '/workspace/physical/bin/Debug/net10.0/Api Replica', + }), false); + }); + + test('falls back to raw apphost candidates when canonical TargetPath directory lookup fails', async () => { + const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; + const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; + const appHostExePath = `${appHostPath}.exe`; + const targetDirectory = nodePath.dirname(targetPath); const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); const resolver = { resolveProcessId: sinon.stub().resolves(4321), @@ -479,7 +616,15 @@ suite('Dotnet Debugger Extension Tests', () => { executable: appHostPath, command: appHostPath, }), true); + assert.strictEqual(appHostIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: appHostExePath, + command: appHostExePath, + }), true); assert.ok(realpath.calledWithExactly(appHostPath)); + assert.ok(realpath.calledWithExactly(appHostExePath)); + assert.ok(realpath.calledWithExactly(targetDirectory)); }); test('normalizes Windows executable and command identities before matching', async () => { From 068bebed26b43193f435fda844b49c4a78f13c84 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 04:03:11 -0400 Subject: [PATCH 55/90] feat(extension): add resource debug telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../src/debugger/resourceDebugContracts.ts | 3 +- .../src/debugger/resourceDebugService.ts | 195 +++++++- .../debugger/resourceDebugSessionRegistry.ts | 56 ++- .../src/debugger/resourceDebugTelemetry.ts | 109 +++++ .../src/test/resourceDebugService.test.ts | 438 +++++++++++++++++- extension/src/test/telemetryInventory.test.ts | 10 +- extension/src/utils/telemetryRegistry.ts | 29 ++ extension/telemetry.json | 126 +++++ 8 files changed, 938 insertions(+), 28 deletions(-) create mode 100644 extension/src/debugger/resourceDebugTelemetry.ts diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index 176eaaa0e04..8eb0d0e4aa4 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -75,7 +75,8 @@ export type ResourceDebugErrorKind = | 'providerResolutionFailed' | 'configurationFailed' | 'debuggerStartDeclined' - | 'debuggerStartFailed'; + | 'debuggerStartFailed' + | 'unexpected'; export type ResourceDebugResult = | { readonly outcome: 'started'; readonly providerId: ResourceAttachProviderId } diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 0cc085abf31..db9690e1a99 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -14,6 +14,14 @@ import { } from './resourceDebugContracts'; import { ResourceAttachProviderRegistry } from './resourceAttachProviders'; import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; +import { + ExtensionResourceDebugTelemetry, + type ResourceDebugAttachSessionMetadata, + type ResourceDebugDebuggerRequirement, + type ResourceDebugResourceState, + type ResourceDebugResourceType, + type ResourceDebugTelemetry, +} from './resourceDebugTelemetry'; export interface ResourceDebugAppHostRepository { fetchRunningAppHostsOnce(cancellationToken?: vscode.CancellationToken): Promise; @@ -32,6 +40,7 @@ export interface ResourceDebugServiceDependencies { readonly sessionRegistry: ResourceDebugSessionRegistry; readonly startDebugging: ResourceDebugStartDebugging; readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; + readonly telemetry?: ResourceDebugTelemetry; } /** @@ -40,9 +49,11 @@ export interface ResourceDebugServiceDependencies { */ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger { private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; + private readonly _telemetry: ResourceDebugTelemetry; constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { this._compareAppHostIdentity = _dependencies.compareAppHostIdentity ?? compareAppHostIdentity; + this._telemetry = _dependencies.telemetry ?? new ExtensionResourceDebugTelemetry(); } dispose(): void { @@ -62,25 +73,47 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } async debug(request: ResourceDebugRequest): Promise { - if (request.cancellationToken?.isCancellationRequested) { - return { outcome: 'cancelled' }; - } + const telemetry = new ResourceDebugOperationTelemetry(this._telemetry, request.source); + telemetry.recordStart(); + let result: ResourceDebugResult = { outcome: 'error', errorKind: 'unexpected' }; + + try { + if (request.cancellationToken?.isCancellationRequested) { + result = { outcome: 'cancelled' }; + return result; + } - const resolvedAppHost = await this._resolveAppHost(request); - if ('outcome' in resolvedAppHost) { - return resolvedAppHost; + const resolvedAppHost = await this._resolveAppHost(request); + if ('outcome' in resolvedAppHost) { + result = resolvedAppHost; + return result; + } + + const resolvedTarget: ResourceDebugAppHostTarget = { + absolutePath: resolvedAppHost.appHostPath, + displayPath: request.appHost.displayPath, + }; + result = await this._dependencies.sessionRegistry.runSerialized( + resolvedTarget, + request.resourceName, + request.cancellationToken, + async () => await this._debugSerialized(request, resolvedTarget, telemetry), + () => ({ outcome: 'cancelled' })); + return result; } + catch (error) { + if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { + result = { outcome: 'cancelled' }; + return result; + } - const resolvedTarget: ResourceDebugAppHostTarget = { - absolutePath: resolvedAppHost.appHostPath, - displayPath: request.appHost.displayPath, - }; - return await this._dependencies.sessionRegistry.runSerialized( - resolvedTarget, - request.resourceName, - request.cancellationToken, - async () => await this._debugSerialized(request, resolvedTarget), - () => ({ outcome: 'cancelled' })); + this._logFailure('debugging the resource', error); + result = { outcome: 'error', errorKind: 'unexpected' }; + return result; + } + finally { + telemetry.recordResult(result); + } } private async _resolveAppHost(request: ResourceDebugRequest): Promise { @@ -122,6 +155,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger private async _debugSerialized( request: ResourceDebugRequest, resolvedTarget: ResourceDebugAppHostTarget, + telemetry: ResourceDebugOperationTelemetry, ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -152,6 +186,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } const resource = matchingResources[0]; + telemetry.recordResource(resource); let provider: ResourceAttachProvider | undefined; try { provider = this._dependencies.attachProviders.getRecognizedProviderForResource(resource); @@ -169,11 +204,12 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'unsupportedResource' }; } + telemetry.recordProvider(provider); if (resource.state !== 'Running') { return { outcome: 'resourceNotRunning' }; } - return await this._attach(request, resolvedTarget, resource, provider); + return await this._attach(request, resolvedTarget, resource, provider, telemetry); } private async _attach( @@ -181,6 +217,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger appHost: ResourceDebugAppHostTarget, resource: ResourceJson, provider: ResourceAttachProvider, + telemetry: ResourceDebugOperationTelemetry, ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -208,6 +245,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } if (missingDebuggerExtensions.length > 0) { + telemetry.recordDebuggerRequirement('missing'); return { outcome: 'debuggerExtensionMissing', debuggerExtensions: missingDebuggerExtensions.map(requirement => requirement.installMessage @@ -216,6 +254,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger }; } + telemetry.recordDebuggerRequirement('installed'); let configuration: vscode.DebugConfiguration; try { configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); @@ -237,8 +276,13 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'cancelled' }; } - const attempt = this._dependencies.sessionRegistry.createAttempt(appHost, resource.name, configuration); + const attempt = this._dependencies.sessionRegistry.createAttempt( + appHost, + resource.name, + configuration, + telemetry.createSessionMetadata(provider.id)); try { + telemetry.recordDebugStart(); const started = await this._dependencies.startDebugging(undefined, attempt.configuration); if (!started) { attempt.abandon(); @@ -263,3 +307,118 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger extensionLogOutputChannel.error(`Resource debugger failed while ${operation}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); } } + +class ResourceDebugOperationTelemetry { + private readonly _startedAt: number; + private _resourceType: ResourceDebugResourceType | undefined; + private _provider: ResourceAttachProvider['id'] | 'none' = 'none'; + private _state: ResourceDebugResourceState = 'unknown'; + private _debuggerRequirement: ResourceDebugDebuggerRequirement = 'none'; + private _debugStartAt: number | undefined; + + constructor( + private readonly _telemetry: ResourceDebugTelemetry, + private readonly _source: ResourceDebugRequest['source'], + ) { + this._startedAt = this._getTimestamp(); + } + + recordStart(): void { + this._record(() => this._telemetry.recordStart({ + source: this._source, + requested_strategy: 'attach', + controller: 'editor', + })); + } + + recordResource(resource: ResourceJson): void { + this._resourceType = getResourceTypeBucket(resource.resourceType); + this._state = resource.state === 'Running' + ? 'running' + : resource.state === null + ? 'unknown' + : 'notRunning'; + } + + recordProvider(provider: ResourceAttachProvider): void { + this._provider = provider.id; + } + + recordDebuggerRequirement(requirement: ResourceDebugDebuggerRequirement): void { + this._debuggerRequirement = requirement; + } + + recordDebugStart(): void { + this._debugStartAt = this._getTimestamp(); + } + + createSessionMetadata(provider: ResourceAttachProvider['id']): ResourceDebugAttachSessionMetadata { + return { + source: this._source, + provider, + resource_type: this._resourceType ?? 'other', + }; + } + + recordResult(result: ResourceDebugResult): void { + const endedAt = this._getTimestamp(); + const resolutionEndedAt = this._debugStartAt ?? endedAt; + this._record(() => this._telemetry.recordResult({ + source: this._source, + provider: this._provider, + ...(this._resourceType === undefined ? {} : { resource_type: this._resourceType }), + requested_strategy: 'attach', + effective_strategy: result.outcome === 'started' || result.outcome === 'alreadyDebugging' + ? 'attach' + : 'none', + outcome: result.outcome, + controller: 'editor', + state: this._state, + debugger_requirement: this._debuggerRequirement, + error_kind: result.outcome === 'error' ? result.errorKind : 'none', + }, { + resolution_duration_ms: this._getDuration(this._startedAt, resolutionEndedAt), + debug_start_duration_ms: this._debugStartAt === undefined + ? 0 + : this._getDuration(this._debugStartAt, endedAt), + total_duration_ms: this._getDuration(this._startedAt, endedAt), + })); + } + + private _getTimestamp(): number { + try { + const timestamp = this._telemetry.now(); + return Number.isFinite(timestamp) ? timestamp : 0; + } + catch { + return 0; + } + } + + private _getDuration(start: number, end: number): number { + const duration = end - start; + return Number.isFinite(duration) && duration >= 0 ? duration : 0; + } + + private _record(record: () => void): void { + try { + record(); + } + catch { + // Telemetry is observational. A telemetry sink must not change debug behavior. + } + } +} + +function getResourceTypeBucket(resourceType: string): ResourceDebugResourceType { + switch (resourceType.toLowerCase()) { + case 'project': + return 'project'; + case 'executable': + return 'executable'; + case 'container': + return 'container'; + default: + return 'other'; + } +} diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 3830a5a10c6..d2c80e013b6 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -2,6 +2,11 @@ import * as vscode from 'vscode'; import { getAppHostIdentityKey } from '../utils/appHostIdentity'; import { extensionLogOutputChannel } from '../utils/logging'; import type { ResourceDebugAppHostTarget } from './resourceDebugContracts'; +import { + ExtensionResourceDebugTelemetry, + type ResourceDebugAttachSessionMetadata, + type ResourceDebugTelemetry, +} from './resourceDebugTelemetry'; const resourceDebugSessionMarkerConfigKey = '__aspireResourceDebugSessionMarker'; @@ -18,6 +23,7 @@ export interface ResourceDebugSessionAttempt { export interface ResourceDebugSessionRegistryOptions { readonly pendingStartTimeoutMs?: number; + readonly telemetry?: ResourceDebugTelemetry; } interface TrackedAttachAttempt { @@ -27,6 +33,8 @@ interface TrackedAttachAttempt { pendingStartTimeout: ReturnType | undefined; startAccepted: boolean; terminated: boolean; + sessionStartedAt: number | undefined; + readonly telemetry: ResourceDebugAttachSessionMetadata; } /** @@ -42,10 +50,12 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { private readonly _resourceLocks = new Map>(); private readonly _subscriptions: vscode.Disposable; private readonly _pendingStartTimeoutMs: number; + private readonly _telemetry: ResourceDebugTelemetry; private _nextMarker = 0; constructor(events: ResourceDebugSessionEvents = vscode.debug, options: ResourceDebugSessionRegistryOptions = {}) { this._pendingStartTimeoutMs = options.pendingStartTimeoutMs ?? ResourceDebugSessionRegistry._defaultPendingStartTimeoutMs; + this._telemetry = options.telemetry ?? new ExtensionResourceDebugTelemetry(); this._subscriptions = vscode.Disposable.from( events.onDidStartDebugSession(session => this._onDidStartDebugSession(session)), events.onDidTerminateDebugSession(session => this._onDidTerminateDebugSession(session))); @@ -112,7 +122,12 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } } - createAttempt(appHost: ResourceDebugAppHostTarget, resourceName: string, configuration: vscode.DebugConfiguration): ResourceDebugSessionAttempt { + createAttempt( + appHost: ResourceDebugAppHostTarget, + resourceName: string, + configuration: vscode.DebugConfiguration, + telemetry: ResourceDebugAttachSessionMetadata, + ): ResourceDebugSessionAttempt { const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); const marker = ++this._nextMarker; const attempt: TrackedAttachAttempt = { @@ -122,6 +137,8 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { pendingStartTimeout: undefined, startAccepted: false, terminated: false, + sessionStartedAt: undefined, + telemetry, }; this._attempts.set(marker, attempt); const attemptMarkers = this._attemptsByResource.get(resourceKey) ?? new Set(); @@ -154,6 +171,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.sessionIds.add(session.id); + attempt.sessionStartedAt ??= this._getTimestamp(); this._clearPendingStartExpiry(attempt); } @@ -169,6 +187,18 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.terminated = true; + const sessionStartedAt = attempt.sessionStartedAt; + if (sessionStartedAt !== undefined) { + this._recordTelemetry(() => this._telemetry.recordSessionEnd({ + ...attempt.telemetry, + requested_strategy: 'attach', + effective_strategy: 'attach', + controller: 'editor', + session_end_reason: 'terminated', + }, { + session_duration_ms: this._getDuration(sessionStartedAt, this._getTimestamp()), + })); + } this._removeAttempt(attempt); } @@ -209,6 +239,30 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } } + private _getTimestamp(): number { + try { + const timestamp = this._telemetry.now(); + return Number.isFinite(timestamp) ? timestamp : 0; + } + catch { + return 0; + } + } + + private _getDuration(start: number, end: number): number { + const duration = end - start; + return Number.isFinite(duration) && duration >= 0 ? duration : 0; + } + + private _recordTelemetry(record: () => void): void { + try { + record(); + } + catch { + // Telemetry is observational. Debug session lifecycle tracking must continue if it fails. + } + } + private async _waitForLock( precedingOperation: Promise | undefined, cancellationToken: vscode.CancellationToken | undefined, diff --git a/extension/src/debugger/resourceDebugTelemetry.ts b/extension/src/debugger/resourceDebugTelemetry.ts new file mode 100644 index 00000000000..d2075359b0c --- /dev/null +++ b/extension/src/debugger/resourceDebugTelemetry.ts @@ -0,0 +1,109 @@ +import { type ResourceAttachProviderId, type ResourceDebugErrorKind, type ResourceDebugResult, type ResourceDebugSource } from './resourceDebugContracts'; +import { sendTelemetryEvent } from '../utils/telemetry'; + +export type ResourceDebugResourceType = 'project' | 'executable' | 'container' | 'other'; +export type ResourceDebugResourceState = 'running' | 'notRunning' | 'unknown'; +export type ResourceDebugDebuggerRequirement = 'installed' | 'missing' | 'none'; + +export interface ResourceDebugClock { + now(): number; +} + +export interface ResourceDebugStartTelemetryProperties { + readonly source: ResourceDebugSource; + readonly requested_strategy: 'attach'; + readonly controller: 'editor'; +} + +export interface ResourceDebugResultTelemetryProperties { + readonly source: ResourceDebugSource; + readonly provider: ResourceAttachProviderId | 'none'; + readonly resource_type?: ResourceDebugResourceType; + readonly requested_strategy: 'attach'; + readonly effective_strategy: 'attach' | 'none'; + readonly outcome: ResourceDebugResult['outcome']; + readonly controller: 'editor'; + readonly state: ResourceDebugResourceState; + readonly debugger_requirement: ResourceDebugDebuggerRequirement; + readonly error_kind: ResourceDebugErrorKind | 'none'; +} + +export interface ResourceDebugResultTelemetryMeasurements { + readonly resolution_duration_ms: number; + readonly debug_start_duration_ms: number; + readonly total_duration_ms: number; +} + +export interface ResourceDebugAttachSessionMetadata { + readonly source: ResourceDebugSource; + readonly provider: ResourceAttachProviderId; + readonly resource_type: ResourceDebugResourceType; +} + +export interface ResourceDebugSessionEndTelemetryProperties extends ResourceDebugAttachSessionMetadata { + readonly requested_strategy: 'attach'; + readonly effective_strategy: 'attach'; + readonly controller: 'editor'; + readonly session_end_reason: 'terminated'; +} + +export interface ResourceDebugSessionEndTelemetryMeasurements { + readonly session_duration_ms: number; +} + +export interface ResourceDebugTelemetry { + now(): number; + recordStart(properties: ResourceDebugStartTelemetryProperties): void; + recordResult( + properties: ResourceDebugResultTelemetryProperties, + measurements: ResourceDebugResultTelemetryMeasurements, + ): void; + recordSessionEnd( + properties: ResourceDebugSessionEndTelemetryProperties, + measurements: ResourceDebugSessionEndTelemetryMeasurements, + ): void; +} + +const systemClock: ResourceDebugClock = { + now: () => Date.now(), +}; + +/** + * Sends only the resource-debug telemetry schema. Keeping the event shapes here means the + * service and session registry cannot accidentally forward debug configurations or errors. + */ +export class ExtensionResourceDebugTelemetry implements ResourceDebugTelemetry { + constructor(private readonly _clock: ResourceDebugClock = systemClock) { + } + + now(): number { + return this._clock.now(); + } + + recordStart(properties: ResourceDebugStartTelemetryProperties): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/start', properties)); + } + + recordResult( + properties: ResourceDebugResultTelemetryProperties, + measurements: ResourceDebugResultTelemetryMeasurements, + ): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/result', properties, measurements)); + } + + recordSessionEnd( + properties: ResourceDebugSessionEndTelemetryProperties, + measurements: ResourceDebugSessionEndTelemetryMeasurements, + ): void { + this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/session/end', properties, measurements)); + } + + private _send(send: () => void): void { + try { + send(); + } + catch { + // Telemetry is observational. A transport failure must not change resource debugging. + } + } +} diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 7062b0a47e8..7bd9693c057 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -5,9 +5,9 @@ import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataReposi import { createProjectResourceAttachProvider, projectDebuggerExtension, projectResourceAttachProvider } from '../debugger/languages/dotnet'; import { createGoResourceAttachProvider } from '../debugger/languages/go'; import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProviders'; -import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService } from '../debugger/resourceDebugService'; -import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry } from '../debugger/resourceDebugSessionRegistry'; -import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugRequest, type ResourceDebugResourceSnapshot } from '../debugger/resourceDebugContracts'; +import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService, ResourceDebugServiceDependencies } from '../debugger/resourceDebugService'; +import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry, ResourceDebugSessionRegistryOptions } from '../debugger/resourceDebugSessionRegistry'; +import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugRequest, type ResourceDebugResourceSnapshot, type ResourceDebugResult } from '../debugger/resourceDebugContracts'; import { extensionLogOutputChannel } from '../utils/logging'; const target: ResourceDebugAppHostTarget = { @@ -68,6 +68,37 @@ function createRequest(overrides: Partial = {}): ResourceD }; } +interface RecordedResourceDebugTelemetryEvent { + readonly name: string; + readonly properties: Record; + readonly measurements: Record | undefined; +} + +class TestResourceDebugTelemetry { + public readonly events: RecordedResourceDebugTelemetryEvent[] = []; + public currentTime = 0; + + now(): number { + return this.currentTime; + } + + recordStart(properties: Record): void { + this._record('aspire/vscode/resourceDebug/start', properties); + } + + recordResult(properties: Record, measurements: Record): void { + this._record('aspire/vscode/resourceDebug/result', properties, measurements); + } + + recordSessionEnd(properties: Record, measurements: Record): void { + this._record('aspire/vscode/resourceDebug/session/end', properties, measurements); + } + + private _record(name: string, properties: Record, measurements?: Record): void { + this.events.push({ name, properties, measurements }); + } +} + function createProvider(overrides: Partial = {}): ResourceAttachProvider { return { id: 'dotnet', @@ -89,6 +120,7 @@ function createProvider(overrides: Partial = {}): Resour class TestDebugSessionEvents implements ResourceDebugSessionEvents { private _startListener: ((session: vscode.DebugSession) => void) | undefined; private _terminateListener: ((session: vscode.DebugSession) => void) | undefined; + public startedConfiguration: vscode.DebugConfiguration | undefined; onDidStartDebugSession(listener: (session: vscode.DebugSession) => void): vscode.Disposable { this._startListener = listener; @@ -105,6 +137,7 @@ class TestDebugSessionEvents implements ResourceDebugSessionEvents { } start(configuration: vscode.DebugConfiguration): void { + this.startedConfiguration = configuration; this._startListener?.({ id: 'resource-attach-session', configuration, @@ -126,11 +159,14 @@ function createService(options: { isExtensionInstalled?: (extensionId: string) => boolean; startDebugging?: (folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; + telemetry?: TestResourceDebugTelemetry; + pendingStartTimeoutMs?: number; } = {}): { service: ResourceDebugService; repository: ResourceDebugAppHostRepository; sessions: ResourceDebugSessionRegistry; events: TestDebugSessionEvents; + telemetry: TestResourceDebugTelemetry; } { const repository: ResourceDebugAppHostRepository = { fetchRunningAppHostsOnce: async () => options.appHosts ?? [createAppHost()], @@ -138,7 +174,11 @@ function createService(options: { (options.appHosts ?? [createAppHost()]).find(appHost => appHost.appHostPath === appHostPath)?.resources ?? [], }; const events = new TestDebugSessionEvents(); - const sessions = new ResourceDebugSessionRegistry(events); + const telemetry = options.telemetry ?? new TestResourceDebugTelemetry(); + const sessions = new ResourceDebugSessionRegistry(events, { + pendingStartTimeoutMs: options.pendingStartTimeoutMs, + telemetry, + } as unknown as ResourceDebugSessionRegistryOptions); const providers = new ResourceAttachProviderRegistry( options.providers ?? [options.provider ?? createProvider()], options.isExtensionInstalled ?? (() => true)); @@ -148,9 +188,10 @@ function createService(options: { sessionRegistry: sessions, startDebugging: options.startDebugging ?? (async () => true), compareAppHostIdentity: options.compareAppHostIdentity, - }); + telemetry, + } as unknown as ResourceDebugServiceDependencies); - return { service, repository, sessions, events }; + return { service, repository, sessions, events, telemetry }; } suite('Resource debug service', () => { @@ -829,6 +870,10 @@ suite('Resource debug service', () => { type: 'coreclr', request: 'attach', name: 'Attach debugger: API', + }, { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', }); try { @@ -971,4 +1016,385 @@ suite('Resource debug service', () => { assert.strictEqual(sessions.hasActiveSession(target, 'api'), false); sessions.dispose(); }); + + test('emits a bounded start and success result with deterministic durations', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 100; + const { service, sessions } = createService({ + telemetry, + provider: createProvider({ + createDebugConfiguration: async () => { + telemetry.currentTime = 105; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging: async () => { + telemetry.currentTime = 108; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(telemetry.events, [ + { + name: 'aspire/vscode/resourceDebug/start', + properties: { + source: 'tree', + requested_strategy: 'attach', + controller: 'editor', + }, + measurements: undefined, + }, + { + name: 'aspire/vscode/resourceDebug/result', + properties: { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + outcome: 'started', + controller: 'editor', + state: 'running', + debugger_requirement: 'installed', + error_kind: 'none', + }, + measurements: { + resolution_duration_ms: 5, + debug_start_duration_ms: 3, + total_duration_ms: 8, + }, + }, + ]); + } + finally { + sessions.dispose(); + } + }); + + test('emits exactly one bounded result for every resource debug outcome', async () => { + const run = async ( + create: () => { + service: ResourceDebugService; + sessions: ResourceDebugSessionRegistry; + telemetry: TestResourceDebugTelemetry; + }, + expectedOutcome: ResourceDebugResult['outcome'], + expectedErrorKind = 'none', + ) => { + const { service, sessions, telemetry } = create(); + try { + const result = await service.debug(createRequest()); + assert.strictEqual(result.outcome, expectedOutcome); + + const startEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start'); + const resultEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result'); + assert.strictEqual(startEvents.length, 1); + assert.strictEqual(resultEvents.length, 1); + assert.strictEqual(resultEvents[0].properties.outcome, expectedOutcome); + assert.strictEqual(resultEvents[0].properties.error_kind, expectedErrorKind); + } + finally { + sessions.dispose(); + } + }; + + const cancelled = new vscode.CancellationTokenSource(); + cancelled.cancel(); + await run( + () => createService(), + 'started'); + await run( + () => createService({ + appHosts: [], + }), + 'appHostNotFound'); + await run( + () => createService({ + appHosts: [createAppHost({ resources: [] })], + }), + 'resourceNotFound'); + await run( + () => createService({ + provider: createProvider({ canAttachToResource: () => false }), + }), + 'unsupportedResource'); + await run( + () => createService({ + appHosts: [createAppHost({ resources: [createResource({ state: 'Finished' })] })], + }), + 'resourceNotRunning'); + await run( + () => createService({ + isExtensionInstalled: () => false, + }), + 'debuggerExtensionMissing'); + await run( + () => createService({ + provider: createProvider({ + createDebugConfiguration: async () => { + throw new Error('raw configuration error'); + }, + }), + }), + 'error', + 'configurationFailed'); + await run( + () => createService({ + startDebugging: async () => false, + }), + 'error', + 'debuggerStartDeclined'); + await run( + () => createService({ + startDebugging: async () => { + throw new Error('raw debugger failure'); + }, + }), + 'error', + 'debuggerStartFailed'); + await run( + () => { + const fixture = createService(); + fixture.repository.fetchRunningAppHostsOnce = async () => { + throw new Error('raw AppHost snapshot failure'); + }; + return fixture; + }, + 'error', + 'resourceSnapshotFailed'); + await run( + () => createService({ + provider: createProvider({ + canRecognizeResource: () => { + throw new Error('raw provider resolution failure'); + }, + }), + }), + 'error', + 'providerResolutionFailed'); + await run( + () => createService({ + compareAppHostIdentity: () => { + throw new Error('raw unexpected comparison failure'); + }, + }), + 'error', + 'unexpected'); + + const cancelledFixture = createService(); + try { + const result = await cancelledFixture.service.debug(createRequest({ cancellationToken: cancelled.token })); + assert.deepStrictEqual(result, { outcome: 'cancelled' }); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start').length, 1); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result').length, 1); + assert.strictEqual( + cancelledFixture.telemetry.events.find(event => event.name === 'aspire/vscode/resourceDebug/result')?.properties.error_kind, + 'none'); + } + finally { + cancelled.dispose(); + cancelledFixture.sessions.dispose(); + } + + const duplicate = createService(); + try { + assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + const resultEvents = duplicate.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result'); + assert.strictEqual(resultEvents.length, 2); + assert.deepStrictEqual(resultEvents.map(event => event.properties.outcome), ['started', 'alreadyDebugging']); + } + finally { + duplicate.sessions.dispose(); + } + }); + + test('emits an exact private-data-free result payload', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 50; + const secrets = [ + '/Users/example/Private Workspace/Secret AppHost.csproj', + 'Secret AppHost.csproj', + 'private-resource-name', + 'Private Resource Display Name', + '54321', + 'session-secret-marker', + '/opt/private/bin/secret-process --api-key very-secret', + 'https://private.example.test/dashboard?token=very-secret', + 'PRIVATE_ENVIRONMENT_VARIABLE', + '--private-argument', + 'private-property-value', + 'private.debugger.extension', + 'raw configuration error with stack trace', + ]; + const privateTarget: ResourceDebugAppHostTarget = { + absolutePath: secrets[0], + displayPath: secrets[1], + }; + const { service, sessions } = createService({ + telemetry, + appHosts: [createAppHost({ + appHostPath: privateTarget.absolutePath, + appHostPid: Number(secrets[4]), + dashboardUrl: secrets[7], + resources: [createResource({ + name: secrets[2], + displayName: secrets[3], + resourceType: 'Container', + properties: { + pid: secrets[4], + marker: secrets[5], + executable: secrets[6], + url: secrets[7], + environment: secrets[8], + args: secrets[9], + property: secrets[10], + }, + })], + })], + provider: createProvider({ + requiredDebuggerExtensions: [{ id: secrets[11], label: secrets[11] }], + createDebugConfiguration: async () => { + telemetry.currentTime = 70; + throw new Error(secrets[12]); + }, + }), + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest({ + source: 'languageModelTool', + appHost: privateTarget, + resourceName: secrets[2], + })), { + outcome: 'error', + errorKind: 'configurationFailed', + }); + + const serializedEvents = JSON.stringify(telemetry.events); + assert.deepStrictEqual(telemetry.events, [ + { + name: 'aspire/vscode/resourceDebug/start', + properties: { + source: 'languageModelTool', + requested_strategy: 'attach', + controller: 'editor', + }, + measurements: undefined, + }, + { + name: 'aspire/vscode/resourceDebug/result', + properties: { + source: 'languageModelTool', + provider: 'dotnet', + resource_type: 'container', + requested_strategy: 'attach', + effective_strategy: 'none', + outcome: 'error', + controller: 'editor', + state: 'running', + debugger_requirement: 'installed', + error_kind: 'configurationFailed', + }, + measurements: { + resolution_duration_ms: 20, + debug_start_duration_ms: 0, + total_duration_ms: 20, + }, + }, + ]); + assert.strictEqual(secrets.every(secret => !serializedEvents.includes(secret)), true); + } + finally { + sessions.dispose(); + } + }); + + test('emits one session-end only after a correlated attach session starts and terminates', async () => { + const telemetry = new TestResourceDebugTelemetry(); + telemetry.currentTime = 100; + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + provider: createProvider({ + createDebugConfiguration: async () => { + telemetry.currentTime = 110; + return { type: 'coreclr', request: 'attach', name: 'Attach debugger: API' }; + }, + }), + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + telemetry.currentTime = 115; + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + telemetry.currentTime = 140; + assert.ok(events.startedConfiguration); + events.terminate(events.startedConfiguration); + + assert.deepStrictEqual(telemetry.events.at(-1), { + name: 'aspire/vscode/resourceDebug/session/end', + properties: { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + controller: 'editor', + session_end_reason: 'terminated', + }, + measurements: { + session_duration_ms: 30, + }, + }); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/session/end').length, 1); + } + finally { + fixture.sessions.dispose(); + } + }); + + test('does not emit a session-end for a pending attach that expires before a session starts', async () => { + const clock = sinon.useFakeTimers(); + const telemetry = new TestResourceDebugTelemetry(); + const fixture = createService({ + telemetry, + pendingStartTimeoutMs: 10, + }); + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + await clock.tickAsync(10); + + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/session/end').length, 0); + } + finally { + fixture.sessions.dispose(); + clock.restore(); + } + }); + + test('ignores telemetry sink failures when debugging a resource', async () => { + const telemetry = new TestResourceDebugTelemetry(); + sinon.stub(telemetry, 'recordStart').throws(new Error('raw telemetry start failure')); + sinon.stub(telemetry, 'recordResult').throws(new Error('raw telemetry result failure')); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual((telemetry.recordStart as sinon.SinonStub).callCount, 1); + assert.strictEqual((telemetry.recordResult as sinon.SinonStub).callCount, 1); + } + finally { + sessions.dispose(); + } + }); }); diff --git a/extension/src/test/telemetryInventory.test.ts b/extension/src/test/telemetryInventory.test.ts index 1f829eb7649..beb3614dbd1 100644 --- a/extension/src/test/telemetryInventory.test.ts +++ b/extension/src/test/telemetryInventory.test.ts @@ -19,6 +19,11 @@ type TelemetryRegistryEvent = { // Code's automatic `/` prefix after the TelemetryLogger has // applied its platform guarantees. const telemetryEntityPrefix = ''; +const caseSensitiveWireEventNames = [ + 'aspire/vscode/resourceDebug/start', + 'aspire/vscode/resourceDebug/result', + 'aspire/vscode/resourceDebug/session/end', +] as const; const freeformPropertyNamePattern = /(?:^|_)(?:path|message|description|args?)(?:_|$)/i; const platformCommonTelemetryProperties = [ 'common.devDeviceId', @@ -129,9 +134,10 @@ function getStringLiteralUnion(typeNode: ts.TypeNode): string[] { } suite('extension/telemetry.json', () => { - test('event entity names are lowercase to match VS Code telemetry ingestion', () => { + test('event entity names are lowercase except approved case-sensitive wire names', () => { const inventory = readTelemetryInventory(); - const mixedCaseEntityNames = Object.keys(inventory.events).filter(name => name !== name.toLowerCase()); + const mixedCaseEntityNames = Object.keys(inventory.events) + .filter(name => name !== name.toLowerCase() && !caseSensitiveWireEventNames.includes(name as typeof caseSensitiveWireEventNames[number])); assert.deepStrictEqual(mixedCaseEntityNames, []); }); diff --git a/extension/src/utils/telemetryRegistry.ts b/extension/src/utils/telemetryRegistry.ts index e51dd6683d1..5718c4ac91c 100644 --- a/extension/src/utils/telemetryRegistry.ts +++ b/extension/src/utils/telemetryRegistry.ts @@ -106,6 +106,35 @@ export interface TelemetryEventSchema { properties: 'resource_type' | 'mode' | 'exit_code_bucket' | 'end_reason' | 'error_kind'; measurements: 'duration_ms' | 'exit_code'; }; + 'aspire/vscode/resourceDebug/start': { + properties: 'source' | 'requested_strategy' | 'controller'; + measurements: never; + }; + 'aspire/vscode/resourceDebug/result': { + properties: + | 'source' + | 'provider' + | 'resource_type' + | 'requested_strategy' + | 'effective_strategy' + | 'outcome' + | 'controller' + | 'state' + | 'debugger_requirement' + | 'error_kind'; + measurements: 'resolution_duration_ms' | 'debug_start_duration_ms' | 'total_duration_ms'; + }; + 'aspire/vscode/resourceDebug/session/end': { + properties: + | 'source' + | 'provider' + | 'resource_type' + | 'requested_strategy' + | 'effective_strategy' + | 'controller' + | 'session_end_reason'; + measurements: 'session_duration_ms'; + }; 'aspire/vscode/dashboard/launch/resolved': { properties: 'behavior' | 'source'; measurements: never; diff --git a/extension/telemetry.json b/extension/telemetry.json index c3d359d2f96..31d0cb905ed 100644 --- a/extension/telemetry.json +++ b/extension/telemetry.json @@ -307,6 +307,132 @@ "comment": "The numeric process exit code for the resource debug session." } }, + "aspire/vscode/resourceDebug/start": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: attach." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + } + }, + "aspire/vscode/resourceDebug/result": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "provider": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded attach provider selected for the resource: dotnet, go, or none." + }, + "resource_type": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource type bucket: project, executable, container, or other." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: attach." + }, + "effective_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "Whether a bounded attach strategy was effective, or no strategy was used." + }, + "outcome": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded ResourceDebugResult outcome." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + }, + "state": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource state: running, notRunning, or unknown." + }, + "debugger_requirement": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "Whether the selected provider debugger requirement was installed, missing, or not applicable." + }, + "error_kind": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded ResourceDebugErrorKind for an error result, or none." + }, + "resolution_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of resource and attach resolution in milliseconds." + }, + "debug_start_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of the VS Code debugger start request in milliseconds." + }, + "total_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative total resource debug operation duration in milliseconds." + } + }, + "aspire/vscode/resourceDebug/session/end": { + "source": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded extension surface that requested resource debugging: tree or languageModelTool." + }, + "provider": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded attach provider selected for the resource: dotnet or go." + }, + "resource_type": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The coarse resource type bucket: project, executable, container, or other." + }, + "requested_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded resource debug strategy requested by the caller: attach." + }, + "effective_strategy": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded effective resource debug strategy: attach." + }, + "controller": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "comment": "The bounded controller responsible for resource debugging: editor." + }, + "session_end_reason": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The bounded reason the tracked independent attach session ended: terminated." + }, + "session_duration_ms": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth", + "comment": "The non-negative duration of the tracked independent attach session in milliseconds." + } + }, "aspire/vscode/dashboard/launch/resolved": { "behavior": { "classification": "SystemMetaData", From 6da38327d6c75b2931938fd01446040ebd965e36 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 04:50:43 -0400 Subject: [PATCH 56/90] fix(extension): harden resource debug telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../src/debugger/resourceDebugService.ts | 86 ++++--- .../debugger/resourceDebugSessionRegistry.ts | 36 ++- .../src/debugger/resourceDebugTelemetry.ts | 26 +- extension/src/extension.ts | 11 +- .../src/test/resourceDebugService.test.ts | 225 ++++++++++++++++-- extension/src/test/telemetryInventory.test.ts | 9 +- extension/src/utils/telemetryRegistry.ts | 6 +- extension/telemetry.json | 6 +- 8 files changed, 314 insertions(+), 91 deletions(-) diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index db9690e1a99..478c9a840be 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -17,10 +17,13 @@ import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; import { ExtensionResourceDebugTelemetry, type ResourceDebugAttachSessionMetadata, + type ResourceDebugClock, type ResourceDebugDebuggerRequirement, type ResourceDebugResourceState, type ResourceDebugResourceType, + type ResourceDebugResultTelemetryMeasurements, type ResourceDebugTelemetry, + monotonicResourceDebugClock, } from './resourceDebugTelemetry'; export interface ResourceDebugAppHostRepository { @@ -41,6 +44,7 @@ export interface ResourceDebugServiceDependencies { readonly startDebugging: ResourceDebugStartDebugging; readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; readonly telemetry?: ResourceDebugTelemetry; + readonly clock?: ResourceDebugClock; } /** @@ -50,10 +54,12 @@ export interface ResourceDebugServiceDependencies { export class ResourceDebugService implements vscode.Disposable, ResourceDebugger { private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; private readonly _telemetry: ResourceDebugTelemetry; + private readonly _clock: ResourceDebugClock; constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { this._compareAppHostIdentity = _dependencies.compareAppHostIdentity ?? compareAppHostIdentity; this._telemetry = _dependencies.telemetry ?? new ExtensionResourceDebugTelemetry(); + this._clock = _dependencies.clock ?? monotonicResourceDebugClock; } dispose(): void { @@ -73,7 +79,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } async debug(request: ResourceDebugRequest): Promise { - const telemetry = new ResourceDebugOperationTelemetry(this._telemetry, request.source); + const telemetry = new ResourceDebugOperationTelemetry(this._telemetry, this._clock, request.source); telemetry.recordStart(); let result: ResourceDebugResult = { outcome: 'error', errorKind: 'unexpected' }; @@ -309,15 +315,17 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } class ResourceDebugOperationTelemetry { - private readonly _startedAt: number; + private readonly _startedAt: number | undefined; private _resourceType: ResourceDebugResourceType | undefined; private _provider: ResourceAttachProvider['id'] | 'none' = 'none'; private _state: ResourceDebugResourceState = 'unknown'; private _debuggerRequirement: ResourceDebugDebuggerRequirement = 'none'; private _debugStartAt: number | undefined; + private _debugStartAttempted = false; constructor( private readonly _telemetry: ResourceDebugTelemetry, + private readonly _clock: ResourceDebugClock, private readonly _source: ResourceDebugRequest['source'], ) { this._startedAt = this._getTimestamp(); @@ -332,24 +340,33 @@ class ResourceDebugOperationTelemetry { } recordResource(resource: ResourceJson): void { - this._resourceType = getResourceTypeBucket(resource.resourceType); - this._state = resource.state === 'Running' - ? 'running' - : resource.state === null - ? 'unknown' - : 'notRunning'; + this._record(() => { + this._resourceType = getResourceTypeBucket(resource.resourceType); + this._state = resource.state === 'Running' + ? 'running' + : resource.state === null + ? 'unknown' + : 'notRunning'; + }); } recordProvider(provider: ResourceAttachProvider): void { - this._provider = provider.id; + this._record(() => { + this._provider = provider.id; + }); } recordDebuggerRequirement(requirement: ResourceDebugDebuggerRequirement): void { - this._debuggerRequirement = requirement; + this._record(() => { + this._debuggerRequirement = requirement; + }); } recordDebugStart(): void { - this._debugStartAt = this._getTimestamp(); + this._record(() => { + this._debugStartAttempted = true; + this._debugStartAt = this._getTimestamp(); + }); } createSessionMetadata(provider: ResourceAttachProvider['id']): ResourceDebugAttachSessionMetadata { @@ -361,8 +378,6 @@ class ResourceDebugOperationTelemetry { } recordResult(result: ResourceDebugResult): void { - const endedAt = this._getTimestamp(); - const resolutionEndedAt = this._debugStartAt ?? endedAt; this._record(() => this._telemetry.recordResult({ source: this._source, provider: this._provider, @@ -376,28 +391,43 @@ class ResourceDebugOperationTelemetry { state: this._state, debugger_requirement: this._debuggerRequirement, error_kind: result.outcome === 'error' ? result.errorKind : 'none', - }, { - resolution_duration_ms: this._getDuration(this._startedAt, resolutionEndedAt), - debug_start_duration_ms: this._debugStartAt === undefined - ? 0 - : this._getDuration(this._debugStartAt, endedAt), - total_duration_ms: this._getDuration(this._startedAt, endedAt), - })); + }, this._getMeasurements())); + } + + private _getMeasurements(): ResourceDebugResultTelemetryMeasurements { + const endedAt = this._getTimestamp(); + const resolutionDuration = this._getDuration( + this._startedAt, + this._debugStartAttempted ? this._debugStartAt : endedAt); + const debugStartDuration = this._debugStartAttempted + ? this._getDuration(this._debugStartAt, endedAt) + : undefined; + const totalDuration = this._getDuration(this._startedAt, endedAt); + + return { + ...(resolutionDuration === undefined ? {} : { resolution_duration_ms: resolutionDuration }), + ...(debugStartDuration === undefined ? {} : { debug_start_duration_ms: debugStartDuration }), + ...(totalDuration === undefined ? {} : { total_duration_ms: totalDuration }), + }; } - private _getTimestamp(): number { + private _getTimestamp(): number | undefined { try { - const timestamp = this._telemetry.now(); - return Number.isFinite(timestamp) ? timestamp : 0; + const timestamp = this._clock.now(); + return Number.isFinite(timestamp) ? timestamp : undefined; } catch { - return 0; + return undefined; } } - private _getDuration(start: number, end: number): number { + private _getDuration(start: number | undefined, end: number | undefined): number | undefined { + if (start === undefined || end === undefined) { + return undefined; + } + const duration = end - start; - return Number.isFinite(duration) && duration >= 0 ? duration : 0; + return Number.isFinite(duration) && duration >= 0 ? duration : undefined; } private _record(record: () => void): void { @@ -410,8 +440,8 @@ class ResourceDebugOperationTelemetry { } } -function getResourceTypeBucket(resourceType: string): ResourceDebugResourceType { - switch (resourceType.toLowerCase()) { +function getResourceTypeBucket(resourceType: unknown): ResourceDebugResourceType { + switch (typeof resourceType === 'string' ? resourceType.toLowerCase() : '') { case 'project': return 'project'; case 'executable': diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index d2c80e013b6..9bb55876e21 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -5,7 +5,9 @@ import type { ResourceDebugAppHostTarget } from './resourceDebugContracts'; import { ExtensionResourceDebugTelemetry, type ResourceDebugAttachSessionMetadata, + type ResourceDebugClock, type ResourceDebugTelemetry, + monotonicResourceDebugClock, } from './resourceDebugTelemetry'; const resourceDebugSessionMarkerConfigKey = '__aspireResourceDebugSessionMarker'; @@ -24,6 +26,7 @@ export interface ResourceDebugSessionAttempt { export interface ResourceDebugSessionRegistryOptions { readonly pendingStartTimeoutMs?: number; readonly telemetry?: ResourceDebugTelemetry; + readonly clock?: ResourceDebugClock; } interface TrackedAttachAttempt { @@ -33,6 +36,7 @@ interface TrackedAttachAttempt { pendingStartTimeout: ReturnType | undefined; startAccepted: boolean; terminated: boolean; + sessionStarted: boolean; sessionStartedAt: number | undefined; readonly telemetry: ResourceDebugAttachSessionMetadata; } @@ -51,11 +55,13 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { private readonly _subscriptions: vscode.Disposable; private readonly _pendingStartTimeoutMs: number; private readonly _telemetry: ResourceDebugTelemetry; + private readonly _clock: ResourceDebugClock; private _nextMarker = 0; constructor(events: ResourceDebugSessionEvents = vscode.debug, options: ResourceDebugSessionRegistryOptions = {}) { this._pendingStartTimeoutMs = options.pendingStartTimeoutMs ?? ResourceDebugSessionRegistry._defaultPendingStartTimeoutMs; this._telemetry = options.telemetry ?? new ExtensionResourceDebugTelemetry(); + this._clock = options.clock ?? monotonicResourceDebugClock; this._subscriptions = vscode.Disposable.from( events.onDidStartDebugSession(session => this._onDidStartDebugSession(session)), events.onDidTerminateDebugSession(session => this._onDidTerminateDebugSession(session))); @@ -137,6 +143,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { pendingStartTimeout: undefined, startAccepted: false, terminated: false, + sessionStarted: false, sessionStartedAt: undefined, telemetry, }; @@ -171,6 +178,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.sessionIds.add(session.id); + attempt.sessionStarted = true; attempt.sessionStartedAt ??= this._getTimestamp(); this._clearPendingStartExpiry(attempt); } @@ -187,17 +195,14 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } attempt.terminated = true; - const sessionStartedAt = attempt.sessionStartedAt; - if (sessionStartedAt !== undefined) { + if (attempt.sessionStarted) { this._recordTelemetry(() => this._telemetry.recordSessionEnd({ ...attempt.telemetry, requested_strategy: 'attach', effective_strategy: 'attach', controller: 'editor', session_end_reason: 'terminated', - }, { - session_duration_ms: this._getDuration(sessionStartedAt, this._getTimestamp()), - })); + }, this._getMeasurements(attempt.sessionStartedAt))); } this._removeAttempt(attempt); } @@ -239,19 +244,28 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } } - private _getTimestamp(): number { + private _getMeasurements(startedAt: number | undefined): { readonly session_duration_ms?: number } { + const duration = this._getDuration(startedAt, this._getTimestamp()); + return duration === undefined ? {} : { session_duration_ms: duration }; + } + + private _getTimestamp(): number | undefined { try { - const timestamp = this._telemetry.now(); - return Number.isFinite(timestamp) ? timestamp : 0; + const timestamp = this._clock.now(); + return Number.isFinite(timestamp) ? timestamp : undefined; } catch { - return 0; + return undefined; } } - private _getDuration(start: number, end: number): number { + private _getDuration(start: number | undefined, end: number | undefined): number | undefined { + if (start === undefined || end === undefined) { + return undefined; + } + const duration = end - start; - return Number.isFinite(duration) && duration >= 0 ? duration : 0; + return Number.isFinite(duration) && duration >= 0 ? duration : undefined; } private _recordTelemetry(record: () => void): void { diff --git a/extension/src/debugger/resourceDebugTelemetry.ts b/extension/src/debugger/resourceDebugTelemetry.ts index d2075359b0c..83f32bd6390 100644 --- a/extension/src/debugger/resourceDebugTelemetry.ts +++ b/extension/src/debugger/resourceDebugTelemetry.ts @@ -29,9 +29,9 @@ export interface ResourceDebugResultTelemetryProperties { } export interface ResourceDebugResultTelemetryMeasurements { - readonly resolution_duration_ms: number; - readonly debug_start_duration_ms: number; - readonly total_duration_ms: number; + readonly resolution_duration_ms?: number; + readonly debug_start_duration_ms?: number; + readonly total_duration_ms?: number; } export interface ResourceDebugAttachSessionMetadata { @@ -48,11 +48,10 @@ export interface ResourceDebugSessionEndTelemetryProperties extends ResourceDebu } export interface ResourceDebugSessionEndTelemetryMeasurements { - readonly session_duration_ms: number; + readonly session_duration_ms?: number; } export interface ResourceDebugTelemetry { - now(): number; recordStart(properties: ResourceDebugStartTelemetryProperties): void; recordResult( properties: ResourceDebugResultTelemetryProperties, @@ -64,8 +63,8 @@ export interface ResourceDebugTelemetry { ): void; } -const systemClock: ResourceDebugClock = { - now: () => Date.now(), +export const monotonicResourceDebugClock: ResourceDebugClock = { + now: () => performance.now(), }; /** @@ -73,29 +72,22 @@ const systemClock: ResourceDebugClock = { * service and session registry cannot accidentally forward debug configurations or errors. */ export class ExtensionResourceDebugTelemetry implements ResourceDebugTelemetry { - constructor(private readonly _clock: ResourceDebugClock = systemClock) { - } - - now(): number { - return this._clock.now(); - } - recordStart(properties: ResourceDebugStartTelemetryProperties): void { - this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/start', properties)); + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/start', properties)); } recordResult( properties: ResourceDebugResultTelemetryProperties, measurements: ResourceDebugResultTelemetryMeasurements, ): void { - this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/result', properties, measurements)); + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/result', properties, measurements)); } recordSessionEnd( properties: ResourceDebugSessionEndTelemetryProperties, measurements: ResourceDebugSessionEndTelemetryMeasurements, ): void { - this._send(() => sendTelemetryEvent('aspire/vscode/resourceDebug/session/end', properties, measurements)); + this._send(() => sendTelemetryEvent('aspire/vscode/resourcedebug/session/end', properties, measurements)); } private _send(send: () => void): void { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 99320ea380f..28a1899fbe8 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -38,6 +38,7 @@ import { registerCodeLensCommands } from './activation/registerCodeLensCommands' import { extensionResourceAttachProviders, ResourceAttachProviderRegistry } from './debugger/resourceAttachProviders'; import { ResourceDebugService } from './debugger/resourceDebugService'; import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; +import { ExtensionResourceDebugTelemetry, monotonicResourceDebugClock } from './debugger/resourceDebugTelemetry'; let aspireExtensionContext = new AspireExtensionContext(); @@ -111,12 +112,20 @@ export async function activate(context: vscode.ExtensionContext) { // Aspire panel - running app hosts tree view const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); + const resourceDebugTelemetry = new ExtensionResourceDebugTelemetry(); + const resourceDebugClock = monotonicResourceDebugClock; + const resourceDebugSessionRegistry = new ResourceDebugSessionRegistry(vscode.debug, { + telemetry: resourceDebugTelemetry, + clock: resourceDebugClock, + }); const resourceDebugService = new ResourceDebugService({ appHostRepository: dataRepository, attachProviders: new ResourceAttachProviderRegistry(extensionResourceAttachProviders), - sessionRegistry: new ResourceDebugSessionRegistry(), + sessionRegistry: resourceDebugSessionRegistry, startDebugging: (workspaceFolder, configuration) => vscode.debug.startDebugging(workspaceFolder, configuration), + telemetry: resourceDebugTelemetry, + clock: resourceDebugClock, }); context.subscriptions.push(resourceDebugService); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 7bd9693c057..c914c25f5d8 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -83,15 +83,15 @@ class TestResourceDebugTelemetry { } recordStart(properties: Record): void { - this._record('aspire/vscode/resourceDebug/start', properties); + this._record('aspire/vscode/resourcedebug/start', properties); } recordResult(properties: Record, measurements: Record): void { - this._record('aspire/vscode/resourceDebug/result', properties, measurements); + this._record('aspire/vscode/resourcedebug/result', properties, measurements); } recordSessionEnd(properties: Record, measurements: Record): void { - this._record('aspire/vscode/resourceDebug/session/end', properties, measurements); + this._record('aspire/vscode/resourcedebug/session/end', properties, measurements); } private _record(name: string, properties: Record, measurements?: Record): void { @@ -99,6 +99,23 @@ class TestResourceDebugTelemetry { } } +class TestResourceDebugClock { + private readonly _timestamps: (number | Error)[]; + + constructor(...timestamps: (number | Error)[]) { + this._timestamps = timestamps; + } + + now(): number { + const timestamp = this._timestamps.shift(); + if (timestamp instanceof Error) { + throw timestamp; + } + + return timestamp ?? 0; + } +} + function createProvider(overrides: Partial = {}): ResourceAttachProvider { return { id: 'dotnet', @@ -160,6 +177,7 @@ function createService(options: { startDebugging?: (folder: vscode.WorkspaceFolder | undefined, configuration: vscode.DebugConfiguration) => Thenable; compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; telemetry?: TestResourceDebugTelemetry; + clock?: { now(): number }; pendingStartTimeoutMs?: number; } = {}): { service: ResourceDebugService; @@ -175,9 +193,11 @@ function createService(options: { }; const events = new TestDebugSessionEvents(); const telemetry = options.telemetry ?? new TestResourceDebugTelemetry(); + const clock = options.clock ?? telemetry; const sessions = new ResourceDebugSessionRegistry(events, { pendingStartTimeoutMs: options.pendingStartTimeoutMs, telemetry, + clock, } as unknown as ResourceDebugSessionRegistryOptions); const providers = new ResourceAttachProviderRegistry( options.providers ?? [options.provider ?? createProvider()], @@ -189,6 +209,7 @@ function createService(options: { startDebugging: options.startDebugging ?? (async () => true), compareAppHostIdentity: options.compareAppHostIdentity, telemetry, + clock, } as unknown as ResourceDebugServiceDependencies); return { service, repository, sessions, events, telemetry }; @@ -950,6 +971,27 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('normalizes unexpected service errors while logging their raw details internally', async () => { + const rawError = 'process 1234 at /repo/private/AppHost.csproj'; + const logError = sinon.stub(extensionLogOutputChannel, 'error'); + const { service, sessions } = createService({ + compareAppHostIdentity: () => { + throw new Error(rawError); + }, + }); + + try { + const result = await service.debug(createRequest()); + + assert.deepStrictEqual(result, { outcome: 'error', errorKind: 'unexpected' }); + assert.doesNotMatch(JSON.stringify(result), /1234|\/repo|AppHost\.csproj/); + assert.ok(logError.calledWithMatch(rawError)); + } + finally { + sessions.dispose(); + } + }); + test('returns cancelled when the request cancellation token is already cancelled', async () => { const cancellation = new vscode.CancellationTokenSource(); cancellation.cancel(); @@ -1038,7 +1080,7 @@ suite('Resource debug service', () => { assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); assert.deepStrictEqual(telemetry.events, [ { - name: 'aspire/vscode/resourceDebug/start', + name: 'aspire/vscode/resourcedebug/start', properties: { source: 'tree', requested_strategy: 'attach', @@ -1047,7 +1089,7 @@ suite('Resource debug service', () => { measurements: undefined, }, { - name: 'aspire/vscode/resourceDebug/result', + name: 'aspire/vscode/resourcedebug/result', properties: { source: 'tree', provider: 'dotnet', @@ -1088,8 +1130,8 @@ suite('Resource debug service', () => { const result = await service.debug(createRequest()); assert.strictEqual(result.outcome, expectedOutcome); - const startEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start'); - const resultEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result'); + const startEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start'); + const resultEvents = telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result'); assert.strictEqual(startEvents.length, 1); assert.strictEqual(resultEvents.length, 1); assert.strictEqual(resultEvents[0].properties.outcome, expectedOutcome); @@ -1187,10 +1229,10 @@ suite('Resource debug service', () => { try { const result = await cancelledFixture.service.debug(createRequest({ cancellationToken: cancelled.token })); assert.deepStrictEqual(result, { outcome: 'cancelled' }); - assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start').length, 1); - assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result').length, 1); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start').length, 1); + assert.strictEqual(cancelledFixture.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result').length, 1); assert.strictEqual( - cancelledFixture.telemetry.events.find(event => event.name === 'aspire/vscode/resourceDebug/result')?.properties.error_kind, + cancelledFixture.telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.properties.error_kind, 'none'); } finally { @@ -1202,7 +1244,7 @@ suite('Resource debug service', () => { try { assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); assert.deepStrictEqual(await duplicate.service.debug(createRequest()), { outcome: 'alreadyDebugging' }); - const resultEvents = duplicate.telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result'); + const resultEvents = duplicate.telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result'); assert.strictEqual(resultEvents.length, 2); assert.deepStrictEqual(resultEvents.map(event => event.properties.outcome), ['started', 'alreadyDebugging']); } @@ -1211,7 +1253,7 @@ suite('Resource debug service', () => { } }); - test('emits an exact private-data-free result payload', async () => { + test('emits an exact private-data-free payload without correlation identifiers', async () => { const telemetry = new TestResourceDebugTelemetry(); telemetry.currentTime = 50; const secrets = [ @@ -1276,7 +1318,7 @@ suite('Resource debug service', () => { const serializedEvents = JSON.stringify(telemetry.events); assert.deepStrictEqual(telemetry.events, [ { - name: 'aspire/vscode/resourceDebug/start', + name: 'aspire/vscode/resourcedebug/start', properties: { source: 'languageModelTool', requested_strategy: 'attach', @@ -1285,7 +1327,7 @@ suite('Resource debug service', () => { measurements: undefined, }, { - name: 'aspire/vscode/resourceDebug/result', + name: 'aspire/vscode/resourcedebug/result', properties: { source: 'languageModelTool', provider: 'dotnet', @@ -1300,7 +1342,6 @@ suite('Resource debug service', () => { }, measurements: { resolution_duration_ms: 20, - debug_start_duration_ms: 0, total_duration_ms: 20, }, }, @@ -1339,7 +1380,7 @@ suite('Resource debug service', () => { events.terminate(events.startedConfiguration); assert.deepStrictEqual(telemetry.events.at(-1), { - name: 'aspire/vscode/resourceDebug/session/end', + name: 'aspire/vscode/resourcedebug/session/end', properties: { source: 'tree', provider: 'dotnet', @@ -1353,7 +1394,7 @@ suite('Resource debug service', () => { session_duration_ms: 30, }, }); - assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/session/end').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/session/end').length, 1); } finally { fixture.sessions.dispose(); @@ -1372,9 +1413,9 @@ suite('Resource debug service', () => { assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); await clock.tickAsync(10); - assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/start').length, 1); - assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/result').length, 1); - assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourceDebug/session/end').length, 0); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/start').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/result').length, 1); + assert.strictEqual(telemetry.events.filter(event => event.name === 'aspire/vscode/resourcedebug/session/end').length, 0); } finally { fixture.sessions.dispose(); @@ -1385,16 +1426,158 @@ suite('Resource debug service', () => { test('ignores telemetry sink failures when debugging a resource', async () => { const telemetry = new TestResourceDebugTelemetry(); sinon.stub(telemetry, 'recordStart').throws(new Error('raw telemetry start failure')); - sinon.stub(telemetry, 'recordResult').throws(new Error('raw telemetry result failure')); const { service, sessions } = createService({ telemetry }); try { assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); assert.strictEqual((telemetry.recordStart as sinon.SinonStub).callCount, 1); + } + finally { + sessions.dispose(); + } + }); + + test('ignores a throwing result telemetry sink when debugging a resource', async () => { + const telemetry = new TestResourceDebugTelemetry(); + sinon.stub(telemetry, 'recordResult').throws(new Error('raw telemetry result failure')); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); assert.strictEqual((telemetry.recordResult as sinon.SinonStub).callCount, 1); } finally { sessions.dispose(); } }); + + test('treats non-string and missing resource types as other without changing the debug result', async () => { + for (const resourceType of [undefined, 42] as const) { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ + telemetry, + appHosts: [createAppHost({ + resources: [createResource({ resourceType: resourceType as unknown as string })], + })], + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.properties.resource_type, + 'other'); + } + finally { + sessions.dispose(); + } + } + }); + + test('omits invalid result durations from a separate monotonic clock', async () => { + const testCases: readonly { + readonly name: string; + readonly clock: TestResourceDebugClock; + readonly measurements: Record; + }[] = [ + { + name: 'throws', + clock: new TestResourceDebugClock( + new Error('clock failure'), + new Error('clock failure'), + new Error('clock failure')), + measurements: {}, + }, + { + name: 'moves backwards', + clock: new TestResourceDebugClock(100, 150, 50), + measurements: { resolution_duration_ms: 50 }, + }, + { + name: 'returns NaN', + clock: new TestResourceDebugClock(Number.NaN, Number.NaN, Number.NaN), + measurements: {}, + }, + { + name: 'returns infinity', + clock: new TestResourceDebugClock( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY), + measurements: {}, + }, + ]; + + for (const testCase of testCases) { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ telemetry, clock: testCase.clock }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }, testCase.name); + assert.deepStrictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/result')?.measurements, + testCase.measurements, + testCase.name); + } + finally { + sessions.dispose(); + } + } + }); + + test('swallows a throwing session-end sink and cleans up the session exactly once', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const recordSessionEnd = sinon.stub(telemetry, 'recordSessionEnd').throws(new Error('raw session-end telemetry failure')); + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + events.terminate(events.startedConfiguration); + + assert.strictEqual(recordSessionEnd.callCount, 1); + assert.strictEqual(fixture.sessions.hasActiveSession(target, 'api'), false); + } + finally { + fixture.sessions.dispose(); + } + }); + + test('omits the session duration when the injected monotonic clock moves backwards', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const clock = new TestResourceDebugClock(100, 110, 120, 130, 90); + let events: TestDebugSessionEvents | undefined; + const fixture = createService({ + telemetry, + clock, + startDebugging: async (_folder, configuration) => { + events!.start(configuration); + return true; + }, + }); + events = fixture.events; + + try { + assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + + assert.deepStrictEqual( + telemetry.events.find(event => event.name === 'aspire/vscode/resourcedebug/session/end')?.measurements, + {}); + } + finally { + fixture.sessions.dispose(); + } + }); }); diff --git a/extension/src/test/telemetryInventory.test.ts b/extension/src/test/telemetryInventory.test.ts index beb3614dbd1..6770f19b28f 100644 --- a/extension/src/test/telemetryInventory.test.ts +++ b/extension/src/test/telemetryInventory.test.ts @@ -19,11 +19,6 @@ type TelemetryRegistryEvent = { // Code's automatic `/` prefix after the TelemetryLogger has // applied its platform guarantees. const telemetryEntityPrefix = ''; -const caseSensitiveWireEventNames = [ - 'aspire/vscode/resourceDebug/start', - 'aspire/vscode/resourceDebug/result', - 'aspire/vscode/resourceDebug/session/end', -] as const; const freeformPropertyNamePattern = /(?:^|_)(?:path|message|description|args?)(?:_|$)/i; const platformCommonTelemetryProperties = [ 'common.devDeviceId', @@ -134,10 +129,10 @@ function getStringLiteralUnion(typeNode: ts.TypeNode): string[] { } suite('extension/telemetry.json', () => { - test('event entity names are lowercase except approved case-sensitive wire names', () => { + test('event entity names are lowercase', () => { const inventory = readTelemetryInventory(); const mixedCaseEntityNames = Object.keys(inventory.events) - .filter(name => name !== name.toLowerCase() && !caseSensitiveWireEventNames.includes(name as typeof caseSensitiveWireEventNames[number])); + .filter(name => name !== name.toLowerCase()); assert.deepStrictEqual(mixedCaseEntityNames, []); }); diff --git a/extension/src/utils/telemetryRegistry.ts b/extension/src/utils/telemetryRegistry.ts index 5718c4ac91c..f1d6b7b73f4 100644 --- a/extension/src/utils/telemetryRegistry.ts +++ b/extension/src/utils/telemetryRegistry.ts @@ -106,11 +106,11 @@ export interface TelemetryEventSchema { properties: 'resource_type' | 'mode' | 'exit_code_bucket' | 'end_reason' | 'error_kind'; measurements: 'duration_ms' | 'exit_code'; }; - 'aspire/vscode/resourceDebug/start': { + 'aspire/vscode/resourcedebug/start': { properties: 'source' | 'requested_strategy' | 'controller'; measurements: never; }; - 'aspire/vscode/resourceDebug/result': { + 'aspire/vscode/resourcedebug/result': { properties: | 'source' | 'provider' @@ -124,7 +124,7 @@ export interface TelemetryEventSchema { | 'error_kind'; measurements: 'resolution_duration_ms' | 'debug_start_duration_ms' | 'total_duration_ms'; }; - 'aspire/vscode/resourceDebug/session/end': { + 'aspire/vscode/resourcedebug/session/end': { properties: | 'source' | 'provider' diff --git a/extension/telemetry.json b/extension/telemetry.json index 31d0cb905ed..298b988c293 100644 --- a/extension/telemetry.json +++ b/extension/telemetry.json @@ -307,7 +307,7 @@ "comment": "The numeric process exit code for the resource debug session." } }, - "aspire/vscode/resourceDebug/start": { + "aspire/vscode/resourcedebug/start": { "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", @@ -324,7 +324,7 @@ "comment": "The bounded controller responsible for resource debugging: editor." } }, - "aspire/vscode/resourceDebug/result": { + "aspire/vscode/resourcedebug/result": { "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", @@ -391,7 +391,7 @@ "comment": "The non-negative total resource debug operation duration in milliseconds." } }, - "aspire/vscode/resourceDebug/session/end": { + "aspire/vscode/resourcedebug/session/end": { "source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", From afbe05a28d659afca5f91a3deed1a5f277945a3c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 05:14:48 -0400 Subject: [PATCH 57/90] fix(extension): finalize resource debugger tree action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../src/debugger/resourceDebugContracts.ts | 5 + .../src/debugger/resourceDebugService.ts | 2 + .../debugger/resourceDebugSessionRegistry.ts | 5 + extension/src/test/appHostTreeView.test.ts | 136 +++++++++++++++++- .../src/test/resourceDebugService.test.ts | 26 ++++ .../src/views/AspireAppHostTreeProvider.ts | 21 +-- .../src/views/treeItems/resourceItems.ts | 14 +- 7 files changed, 189 insertions(+), 20 deletions(-) diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index 8eb0d0e4aa4..9403bd51b88 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -68,6 +68,11 @@ export interface ResourceAttachProvider { export interface ResourceDebugger { debug(request: ResourceDebugRequest): Promise; canAttachToResource(resource: ResourceDebugResourceSnapshot): boolean; + /** + * Lets resource presentations refresh after attach sessions start or end without receiving + * internal process, path, or debugger configuration details. + */ + readonly onDidChangeDebugSessions?: vscode.Event; } export type ResourceDebugErrorKind = diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 478c9a840be..d963bb5143d 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -55,11 +55,13 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger private readonly _compareAppHostIdentity: ResourceDebugAppHostIdentityComparer; private readonly _telemetry: ResourceDebugTelemetry; private readonly _clock: ResourceDebugClock; + readonly onDidChangeDebugSessions: vscode.Event; constructor(private readonly _dependencies: ResourceDebugServiceDependencies) { this._compareAppHostIdentity = _dependencies.compareAppHostIdentity ?? compareAppHostIdentity; this._telemetry = _dependencies.telemetry ?? new ExtensionResourceDebugTelemetry(); this._clock = _dependencies.clock ?? monotonicResourceDebugClock; + this.onDidChangeDebugSessions = _dependencies.sessionRegistry.onDidChangeSessions; } dispose(): void { diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 9bb55876e21..865c258accc 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -52,6 +52,8 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { private readonly _attempts = new Map(); private readonly _attemptsByResource = new Map>(); private readonly _resourceLocks = new Map>(); + private readonly _onDidChangeSessions = new vscode.EventEmitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; private readonly _subscriptions: vscode.Disposable; private readonly _pendingStartTimeoutMs: number; private readonly _telemetry: ResourceDebugTelemetry; @@ -75,6 +77,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { this._attempts.clear(); this._attemptsByResource.clear(); this._resourceLocks.clear(); + this._onDidChangeSessions.dispose(); } hasActiveSession(appHost: ResourceDebugAppHostTarget, resourceName: string): boolean { @@ -166,6 +169,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { if (attempt.sessionIds.size === 0) { this._schedulePendingStartExpiry(attempt); } + this._onDidChangeSessions.fire(); }, abandon: () => this._removeAttempt(attempt), }; @@ -220,6 +224,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { if (attemptMarkers?.size === 0) { this._attemptsByResource.delete(attempt.resourceKey); } + this._onDidChangeSessions.fire(); } private _schedulePendingStartExpiry(attempt: TrackedAttachAttempt): void { diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 9f446546f8a..b1bec71bb48 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -2731,6 +2731,45 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource passes the workspace AppHost path to the debug service', async () => { + let request: ResourceDebugRequest | undefined; + const appHostPath = '/workspace/apps/Store/AppHost.csproj'; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [ + makeResource({ + name: 'api', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + workspaceAppHost: makeAppHost({ appHostPath }), + workspaceAppHostPath: appHostPath, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: 'AppHost.csproj', + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), resourceDebugger); + + const [workspaceAppHost] = provider.getChildren(); + const [resourceItem] = provider.getChildren(workspaceAppHost); + await (provider as any).attachDebuggerToResource(resourceItem); + + assert.ok(request); + assert.strictEqual(request.appHost.absolutePath, appHostPath); + assert.strictEqual(request.appHost.displayPath, vscode.workspace.asRelativePath(appHostPath)); + provider.dispose(); + }); + test('attachDebuggerToResource shows cancellable progress and reports an active debugger', async () => { const progressToken = new vscode.CancellationTokenSource(); const withProgressStub = sandbox.stub(vscode.window, 'withProgress').callsFake(async (options, task) => { @@ -2789,7 +2828,9 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }), makeAppHost({ appHostPath: '/repo/second/AppHost.csproj', - appHostPid: 2222, + // The resource tree already knows which AppHost rendered the resource. The attach + // adapter must preserve that path instead of looking the owner up from a PID. + appHostPid: 1111, resources: [ makeResource({ name: 'api', @@ -2815,6 +2856,66 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource passes progress cancellation to the debug service without a warning', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let receivedToken: vscode.CancellationToken | undefined; + const resourceDebugger: ResourceDebugger = { + debug: async request => { + receivedToken = request.cancellationToken; + return { outcome: 'cancelled' }; + }, + canAttachToResource: () => true, + }; + const withProgressStub = sandbox.stub(vscode.window, 'withProgress').callsFake(async (_options, task) => + await task({ report: () => { } }, cancellation.token)); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + try { + await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.ok(withProgressStub.calledOnce); + assert.strictEqual(receivedToken, cancellation.token); + assert.strictEqual(warningStub.called, false); + } + finally { + cancellation.dispose(); + provider.dispose(); + } + }); + + test('attachDebuggerToResource refreshes the tree when resource debug session state changes', () => { + const debugSessionChanges = new vscode.EventEmitter(); + const resourceDebugger = { + debug: async () => ({ outcome: 'started', providerId: 'dotnet' as const }), + canAttachToResource: () => true, + onDidChangeDebugSessions: debugSessionChanges.event, + } as ResourceDebugger & { onDidChangeDebugSessions: vscode.Event }; + const provider = makeTreeProvider([], 'global', undefined, resourceDebugger); + let refreshCount = 0; + const subscription = provider.onDidChangeTreeData(() => refreshCount++); + + try { + debugSessionChanges.fire(); + + assert.strictEqual(refreshCount, 1); + } + finally { + subscription.dispose(); + debugSessionChanges.dispose(); + provider.dispose(); + } + }); + test('attachDebuggerToResource rejects a resource removed before invocation', async () => { const appHosts = [ makeAppHost({ @@ -2937,6 +3038,29 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource uses future provider requirement labels without language-specific branching', async () => { + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, makeResourceDebugger({ + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id: 'future.debugger', label: 'Future debugger' }], + })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); + + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('Install Future debugger to attach the debugger to this resource.')); + provider.dispose(); + }); + test('attachDebuggerToResource reports when VS Code declines the attach session', async () => { const provider = makeTreeProvider([ makeAppHost({ @@ -2954,12 +3078,12 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { outcome: 'error', errorKind: 'debuggerStartDeclined', })); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage'); - await assert.rejects( - (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)), - (error: unknown) => error instanceof Error - && error.name === 'StartDebuggingDeclined' - && error.message === 'VS Code did not start the debugger attach session for API.'); + const outcome = await (provider as any).attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(outcome, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(warningStub.calledOnceWith('VS Code did not start the debugger attach session for API.')); provider.dispose(); }); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index c914c25f5d8..74c927ee419 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -1059,6 +1059,32 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('publishes attach session start and termination changes to tree consumers', async () => { + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + events.start(configuration); + return true; + }, + }); + const lifecycle = service.onDidChangeDebugSessions; + let changeCount = 0; + const subscription = lifecycle(() => changeCount++); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.strictEqual(changeCount, 1); + assert.ok(events.startedConfiguration); + + events.terminate(events.startedConfiguration); + + assert.strictEqual(changeCount, 2); + } + finally { + subscription.dispose(); + sessions.dispose(); + } + }); + test('emits a bounded start and success result with deterministic durations', async () => { const telemetry = new TestResourceDebugTelemetry(); telemetry.currentTime = 100; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index b7516bcb3f1..9083a5f663c 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -112,6 +112,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider>(); private _contentProviderRegistration: vscode.Disposable | undefined; private readonly _appHostSourceContents = new Map(); @@ -139,6 +140,9 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(); }); + this._resourceDebugSessionSubscription = this._resourceDebugService.onDidChangeDebugSessions?.(() => { + this._onDidChangeTreeData.fire(); + }); } provideTextDocumentContent(uri: vscode.Uri): string { @@ -185,6 +189,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider 0) { - items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid)); + items.push(new ResourcesGroupItem(appHost.resources, appHost.appHostPid, appHost.appHostPath)); } return items; @@ -721,7 +726,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { - // Global resource items retain the AppHost PID rather than its path. Resolve that - // owner again before refreshing the resource snapshot so duplicate resource names - // in different AppHosts cannot attach to whichever host happens to render first. - const ownerAppHostPath = element.appHostPath ?? this._findAppHostForResource(element)?.appHostPath; + // The tree captures the owning AppHost when it renders a resource so an attach never + // chooses a same-named resource from another AppHost based on a mutable PID lookup. + const ownerAppHostPath = element.appHostPath; if (!ownerAppHostPath) { vscode.window.showWarningMessage(attachDebuggerResourceNotFound); return { success: false, errorKind: 'ResourceNotFound' }; @@ -1051,9 +1055,8 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider Date: Sat, 15 Aug 2026 05:30:04 -0400 Subject: [PATCH 58/90] fix(extension): scope resource tree IDs to AppHost process Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/test/appHostTreeView.test.ts | 67 +++++++++++++++++++ .../src/views/treeItems/resourceItems.ts | 13 ++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index b1bec71bb48..6848f7976a8 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -15,6 +15,7 @@ import * as configInfoProvider from '../utils/configInfoProvider'; import { AppHostDataRepository, shortenPath, shortenPaths } from '../data/AppHostDataRepository'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { getResourceContextValue, getResourceIcon, getResourceCommandIcon, resolveAppHostSourcePath, buildResourceDescription } from '../views/treePresentation'; +import { ResourceItem } from '../views/treeItems/resourceItems'; import type { Clipboard } from '../views/AspireAppHostTreeProvider'; import type { AppHostDisplayInfo, ResourceJson, ViewMode } from '../data/AppHostDataRepository'; import { ResourceCommandInputType } from '../data/AppHostDataRepository'; @@ -2856,6 +2857,72 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('global AppHost snapshots with the same path have stable process-specific tree IDs', async () => { + let request: unknown; + const resourceDebugger: ResourceDebugger = { + debug: async value => { + request = value; + return { outcome: 'started', providerId: 'dotnet' }; + }, + canAttachToResource: () => true, + }; + const appHostPath = '/repo/AppHost.csproj'; + const provider = makeTreeProvider([ + makeAppHost({ + appHostPath, + appHostPid: 1111, + resources: [ + makeResource({ + name: 'api', + displayName: 'Previous API', + properties: makeAttachableProjectProperties({ 'executable.pid': '111' }), + }), + ], + }), + makeAppHost({ + appHostPath, + appHostPid: 2222, + resources: [ + makeResource({ + name: 'api', + displayName: 'Current API', + properties: makeAttachableProjectProperties({ 'executable.pid': '222' }), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + const [firstAppHostItem, secondAppHostItem] = provider.getChildren(); + const firstResourcesGroup = provider.getChildren(firstAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + const secondResourcesGroup = provider.getChildren(secondAppHostItem).find(item => item.contextValue === 'resourcesGroup'); + assert.ok(firstResourcesGroup); + assert.ok(secondResourcesGroup); + const [firstResourceItem] = provider.getChildren(firstResourcesGroup); + const [secondResourceItem] = provider.getChildren(secondResourcesGroup); + + assert.notStrictEqual(firstResourcesGroup.id, secondResourcesGroup.id); + assert.notStrictEqual(firstResourceItem.id, secondResourceItem.id); + + const refreshedGroups = provider.getChildren() + .map(appHostItem => provider.getChildren(appHostItem).find(item => item.contextValue === 'resourcesGroup')); + const refreshedResourceIds = refreshedGroups.map(resourcesGroup => { + assert.ok(resourcesGroup); + return provider.getChildren(resourcesGroup)[0].id; + }); + assert.deepStrictEqual(refreshedGroups.map(resourcesGroup => resourcesGroup?.id), [firstResourcesGroup.id, secondResourcesGroup.id]); + assert.deepStrictEqual(refreshedResourceIds, [firstResourceItem.id, secondResourceItem.id]); + + await (provider as any).attachDebuggerToResource(secondResourceItem); + + const debugRequest = request as ResourceDebugRequest; + assert.strictEqual(debugRequest.appHost.absolutePath, appHostPath); + assert.strictEqual(debugRequest.resourceName, 'api'); + + const workspaceResourceItem = new ResourceItem(makeResource({ name: 'workspace-api' }), null, false, undefined, appHostPath); + assert.ok(workspaceResourceItem.id?.includes(':workspace:')); + provider.dispose(); + }); + test('attachDebuggerToResource passes progress cancellation to the debug service without a warning', async () => { const cancellation = new vscode.CancellationTokenSource(); let receivedToken: vscode.CancellationToken | undefined; diff --git a/extension/src/views/treeItems/resourceItems.ts b/extension/src/views/treeItems/resourceItems.ts index b4ce086900e..9c0f41ad39f 100644 --- a/extension/src/views/treeItems/resourceItems.ts +++ b/extension/src/views/treeItems/resourceItems.ts @@ -49,7 +49,7 @@ export class ResourcesGroupItem extends vscode.TreeItem { public readonly appHostPath: string, ) { super(resourcesGroupLabel, vscode.TreeItemCollapsibleState.Expanded); - this.id = `resources:${getComparisonKey(path.resolve(appHostPath))}`; + this.id = `resources:${getTreeItemOwnerId(appHostPath, appHostPid)}`; this.iconPath = new vscode.ThemeIcon('layers', new vscode.ThemeColor('aspire.brandPurple')); this.contextValue = 'resourcesGroup'; this.description = `(${resources.length})`; @@ -141,13 +141,16 @@ export class ResourceItem extends vscode.TreeItem { ? vscode.TreeItemCollapsibleState.Expanded : hasExpandableContent ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; super(label, collapsible); - const ownerId = appHostPath - ? getComparisonKey(path.resolve(appHostPath)) - : appHostPid !== null ? appHostPid.toString() : 'workspace'; - this.id = `resource:${ownerId}:${resource.name}`; + this.id = `resource:${getTreeItemOwnerId(appHostPath, appHostPid)}:${resource.name}`; this.iconPath = getResourceIcon(resource); this.description = buildResourceDescription(resource); this.tooltip = buildResourceTooltip(resource); this.contextValue = getResourceContextValue(resource, canAttachDebugger); } } + +function getTreeItemOwnerId(appHostPath: string | undefined, appHostPid: number | null): string { + const pathId = appHostPath ? getComparisonKey(path.resolve(appHostPath)) : 'workspace'; + const processId = appHostPid ?? 'workspace'; + return `${pathId}:pid:${processId}`; +} From fd3a6dee450f41d676b7c3c673934b1d0842e16e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 05:51:33 -0400 Subject: [PATCH 59/90] feat(extension): add resource debug language model tool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/loc/xlf/aspire-vscode.xlf | 21 + extension/package.json | 42 +- extension/package.nls.json | 7 + extension/src/extension.ts | 7 + .../src/lm/appHostLifecycleToolContracts.ts | 33 ++ .../src/lm/appHostLifecycleToolService.ts | 16 +- extension/src/lm/appHostLifecycleTools.ts | 3 + extension/src/lm/resourceDebugToolAdapters.ts | 80 +++ .../src/lm/resourceDebugToolContracts.ts | 76 +++ extension/src/lm/resourceDebugToolService.ts | 263 +++++++++ extension/src/lm/resourceDebugTools.ts | 17 + extension/src/loc/strings.ts | 4 + .../src/test/appHostLifecycleTools.test.ts | 7 +- extension/src/test/resourceDebugTools.test.ts | 542 ++++++++++++++++++ 14 files changed, 1112 insertions(+), 6 deletions(-) create mode 100644 extension/src/lm/resourceDebugToolAdapters.ts create mode 100644 extension/src/lm/resourceDebugToolContracts.ts create mode 100644 extension/src/lm/resourceDebugToolService.ts create mode 100644 extension/src/lm/resourceDebugTools.ts create mode 100644 extension/src/test/resourceDebugTools.test.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index b5d78fc35cf..612817944df 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -88,9 +88,24 @@ Attach debugger + + Attach debugger to Aspire resource + Attach debugger: {0} + + Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported. + + + Attach the debugger to a running Aspire resource. + + + Attach the debugger to resource {0} from Aspire AppHost {1}? + + + Attaching debugger to Aspire resource {0}... + Attaching debugger to {0}... @@ -226,6 +241,9 @@ Debug Aspire pipeline step + + Debug Aspire resource + Debug with Chrome @@ -859,6 +877,9 @@ Unable to add folder to workspace: {0} + + Unable to attach debugger to the requested Aspire resource. + Update Aspire CLI diff --git a/extension/package.json b/extension/package.json index 5a8e3f89c04..d2113123928 100644 --- a/extension/package.json +++ b/extension/package.json @@ -52,7 +52,8 @@ "onCommand:aspire-vscode.installCli", "onCommand:aspire-vscode.verifyCliInstalled", "onLanguageModelTool:aspire_apphost_start", - "onLanguageModelTool:aspire_apphost_stop" + "onLanguageModelTool:aspire_apphost_stop", + "onLanguageModelTool:aspire_resource_debug" ], "main": "./dist/extension.js", "l10n": "./l10n", @@ -120,6 +121,45 @@ ], "additionalProperties": false } + }, + { + "name": "aspire_resource_debug", + "toolReferenceName": "aspireDebugResource", + "displayName": "%languageModelTool.aspireResourceDebug.displayName%", + "modelDescription": "%languageModelTool.aspireResourceDebug.modelDescription%", + "userDescription": "%languageModelTool.aspireResourceDebug.userDescription%", + "icon": "$(debug-alt)", + "canBeReferencedInPrompt": true, + "when": "isWorkspaceTrusted", + "tags": [ + "aspire", + "debug", + "resource" + ], + "inputSchema": { + "type": "object", + "properties": { + "appHostPath": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "strategy": { + "type": "string", + "enum": [ + "auto", + "attach" + ], + "default": "auto" + } + }, + "required": [ + "appHostPath", + "resourceName" + ], + "additionalProperties": false + } } ], "mcpServerDefinitionProviders": [ diff --git a/extension/package.nls.json b/extension/package.nls.json index 4c84a39a4b5..04710feb344 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -321,6 +321,10 @@ "aspire-vscode.strings.appHostLifecycleUnresolvedPath": "an unresolved path", "aspire-vscode.strings.appHostLifecycleBusy": "Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.", "aspire-vscode.strings.appHostLifecycleLaunchAlreadyClaimed": "This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.", + "aspire-vscode.strings.resourceDebugToolConfirmationTitle": "Attach debugger to Aspire resource", + "aspire-vscode.strings.resourceDebugToolConfirmationMessage": "Attach the debugger to resource {0} from Aspire AppHost {1}?", + "aspire-vscode.strings.resourceDebugToolInvocationMessage": "Attaching debugger to Aspire resource {0}...", + "aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage": "Unable to attach debugger to the requested Aspire resource.", "languageModelTool.aspireAppHostStart.displayName": "Start Aspire AppHost", "languageModelTool.aspireAppHostStart.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started.", "languageModelTool.aspireAppHostStart.userDescription": "Start an Aspire AppHost from this workspace in run or debug mode.", @@ -329,5 +333,8 @@ "languageModelTool.aspireAppHostStop.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through 'aspire stop --apphost' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.", "languageModelTool.aspireAppHostStop.userDescription": "Stop a running Aspire AppHost from this workspace.", "languageModelTool.aspireAppHost.appHostPath.description": "Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example 'AppHost/AppHost.csproj' or 'apphost.cs'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example 'backend/AppHost/AppHost.csproj').", + "languageModelTool.aspireResourceDebug.displayName": "Debug Aspire resource", + "languageModelTool.aspireResourceDebug.modelDescription": "Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.", + "languageModelTool.aspireResourceDebug.userDescription": "Attach the debugger to a running Aspire resource.", "command.openDashboardToSide": "Open Aspire Dashboard to the Side" } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 28a1899fbe8..0ce34f7d341 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -31,6 +31,7 @@ import type { AspireAppHostState, AspireExtensionApi, AspireExtensionStateSnapsh import { AppHostsViewTelemetry } from './views/AppHostsViewTelemetry'; import { initializeCliPathEnvironmentSync } from './utils/cliPathEnvironment'; import { AppHostLifecycleToolService, registerAppHostLifecycleTools } from './lm/appHostLifecycleTools'; +import { AspireResourceDebugToolService, registerAspireResourceDebugTool } from './lm/resourceDebugTools'; import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; @@ -228,6 +229,12 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(appHostLifecycleToolService); const appHostLifecycleToolRegistration = registerAppHostLifecycleTools(appHostLifecycleToolService); context.subscriptions.push(appHostLifecycleToolRegistration); + const resourceDebugToolService = new AspireResourceDebugToolService({ + targetResolver: appHostLifecycleToolService, + resourceDebugger: resourceDebugService, + }); + context.subscriptions.push(resourceDebugToolService); + context.subscriptions.push(registerAspireResourceDebugTool(resourceDebugToolService)); const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); diff --git a/extension/src/lm/appHostLifecycleToolContracts.ts b/extension/src/lm/appHostLifecycleToolContracts.ts index b7c16e71379..d0cd4e9e7fe 100644 --- a/extension/src/lm/appHostLifecycleToolContracts.ts +++ b/extension/src/lm/appHostLifecycleToolContracts.ts @@ -142,6 +142,39 @@ export interface AppHostLifecycleToolDependencies { readonly discoveryService: AppHostLifecycleDiscoveryService; } +/** + * A registry-approved AppHost identity safe for a caller to pass to an editor-owned + * operation. `absolutePath` remains internal; presentation layers may render only + * `displayPath`. + */ +export interface SafeAppHostTarget { + readonly absolutePath: string; + readonly displayPath: string; +} + +/** + * The intentionally small result shape shared by tools that need to select an AppHost + * but must not own AppHost discovery or path-security policy. + */ +export type SafeAppHostTargetResolution = + | { readonly resolved: true; readonly target: SafeAppHostTarget } + | { + readonly resolved: false; + readonly outcome: Extract< + AppHostLifecycleOutcome, + 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'cancelled' + >; + }; + +/** + * Resolves a model selector only against the editor's discovered AppHost registry. + * + * Consumers must not recreate discovery, containment, or multi-root selection logic. + */ +export interface SafeAppHostTargetResolver { + resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise; +} + export interface AppHostLifecycleToolRegistration extends vscode.Disposable { readonly registered: boolean; /** diff --git a/extension/src/lm/appHostLifecycleToolService.ts b/extension/src/lm/appHostLifecycleToolService.ts index ee34fce7e70..b8370d1bc06 100644 --- a/extension/src/lm/appHostLifecycleToolService.ts +++ b/extension/src/lm/appHostLifecycleToolService.ts @@ -21,6 +21,9 @@ import { type AppHostLifecycleToolResult, type AppHostStartToolInput, type AppHostStopToolInput, + type SafeAppHostTarget, + type SafeAppHostTargetResolver, + type SafeAppHostTargetResolution, } from './appHostLifecycleToolContracts'; /** @@ -60,7 +63,7 @@ const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F]|\p{Cf}/u; * confirmation renders and the path the launcher receives originate from the same object. * The model's input only ever selects one of these; it never contributes to one. */ -interface ResolvedAppHostTarget { +interface ResolvedAppHostTarget extends SafeAppHostTarget { /** Absolute path exactly as the registry enumerated it, used for launching. */ absolutePath: string; /** Path relative to the containing workspace folder, always with `/` separators. */ @@ -75,7 +78,14 @@ interface ResolvedAppHostTarget { type AppHostTargetResolution = | { resolved: true; target: ResolvedAppHostTarget } - | { resolved: false; outcome: AppHostLifecycleOutcome; knownAppHosts?: readonly string[] }; + | { + resolved: false; + outcome: Extract< + AppHostLifecycleOutcome, + 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'cancelled' + >; + knownAppHosts?: readonly string[]; + }; type PreflightResult = | { rejected: true; result: AppHostLifecycleToolResult } @@ -101,7 +111,7 @@ type PreflightResult = * straight to the debug adapter and bypasses the lock, which is why every decision here * is re-validated against live session state rather than the lock alone. */ -export class AppHostLifecycleToolService implements vscode.Disposable { +export class AppHostLifecycleToolService implements vscode.Disposable, SafeAppHostTargetResolver { private readonly _dependencies: AppHostLifecycleToolDependencies; private _disposed = false; diff --git a/extension/src/lm/appHostLifecycleTools.ts b/extension/src/lm/appHostLifecycleTools.ts index 9d9a53a507e..f48fda9225c 100644 --- a/extension/src/lm/appHostLifecycleTools.ts +++ b/extension/src/lm/appHostLifecycleTools.ts @@ -17,6 +17,9 @@ export type { AppHostStartToolInput, AppHostStopToolInput, PreparableAppHostLifecycleTool, + SafeAppHostTarget, + SafeAppHostTargetResolver, + SafeAppHostTargetResolution, } from './appHostLifecycleToolContracts'; export { AppHostLifecycleToolService } from './appHostLifecycleToolService'; export { diff --git a/extension/src/lm/resourceDebugToolAdapters.ts b/extension/src/lm/resourceDebugToolAdapters.ts new file mode 100644 index 00000000000..4e06d4ab877 --- /dev/null +++ b/extension/src/lm/resourceDebugToolAdapters.ts @@ -0,0 +1,80 @@ +import * as vscode from 'vscode'; + +import { + resourceDebugToolConfirmationMessage, + resourceDebugToolConfirmationTitle, + resourceDebugToolInvocationMessage, + resourceDebugToolUnavailableInvocationMessage, +} from '../loc/strings'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { + aspireResourceDebugToolName, + type AspireResourceDebugToolInput, + type AspireResourceDebugToolRegistration, + type AspireResourceDebugToolResult, +} from './resourceDebugToolContracts'; +import { AspireResourceDebugToolService } from './resourceDebugToolService'; + +export class AspireResourceDebugLanguageModelTool implements vscode.LanguageModelTool { + constructor(private readonly _service: AspireResourceDebugToolService) { + } + + async prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + token: vscode.CancellationToken, + ): Promise { + const preparation = await this._service.prepare(options.input, token); + if (!preparation.canDebug) { + // There is no safe target to confirm, so never fabricate a path for the + // progress message. Invocation independently resolves and bounds its result. + return { invocationMessage: resourceDebugToolUnavailableInvocationMessage }; + } + + const resourceName = escapeMarkdown(preparation.resourceName); + const appHost = escapeMarkdown(preparation.target.displayPath); + return { + invocationMessage: resourceDebugToolInvocationMessage(resourceName), + confirmationMessages: { + title: resourceDebugToolConfirmationTitle, + message: resourceDebugToolConfirmationMessage(resourceName, appHost), + }, + }; + } + + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken, + ): Promise { + return createToolResult(await this._service.debug(options.input, token)); + } +} + +export function registerAspireResourceDebugTool(service: AspireResourceDebugToolService): AspireResourceDebugToolRegistration { + const registrations: vscode.Disposable[] = []; + + if (typeof vscode.lm?.registerTool !== 'function') { + extensionLogOutputChannel.info('Skipping Aspire resource debug language model tool: the language model tool API is unavailable.'); + } + else { + registrations.push(vscode.lm.registerTool(aspireResourceDebugToolName, new AspireResourceDebugLanguageModelTool(service))); + extensionLogOutputChannel.info('Registered Aspire resource debug language model tool.'); + } + + return { + get registered() { + return registrations.length > 0; + }, + dispose() { + registrations.forEach(registration => registration.dispose()); + registrations.length = 0; + }, + }; +} + +function createToolResult(result: AspireResourceDebugToolResult): vscode.LanguageModelToolResult { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result))]); +} + +function escapeMarkdown(value: string): string { + return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); +} diff --git a/extension/src/lm/resourceDebugToolContracts.ts b/extension/src/lm/resourceDebugToolContracts.ts new file mode 100644 index 00000000000..5061bed77ad --- /dev/null +++ b/extension/src/lm/resourceDebugToolContracts.ts @@ -0,0 +1,76 @@ +import type * as vscode from 'vscode'; + +import type { + ResourceDebugErrorKind, + ResourceDebugExtensionRequirement, + ResourceDebugger, +} from '../debugger/resourceDebugContracts'; +import type { SafeAppHostTarget, SafeAppHostTargetResolver } from './appHostLifecycleToolContracts'; + +export const aspireResourceDebugToolName = 'aspire_resource_debug'; + +export type AspireResourceDebugStrategy = 'auto' | 'attach'; + +export interface AspireResourceDebugToolInput { + readonly appHostPath: string; + readonly resourceName: string; + readonly strategy?: AspireResourceDebugStrategy; +} + +export type AspireResourceDebugToolOutcome = + | 'started' + | 'alreadyDebugging' + | 'appHostNotFound' + | 'resourceNotFound' + | 'unsupportedResource' + | 'resourceNotRunning' + | 'debuggerExtensionMissing' + | 'error' + | 'invalidInput' + | 'unknownAppHost' + | 'ambiguousAppHost' + | 'discoveryFailed' + | 'workspaceNotTrusted' + | 'cancelled' + | 'failed'; + +/** + * The entire language-model result boundary. It contains only caller-approved resource + * identity, resolver-produced display identity, and bounded debugger state. + */ +export interface AspireResourceDebugToolResult { + readonly tool: typeof aspireResourceDebugToolName; + readonly success: boolean; + readonly outcome: AspireResourceDebugToolOutcome; + readonly appHost: string; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; + readonly effectiveStrategy: 'attach' | 'none'; + readonly controller: 'editor' | 'none'; + readonly provider?: 'dotnet' | 'go'; + readonly debuggerExtensions?: readonly ResourceDebugExtensionRequirement[]; + readonly errorKind?: ResourceDebugErrorKind; +} + +export interface AspireResourceDebugToolDependencies { + readonly targetResolver: SafeAppHostTargetResolver; + readonly resourceDebugger: ResourceDebugger; +} + +export type AspireResourceDebugToolPreparation = + | { + readonly canDebug: true; + readonly target: SafeAppHostTarget; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; + } + | { + readonly canDebug: false; + readonly result: AspireResourceDebugToolResult; + }; + +export interface AspireResourceDebugToolRegistration extends vscode.Disposable { + readonly registered: boolean; +} + +export type { SafeAppHostTargetResolver, SafeAppHostTargetResolution } from './appHostLifecycleToolContracts'; diff --git a/extension/src/lm/resourceDebugToolService.ts b/extension/src/lm/resourceDebugToolService.ts new file mode 100644 index 00000000000..8ea5e248d67 --- /dev/null +++ b/extension/src/lm/resourceDebugToolService.ts @@ -0,0 +1,263 @@ +import * as vscode from 'vscode'; + +import { + type ResourceDebugExtensionRequirement, + type ResourceDebugResult, +} from '../debugger/resourceDebugContracts'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + aspireResourceDebugToolName, + type AspireResourceDebugStrategy, + type AspireResourceDebugToolDependencies, + type AspireResourceDebugToolOutcome, + type AspireResourceDebugToolPreparation, + type AspireResourceDebugToolResult, +} from './resourceDebugToolContracts'; + +const maxInputLength = 4096; + +// Invisible and bidi controls can make a confirmation differ from what the model sent. +// Match the AppHost lifecycle resolver's identity boundary before resource names reach +// either confirmation text or the resource-debug service. +const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F]|\p{Cf}/u; + +interface ParsedInput { + readonly appHostPath: string; + readonly resourceName: string; + readonly requestedStrategy: AspireResourceDebugStrategy; +} + +/** + * Owns only the language-model boundary for resource attach. AppHost discovery and debug + * lifecycle policy remain with the shared resolver and ResourceDebugger respectively. + */ +export class AspireResourceDebugToolService implements vscode.Disposable { + private _disposed = false; + + constructor(private readonly _dependencies: AspireResourceDebugToolDependencies) { + } + + dispose(): void { + this._disposed = true; + } + + /** + * Validates and resolves a confirmation target without starting a debugger. Invocation + * calls this again rather than retaining the absolute path from confirmation. + */ + async prepare(input: unknown, token: vscode.CancellationToken): Promise { + const parsed = parseInput(input); + if (!parsed) { + return this.reject('invalidInput'); + } + + if (this._disposed || token.isCancellationRequested) { + return this.reject('cancelled', '', parsed); + } + + // The manifest gate is advisory: a tool can remain registered while a workspace + // transitions into Restricted Mode, so never resolve or attach there at runtime. + if (!vscode.workspace.isTrusted) { + return this.reject('workspaceNotTrusted', '', parsed); + } + + try { + const resolution = await this._dependencies.targetResolver.resolveTarget(parsed.appHostPath, token); + if (token.isCancellationRequested) { + return this.reject('cancelled', '', parsed); + } + + if (!resolution.resolved) { + return this.reject(resolution.outcome, '', parsed); + } + + return { + canDebug: true, + target: resolution.target, + resourceName: parsed.resourceName, + requestedStrategy: parsed.requestedStrategy, + }; + } + catch (error) { + return this.reject(isCommandCancellation(error) || token.isCancellationRequested ? 'cancelled' : 'failed', '', parsed); + } + } + + async debug(input: unknown, token: vscode.CancellationToken): Promise { + const preparation = await this.prepare(input, token); + if (!preparation.canDebug) { + return preparation.result; + } + + try { + const result = await this._dependencies.resourceDebugger.debug({ + source: 'languageModelTool', + appHost: preparation.target, + resourceName: preparation.resourceName, + cancellationToken: token, + }); + return mapResourceDebugResult(result, preparation.target.displayPath, preparation.resourceName, preparation.requestedStrategy); + } + catch (error) { + return this.createResult( + isCommandCancellation(error) || token.isCancellationRequested ? 'cancelled' : 'failed', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + } + + private reject( + outcome: Extract< + AspireResourceDebugToolOutcome, + 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'workspaceNotTrusted' | 'cancelled' | 'failed' + >, + appHost = '', + parsed?: ParsedInput, + ): AspireResourceDebugToolPreparation { + return { + canDebug: false, + result: this.createResult( + outcome, + appHost, + parsed?.resourceName ?? '', + parsed?.requestedStrategy ?? 'auto'), + }; + } + + private createResult( + outcome: AspireResourceDebugToolOutcome, + appHost: string, + resourceName: string, + requestedStrategy: AspireResourceDebugStrategy, + ): AspireResourceDebugToolResult { + return { + tool: aspireResourceDebugToolName, + success: false, + outcome, + appHost, + resourceName, + requestedStrategy, + effectiveStrategy: 'none', + controller: 'none', + }; + } +} + +function parseInput(value: unknown): ParsedInput | undefined { + try { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + + const input = value as Record; + const properties = Reflect.ownKeys(input); + if (properties.some(property => + property !== 'appHostPath' && + property !== 'resourceName' && + property !== 'strategy') || + !Object.prototype.hasOwnProperty.call(input, 'appHostPath') || + !Object.prototype.hasOwnProperty.call(input, 'resourceName')) { + return undefined; + } + + const appHostPath = input.appHostPath; + const resourceName = input.resourceName; + const strategy = input.strategy; + if (!isSafeNonBlankString(appHostPath) || + !isSafeNonBlankString(resourceName) || + (strategy !== undefined && strategy !== 'auto' && strategy !== 'attach')) { + return undefined; + } + + return { + appHostPath, + resourceName, + requestedStrategy: strategy ?? 'auto', + }; + } + catch { + // JSON-shaped tool input normally has data properties, but malformed extension-host + // objects can use getters or proxies. Treating a throwing getter as invalid keeps + // its message out of both the model transcript and the extension's control flow. + return undefined; + } +} + +function isSafeNonBlankString(value: unknown): value is string { + return typeof value === 'string' && + value.trim().length > 0 && + value.length <= maxInputLength && + !identityChangingCharacters.test(value); +} + +function mapResourceDebugResult( + result: ResourceDebugResult, + appHost: string, + resourceName: string, + requestedStrategy: AspireResourceDebugStrategy, +): AspireResourceDebugToolResult { + const base = { + tool: aspireResourceDebugToolName, + appHost, + resourceName, + requestedStrategy, + } as const; + + switch (result.outcome) { + case 'started': + return { + ...base, + success: true, + outcome: 'started', + effectiveStrategy: 'attach', + controller: 'editor', + provider: result.providerId, + }; + case 'alreadyDebugging': + return { + ...base, + success: true, + outcome: 'alreadyDebugging', + effectiveStrategy: 'attach', + controller: 'editor', + }; + case 'debuggerExtensionMissing': + return { + ...base, + success: false, + outcome: 'debuggerExtensionMissing', + effectiveStrategy: 'none', + controller: 'none', + debuggerExtensions: result.debuggerExtensions.map(toSafeDebuggerRequirement), + }; + case 'error': + return { + ...base, + success: false, + outcome: 'error', + effectiveStrategy: 'none', + controller: 'none', + errorKind: result.errorKind, + }; + case 'appHostNotFound': + case 'resourceNotFound': + case 'unsupportedResource': + case 'resourceNotRunning': + case 'cancelled': + return { + ...base, + success: false, + outcome: result.outcome, + effectiveStrategy: 'none', + controller: 'none', + }; + } +} + +function toSafeDebuggerRequirement(requirement: ResourceDebugExtensionRequirement): ResourceDebugExtensionRequirement { + return { + id: requirement.id, + label: requirement.label, + }; +} diff --git a/extension/src/lm/resourceDebugTools.ts b/extension/src/lm/resourceDebugTools.ts new file mode 100644 index 00000000000..c9969ed78c1 --- /dev/null +++ b/extension/src/lm/resourceDebugTools.ts @@ -0,0 +1,17 @@ +export { aspireResourceDebugToolName } from './resourceDebugToolContracts'; +export type { + AspireResourceDebugStrategy, + AspireResourceDebugToolDependencies, + AspireResourceDebugToolInput, + AspireResourceDebugToolOutcome, + AspireResourceDebugToolPreparation, + AspireResourceDebugToolRegistration, + AspireResourceDebugToolResult, + SafeAppHostTargetResolver, + SafeAppHostTargetResolution, +} from './resourceDebugToolContracts'; +export { AspireResourceDebugToolService } from './resourceDebugToolService'; +export { + AspireResourceDebugLanguageModelTool, + registerAspireResourceDebugTool, +} from './resourceDebugToolAdapters'; diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index d2863ae9459..96879b9a1fa 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -279,3 +279,7 @@ export const appHostLifecycleUnspecifiedMode = vscode.l10n.t('unspecified'); export const appHostLifecycleUnresolvedPath = vscode.l10n.t('an unresolved path'); export const appHostLifecycleBusy = vscode.l10n.t('Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.'); export const appHostLifecycleLaunchAlreadyClaimed = vscode.l10n.t('This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.'); +export const resourceDebugToolConfirmationTitle = vscode.l10n.t('Attach debugger to Aspire resource'); +export const resourceDebugToolConfirmationMessage = (resourceName: string, appHostPath: string) => vscode.l10n.t('Attach the debugger to resource {0} from Aspire AppHost {1}?', resourceName, appHostPath); +export const resourceDebugToolInvocationMessage = (resourceName: string) => vscode.l10n.t('Attaching debugger to Aspire resource {0}...', resourceName); +export const resourceDebugToolUnavailableInvocationMessage = vscode.l10n.t('Unable to attach debugger to the requested Aspire resource.'); diff --git a/extension/src/test/appHostLifecycleTools.test.ts b/extension/src/test/appHostLifecycleTools.test.ts index 9e84cb87316..c6d05a1b3a1 100644 --- a/extension/src/test/appHostLifecycleTools.test.ts +++ b/extension/src/test/appHostLifecycleTools.test.ts @@ -355,9 +355,12 @@ suite('AppHost lifecycle language model tools', () => { const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; const tools = manifest.contributes.languageModelTools ?? []; - assert.deepStrictEqual(tools.map(tool => tool.name), [aspireAppHostStartToolName, aspireAppHostStopToolName]); + const lifecycleTools = tools.filter(tool => + tool.name === aspireAppHostStartToolName || + tool.name === aspireAppHostStopToolName); + assert.deepStrictEqual(lifecycleTools.map(tool => tool.name), [aspireAppHostStartToolName, aspireAppHostStopToolName]); - for (const tool of tools) { + for (const tool of lifecycleTools) { for (const localizedField of ['displayName', 'modelDescription', 'userDescription']) { const reference = tool[localizedField] as string; assert.match(reference, /^%[\w.-]+%$/, `${tool.name}.${localizedField} must be a package.nls reference.`); diff --git a/extension/src/test/resourceDebugTools.test.ts b/extension/src/test/resourceDebugTools.test.ts new file mode 100644 index 00000000000..92ce3af1090 --- /dev/null +++ b/extension/src/test/resourceDebugTools.test.ts @@ -0,0 +1,542 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; + +import type { + ResourceDebugger, + ResourceDebugRequest, + ResourceDebugResult, +} from '../debugger/resourceDebugContracts'; +import { + AppHostLifecycleToolService, + aspireAppHostStartToolName, + aspireAppHostStopToolName, +} from '../lm/appHostLifecycleTools'; +import { + AspireResourceDebugLanguageModelTool, + AspireResourceDebugToolService, + aspireResourceDebugToolName, + registerAspireResourceDebugTool, + type AspireResourceDebugToolInput, + type AspireResourceDebugToolResult, + type SafeAppHostTargetResolver, + type SafeAppHostTargetResolution, +} from '../lm/resourceDebugTools'; + +const absoluteAppHostPath = '/private/workspace/AppHost/AppHost.csproj'; +const safeAppHostPath = 'AppHost/AppHost.csproj'; + +class FakeTargetResolver implements SafeAppHostTargetResolver { + calls = 0; + results: SafeAppHostTargetResolution[] = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + displayPath: safeAppHostPath, + }, + }]; + error: Error | undefined; + onResolve: (() => void) | undefined; + + async resolveTarget(_rawAppHost: unknown, token: vscode.CancellationToken): Promise { + this.calls++; + this.onResolve?.(); + if (token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + if (this.error) { + throw this.error; + } + + return this.results[Math.min(this.calls - 1, this.results.length - 1)]; + } +} + +class FakeResourceDebugger implements ResourceDebugger { + calls: ResourceDebugRequest[] = []; + result: ResourceDebugResult = { outcome: 'started', providerId: 'dotnet' }; + error: Error | undefined; + onDebug: (() => void) | undefined; + + async debug(request: ResourceDebugRequest): Promise { + this.calls.push(request); + this.onDebug?.(); + if (request.cancellationToken?.isCancellationRequested) { + return { outcome: 'cancelled' }; + } + + if (this.error) { + throw this.error; + } + + return this.result; + } + + canAttachToResource(): boolean { + return true; + } +} + +function readToolResultPayload(result: vscode.LanguageModelToolResult): AspireResourceDebugToolResult { + const parts = result.content as Array<{ value?: unknown }>; + assert.strictEqual(parts.length, 1, 'Tool results must be a single bounded content part.'); + assert.strictEqual(typeof parts[0]?.value, 'string'); + return JSON.parse(parts[0].value as string) as AspireResourceDebugToolResult; +} + +function createService( + targetResolver = new FakeTargetResolver(), + resourceDebugger = new FakeResourceDebugger(), +): { + readonly service: AspireResourceDebugToolService; + readonly targetResolver: FakeTargetResolver; + readonly resourceDebugger: FakeResourceDebugger; +} { + return { + service: new AspireResourceDebugToolService({ targetResolver, resourceDebugger }), + targetResolver, + resourceDebugger, + }; +} + +function createInput(overrides: Record = {}): Record { + return { + appHostPath: safeAppHostPath, + resourceName: 'api', + ...overrides, + }; +} + +suite('Aspire resource debug language model tool', () => { + let isTrustedStub: sinon.SinonStub; + + setup(() => { + isTrustedStub = sinon.stub(vscode.workspace, 'isTrusted').value(true); + }); + + teardown(() => { + isTrustedStub.restore(); + sinon.restore(); + }); + + suite('manifest and localization', () => { + test('contributes the localized resource debug tool contract without changing lifecycle tools', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.json'), 'utf8')) as { + activationEvents?: string[]; + contributes: { languageModelTools?: Array> }; + }; + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const tools = manifest.contributes.languageModelTools ?? []; + const tool = tools.find(candidate => candidate.name === aspireResourceDebugToolName); + + assert.ok(tool); + assert.strictEqual(tool.toolReferenceName, 'aspireDebugResource'); + assert.strictEqual(tool.icon, '$(debug-alt)'); + assert.strictEqual(tool.canBeReferencedInPrompt, true); + assert.strictEqual(tool.when, 'isWorkspaceTrusted'); + assert.deepStrictEqual(tool.tags, ['aspire', 'debug', 'resource']); + assert.ok(manifest.activationEvents?.includes(`onLanguageModelTool:${aspireResourceDebugToolName}`)); + + for (const field of ['displayName', 'modelDescription', 'userDescription']) { + const reference = tool[field] as string; + assert.match(reference, /^%[\w.-]+%$/); + assert.ok(packageNls[reference.slice(1, -1)]); + } + + assert.deepStrictEqual(tool.inputSchema, { + type: 'object', + properties: { + appHostPath: { type: 'string' }, + resourceName: { type: 'string' }, + strategy: { + type: 'string', + enum: ['auto', 'attach'], + default: 'auto', + }, + }, + required: ['appHostPath', 'resourceName'], + additionalProperties: false, + }); + assert.deepStrictEqual( + tools + .filter(candidate => candidate.name === aspireAppHostStartToolName || candidate.name === aspireAppHostStopToolName) + .map(candidate => [candidate.name, candidate.toolReferenceName]), + [ + [aspireAppHostStartToolName, 'aspireStartAppHost'], + [aspireAppHostStopToolName, 'aspireStopAppHost'], + ]); + }); + + test('adds localized manifest and runtime strings for the confirmation', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + + assert.deepStrictEqual( + { + title: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationTitle'], + message: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationMessage'], + invocation: packageNls['aspire-vscode.strings.resourceDebugToolInvocationMessage'], + display: packageNls['languageModelTool.aspireResourceDebug.displayName'], + model: packageNls['languageModelTool.aspireResourceDebug.modelDescription'], + user: packageNls['languageModelTool.aspireResourceDebug.userDescription'], + }, + { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to resource {0} from Aspire AppHost {1}?', + invocation: 'Attaching debugger to Aspire resource {0}...', + display: 'Debug Aspire resource', + model: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', + user: 'Attach the debugger to a running Aspire resource.', + }); + }); + }); + + suite('registration', () => { + test('registers and disposes the resource debug tool once', () => { + const { service } = createService(); + const disposed: string[] = []; + const registerToolStub = sinon.stub(vscode.lm, 'registerTool').callsFake((name: string) => + new vscode.Disposable(() => disposed.push(name))); + + const registration = registerAspireResourceDebugTool(service); + + assert.strictEqual(registration.registered, true); + assert.deepStrictEqual(registerToolStub.getCalls().map(call => call.args[0]), [aspireResourceDebugToolName]); + registration.dispose(); + assert.deepStrictEqual(disposed, [aspireResourceDebugToolName]); + }); + + test('does not register when the language model tool API is unavailable', () => { + const { service } = createService(); + const registerToolStub = sinon.stub(vscode.lm, 'registerTool').value(undefined); + + const registration = registerAspireResourceDebugTool(service); + + assert.strictEqual(registration.registered, false); + registration.dispose(); + registerToolStub.restore(); + }); + }); + + suite('input and target resolution', () => { + test('rejects invalid input and additional properties before resolving or debugging', async () => { + const throwingInput = { resourceName: 'api' }; + Object.defineProperty(throwingInput, 'appHostPath', { + enumerable: true, + get: () => { + throw new Error('token=super-secret'); + }, + }); + const hiddenAdditionalPropertyInput = createInput(); + Object.defineProperty(hiddenAdditionalPropertyInput, 'hidden', { + value: 'unexpected', + }); + const invalidInputs: unknown[] = [ + undefined, + null, + [], + { appHostPath: safeAppHostPath }, + { resourceName: 'api' }, + createInput({ appHostPath: ' ' }), + createInput({ appHostPath: 'AppHost/\u200bAppHost.csproj' }), + createInput({ resourceName: '\t' }), + createInput({ resourceName: 'api\u200b' }), + createInput({ strategy: 'restart' }), + createInput({ unexpected: 'value' }), + throwingInput, + hiddenAdditionalPropertyInput, + ]; + + for (const input of invalidInputs) { + const { service, targetResolver, resourceDebugger } = createService(); + const result = await service.debug(input, new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(result, { + tool: aspireResourceDebugToolName, + success: false, + outcome: 'invalidInput', + appHost: '', + resourceName: '', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }); + assert.strictEqual(targetResolver.calls, 0); + assert.strictEqual(resourceDebugger.calls.length, 0); + } + }); + + test('defaults and explicitly maps auto and attach to attach', async () => { + for (const [input, requestedStrategy] of [ + [createInput(), 'auto'], + [createInput({ strategy: 'auto' }), 'auto'], + [createInput({ strategy: 'attach' }), 'attach'], + ] as const) { + const { service, resourceDebugger } = createService(); + const result = await service.debug(input, new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + success: result.success, + requestedStrategy: result.requestedStrategy, + effectiveStrategy: result.effectiveStrategy, + controller: result.controller, + }, + { + success: true, + requestedStrategy, + effectiveStrategy: 'attach', + controller: 'editor', + }); + assert.strictEqual(resourceDebugger.calls[0].source, 'languageModelTool'); + } + }); + + test('rejects untrusted workspaces without resolving or debugging', async () => { + isTrustedStub.value(false); + const { service, targetResolver, resourceDebugger } = createService(); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'workspaceNotTrusted'); + assert.strictEqual(targetResolver.calls, 0); + assert.strictEqual(resourceDebugger.calls.length, 0); + }); + + test('maps missing, ambiguous, and failed AppHost resolution without leaking a target', async () => { + for (const outcome of ['unknownAppHost', 'ambiguousAppHost', 'discoveryFailed'] as const) { + const resolver = new FakeTargetResolver(); + resolver.results = [{ resolved: false, outcome }]; + const { service, resourceDebugger } = createService(resolver); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + outcome: result.outcome, + appHost: result.appHost, + controller: result.controller, + effectiveStrategy: result.effectiveStrategy, + }, + { + outcome, + appHost: '', + controller: 'none', + effectiveStrategy: 'none', + }); + assert.strictEqual(resourceDebugger.calls.length, 0); + } + }); + + test('retains the resolver safe multi-root display path and never returns its absolute target', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ + resolved: true, + target: { + absolutePath: '/private/workspace/backend/AppHost/AppHost.csproj', + displayPath: 'backend/AppHost/AppHost.csproj', + }, + }]; + const { service, resourceDebugger } = createService(resolver); + + const result = await service.debug( + createInput({ appHostPath: 'backend/AppHost/AppHost.csproj' }), + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.appHost, 'backend/AppHost/AppHost.csproj'); + assert.strictEqual(resourceDebugger.calls[0].appHost.absolutePath, '/private/workspace/backend/AppHost/AppHost.csproj'); + assert.strictEqual(JSON.stringify(result).includes('/private/workspace'), false); + }); + }); + + suite('confirmation and invocation', () => { + test('confirms only the user resource name and safe AppHost display path', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + displayPath: 'backend/AppHost/AppHost.csproj', + }, + }]; + const { service } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: createInput({ appHostPath: 'backend/AppHost/AppHost.csproj', resourceName: 'api' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const confirmation = `${prepared.confirmationMessages?.title}\n${prepared.confirmationMessages?.message}\n${prepared.invocationMessage}`; + + assert.strictEqual(prepared.confirmationMessages?.title, 'Attach debugger to Aspire resource'); + assert.strictEqual(prepared.confirmationMessages?.message, 'Attach the debugger to resource api from Aspire AppHost backend/AppHost/AppHost.csproj?'); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to Aspire resource api...'); + assert.strictEqual(confirmation.includes(absoluteAppHostPath), false); + assert.strictEqual(confirmation.includes('pid'), false); + assert.strictEqual(confirmation.includes('debug configuration'), false); + }); + + test('does not invent an AppHost path when confirmation resolution fails', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [{ resolved: false, outcome: 'unknownAppHost' }]; + const { service } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: createInput({ appHostPath: '../private/token=secret' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(prepared.confirmationMessages, undefined); + assert.strictEqual(prepared.invocationMessage, 'Unable to attach debugger to the requested Aspire resource.'); + }); + + test('re-resolves the AppHost immediately after confirmation', async () => { + const resolver = new FakeTargetResolver(); + resolver.results = [ + { + resolved: true, + target: { + absolutePath: '/private/workspace/first/AppHost.csproj', + displayPath: 'first/AppHost.csproj', + }, + }, + { + resolved: true, + target: { + absolutePath: '/private/workspace/second/AppHost.csproj', + displayPath: 'second/AppHost.csproj', + }, + }, + ]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput({ appHostPath: 'first/AppHost.csproj' }); + + const prepared = await tool.prepareInvocation({ input: input as unknown as AspireResourceDebugToolInput }, new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.strictEqual(prepared.confirmationMessages?.message, 'Attach the debugger to resource api from Aspire AppHost first/AppHost.csproj?'); + assert.strictEqual(result.appHost, 'second/AppHost.csproj'); + assert.strictEqual(resourceDebugger.calls[0].appHost.absolutePath, '/private/workspace/second/AppHost.csproj'); + }); + }); + + suite('cancellation and result mapping', () => { + test('maps cancellation before and during resolution or debugging without side effects after cancellation', async () => { + const before = createService(); + const beforeToken = new vscode.CancellationTokenSource(); + beforeToken.cancel(); + assert.strictEqual((await before.service.debug(createInput(), beforeToken.token)).outcome, 'cancelled'); + assert.strictEqual(before.targetResolver.calls, 0); + + const duringResolution = createService(); + const resolveToken = new vscode.CancellationTokenSource(); + duringResolution.targetResolver.onResolve = () => resolveToken.cancel(); + assert.strictEqual((await duringResolution.service.debug(createInput(), resolveToken.token)).outcome, 'cancelled'); + assert.strictEqual(duringResolution.resourceDebugger.calls.length, 0); + + const duringDebug = createService(); + const debugToken = new vscode.CancellationTokenSource(); + duringDebug.resourceDebugger.onDebug = () => debugToken.cancel(); + assert.strictEqual((await duringDebug.service.debug(createInput(), debugToken.token)).outcome, 'cancelled'); + }); + + test('maps every bounded resource debug result', async () => { + const cases: Array<{ + readonly result: ResourceDebugResult; + readonly expected: Pick; + }> = [ + { result: { outcome: 'started', providerId: 'dotnet' }, expected: { success: true, outcome: 'started', effectiveStrategy: 'attach', controller: 'editor', provider: 'dotnet', errorKind: undefined } }, + { result: { outcome: 'alreadyDebugging' }, expected: { success: true, outcome: 'alreadyDebugging', effectiveStrategy: 'attach', controller: 'editor', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'appHostNotFound' }, expected: { success: false, outcome: 'appHostNotFound', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'resourceNotFound' }, expected: { success: false, outcome: 'resourceNotFound', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'unsupportedResource' }, expected: { success: false, outcome: 'unsupportedResource', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'resourceNotRunning' }, expected: { success: false, outcome: 'resourceNotRunning', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + { result: { outcome: 'cancelled' }, expected: { success: false, outcome: 'cancelled', effectiveStrategy: 'none', controller: 'none', provider: undefined, errorKind: undefined } }, + ...(['resourceSnapshotFailed', 'providerResolutionFailed', 'configurationFailed', 'debuggerStartDeclined', 'debuggerStartFailed', 'unexpected'] as const).map(errorKind => ({ + result: { outcome: 'error', errorKind } as ResourceDebugResult, + expected: { success: false, outcome: 'error' as const, effectiveStrategy: 'none' as const, controller: 'none' as const, provider: undefined, errorKind }, + })), + ]; + + for (const testCase of cases) { + const { service, resourceDebugger } = createService(); + resourceDebugger.result = testCase.result; + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual( + { + success: result.success, + outcome: result.outcome, + effectiveStrategy: result.effectiveStrategy, + controller: result.controller, + provider: result.provider, + errorKind: result.errorKind, + }, + testCase.expected); + } + }); + + test('returns only safe C# and Go debugger requirements', async () => { + for (const [id, label] of [ + ['ms-dotnettools.csharp', 'C#'], + ['golang.go', 'Go'], + ]) { + const { service, resourceDebugger } = createService(); + resourceDebugger.result = { + outcome: 'debuggerExtensionMissing', + debuggerExtensions: [{ id, label, installMessage: 'token=super-secret /private/debug.json --args bad' }], + }; + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(result.debuggerExtensions, [{ id, label }]); + assert.strictEqual(result.provider, undefined); + assert.strictEqual(result.success, false); + assert.strictEqual(JSON.stringify(result).includes('super-secret'), false); + } + }); + + test('converts unexpected exceptions to a bounded, valid JSON result without sensitive data', async () => { + const { service, resourceDebugger } = createService(); + resourceDebugger.error = new Error('token=super-secret pid=42 /private/debug.json --configuration {"process":"dotnet"} https://private.example args=unsafe'); + const tool = new AspireResourceDebugLanguageModelTool(service); + + const languageModelResult = await tool.invoke( + { input: createInput() as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token); + const payload = readToolResultPayload(languageModelResult); + const serialized = JSON.stringify(payload); + + assert.deepStrictEqual( + { + outcome: payload.outcome, + success: payload.success, + appHost: payload.appHost, + effectiveStrategy: payload.effectiveStrategy, + controller: payload.controller, + }, + { + outcome: 'failed', + success: false, + appHost: safeAppHostPath, + effectiveStrategy: 'none', + controller: 'none', + }); + for (const forbidden of ['super-secret', '/private/', 'pid=42', 'dotnet', 'private.example', 'args=unsafe', 'debug.json']) { + assert.strictEqual(serialized.includes(forbidden), false, `Tool result leaked ${forbidden}.`); + } + assert.deepStrictEqual(JSON.parse(serialized), payload); + }); + }); + + test('continues to expose the existing lifecycle resolver without invoking lifecycle policy', () => { + assert.strictEqual(typeof AppHostLifecycleToolService.prototype.resolveTarget, 'function'); + }); +}); From 2fcb7309348806b49cbfb457846166899117f1d0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 06:31:35 -0400 Subject: [PATCH 60/90] fix(extension): harden resource debug tool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/README.md | 7 +- extension/loc/xlf/aspire-vscode.xlf | 12 + extension/package.json | 9 +- extension/package.nls.json | 4 + .../src/debugger/resourceDebugContracts.ts | 8 + .../src/debugger/resourceDebugService.ts | 33 ++- .../src/debugger/resourceDebugTelemetry.ts | 12 +- extension/src/extension.ts | 17 +- .../src/lm/appHostLifecycleToolAdapters.ts | 8 +- .../src/lm/appHostLifecycleToolContracts.ts | 76 ++--- .../src/lm/appHostLifecycleToolService.ts | 260 +----------------- .../src/lm/appHostTargetResolverContracts.ts | 45 +++ .../src/lm/appHostTargetResolverService.ts | 245 +++++++++++++++++ .../src/lm/languageModelToolContracts.ts | 17 ++ extension/src/lm/markdown.ts | 15 + extension/src/lm/resourceDebugToolAdapters.ts | 33 ++- .../src/lm/resourceDebugToolContracts.ts | 22 +- extension/src/lm/resourceDebugToolService.ts | 35 ++- extension/src/loc/strings.ts | 1 + .../src/test-e2e/packageSurface.e2e.test.ts | 51 ++++ .../src/test/appHostLifecycleTools.test.ts | 16 +- .../src/test/resourceDebugService.test.ts | 48 ++++ extension/src/test/resourceDebugTools.test.ts | 114 +++++++- extension/src/testing/e2eStateFileBridge.ts | 10 +- .../src/views/AspireAppHostTreeProvider.ts | 1 + 25 files changed, 728 insertions(+), 371 deletions(-) create mode 100644 extension/src/lm/appHostTargetResolverContracts.ts create mode 100644 extension/src/lm/appHostTargetResolverService.ts create mode 100644 extension/src/lm/languageModelToolContracts.ts create mode 100644 extension/src/lm/markdown.ts diff --git a/extension/README.md b/extension/README.md index 163901cd256..e6914b3296c 100644 --- a/extension/README.md +++ b/extension/README.md @@ -106,7 +106,7 @@ The dashboard gives you a live view of your running app — all your resources a ## Chat Tools for Agents -The extension contributes two Language Model tools so chat agents start and stop your apphost through the same lifecycle operations as the Aspire view: +The extension contributes three Language Model tools so chat agents can use the same AppHost and resource-debug operations as the Aspire view: When VS Code is active, agents should prefer these editor operations over running Aspire AppHost lifecycle commands in a terminal. @@ -114,8 +114,11 @@ When VS Code is active, agents should prefer these editor operations over runnin |------|-------------------|--------------| | `aspire_apphost_start` | `#aspireStartAppHost` | Starts an apphost Aspire already discovered in your workspace, in `run` (no debugger) or `debug` (debugger attached) mode | | `aspire_apphost_stop` | `#aspireStopAppHost` | Stops a running apphost Aspire discovered in this workspace | +| `aspire_resource_debug` | `#aspireDebugResource` | Attaches the debugger to a running resource from a discovered AppHost; `auto` and `attach` currently attach only | -Both tools take the workspace-relative path of an apphost Aspire has already discovered — the same list the Aspire view shows — and resolve it against that list rather than against your filesystem. An agent can only name an apphost Aspire found, so it cannot point these tools at an arbitrary file, and the path shown in the confirmation is that discovered apphost's own path rather than anything the agent supplied. Absolute paths are rejected; in a multi-root workspace, always prefix the path with the workspace folder name. Both tools ask a chat agent's user to confirm before doing anything, and only work in a [trusted workspace](https://code.visualstudio.com/docs/editing/workspaces/workspace-trust). They never pick an apphost for you: a path that names no discovered apphost, or more than one, fails and reports back the apphosts you can name. Starting an apphost that is already starting or running does not launch a second one. Stopping an editor-created apphost coordinates its Aspire debug session; stopping an apphost started from a terminal delegates to `aspire stop --apphost` for the same discovered path. The extension does not kill arbitrary processes. If it cannot determine which apphosts exist or whether the selected apphost is running, the call reports a failure rather than claiming nothing is there. +All three tools take the workspace-relative path of an AppHost Aspire already discovered — the same list the Aspire view shows — and resolve it against that list rather than against your filesystem. An agent can only name an AppHost Aspire found, so it cannot point a tool at an arbitrary file. Absolute paths are rejected; in a multi-root workspace, prefix the path with the workspace folder name. The resource tool also takes a running resource name from that AppHost. `auto` does not start or restart a resource; it currently resolves to debugger attach. + +These tools only work in a [trusted workspace](https://code.visualstudio.com/docs/editing/workspaces/workspace-trust), and VS Code asks the chat user to confirm every invocation. When a target resolves during preparation, the confirmation shows the discovered AppHost identity. If it cannot resolve then, VS Code still shows a generic confirmation with no untrusted path text; invocation resolves the target again and fails safely if it is invalid, untrusted, or unavailable. The tools never pick an AppHost for you: a path that names no discovered AppHost, or more than one, fails and reports the AppHosts you can name. Starting an AppHost that is already starting or running does not launch a second one. Stopping an editor-created AppHost coordinates its Aspire debug session; stopping an AppHost started from a terminal delegates to `aspire stop --apphost` for the same discovered path. The extension does not kill arbitrary processes. Resource attach returns a safe failure when the resource is stopped, unsupported, or missing its debugger extension. --- diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 612817944df..551943db433 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -103,6 +103,9 @@ Attach the debugger to resource {0} from Aspire AppHost {1}? + + Attach the debugger to the requested Aspire resource? + Attaching debugger to Aspire resource {0}... @@ -244,6 +247,9 @@ Debug Aspire resource + + Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported. + Debug with Chrome @@ -475,6 +481,9 @@ Legacy setting for Aspire Dashboard launch behavior. Use Aspire: Dashboard Browser instead. Explicit Aspire: Dashboard Browser none or notification values win. Otherwise, legacy notification or off values override browser-opening choices; launch uses Aspire: Dashboard Browser, falling back to VS Code's integrated browser for compatibility. + + Name of a running resource from the selected AppHost. Resource names are limited to 256 characters. + New Aspire project @@ -958,6 +967,9 @@ Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example 'AppHost/AppHost.csproj' or 'apphost.cs'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example 'backend/AppHost/AppHost.csproj'). + + Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name. + Yes diff --git a/extension/package.json b/extension/package.json index d2113123928..97219a5403c 100644 --- a/extension/package.json +++ b/extension/package.json @@ -140,10 +140,12 @@ "type": "object", "properties": { "appHostPath": { - "type": "string" + "type": "string", + "description": "%languageModelTool.aspireResourceDebug.appHostPath.description%" }, "resourceName": { - "type": "string" + "type": "string", + "description": "%languageModelTool.aspireResourceDebug.resourceName.description%" }, "strategy": { "type": "string", @@ -151,7 +153,8 @@ "auto", "attach" ], - "default": "auto" + "default": "auto", + "description": "%languageModelTool.aspireResourceDebug.strategy.description%" } }, "required": [ diff --git a/extension/package.nls.json b/extension/package.nls.json index 04710feb344..e9c7f53875a 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -323,6 +323,7 @@ "aspire-vscode.strings.appHostLifecycleLaunchAlreadyClaimed": "This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.", "aspire-vscode.strings.resourceDebugToolConfirmationTitle": "Attach debugger to Aspire resource", "aspire-vscode.strings.resourceDebugToolConfirmationMessage": "Attach the debugger to resource {0} from Aspire AppHost {1}?", + "aspire-vscode.strings.resourceDebugToolUnresolvedConfirmationMessage": "Attach the debugger to the requested Aspire resource?", "aspire-vscode.strings.resourceDebugToolInvocationMessage": "Attaching debugger to Aspire resource {0}...", "aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage": "Unable to attach debugger to the requested Aspire resource.", "languageModelTool.aspireAppHostStart.displayName": "Start Aspire AppHost", @@ -336,5 +337,8 @@ "languageModelTool.aspireResourceDebug.displayName": "Debug Aspire resource", "languageModelTool.aspireResourceDebug.modelDescription": "Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.", "languageModelTool.aspireResourceDebug.userDescription": "Attach the debugger to a running Aspire resource.", + "languageModelTool.aspireResourceDebug.appHostPath.description": "Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.", + "languageModelTool.aspireResourceDebug.resourceName.description": "Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.", + "languageModelTool.aspireResourceDebug.strategy.description": "Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.", "command.openDashboardToSide": "Open Aspire Dashboard to the Side" } diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index 9403bd51b88..bd443e294b7 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -4,6 +4,13 @@ export type ResourceDebugSource = 'tree' | 'languageModelTool'; export type ResourceAttachProviderId = 'dotnet' | 'go'; +/** + * The caller's requested behavior. `auto` is intentionally bounded to the same attach + * action as `attach` today; the debug service owns that selection so callers cannot + * introduce start or restart behavior by interpreting it themselves. + */ +export type ResourceDebugStrategy = 'auto' | 'attach'; + /** * An AppHost selected by a caller. The absolute path remains internal to the editor * control plane; only the safe display path may be used by presentation layers. @@ -15,6 +22,7 @@ export interface ResourceDebugAppHostTarget { export interface ResourceDebugRequest { readonly source: ResourceDebugSource; + readonly strategy: ResourceDebugStrategy; readonly appHost: ResourceDebugAppHostTarget; readonly resourceName: string; readonly cancellationToken?: vscode.CancellationToken; diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index d963bb5143d..412dfb05ec7 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -11,6 +11,7 @@ import { type ResourceDebugger, type ResourceDebugRequest, type ResourceDebugResult, + type ResourceDebugStrategy, } from './resourceDebugContracts'; import { ResourceAttachProviderRegistry } from './resourceAttachProviders'; import { ResourceDebugSessionRegistry } from './resourceDebugSessionRegistry'; @@ -81,11 +82,22 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } async debug(request: ResourceDebugRequest): Promise { - const telemetry = new ResourceDebugOperationTelemetry(this._telemetry, this._clock, request.source); + const requestedStrategy = getRequestedStrategy(request.strategy); + const effectiveStrategy = selectEffectiveStrategy(requestedStrategy); + const telemetry = new ResourceDebugOperationTelemetry( + this._telemetry, + this._clock, + request.source, + requestedStrategy ?? 'auto'); telemetry.recordStart(); let result: ResourceDebugResult = { outcome: 'error', errorKind: 'unexpected' }; try { + if (effectiveStrategy === undefined) { + result = { outcome: 'error', errorKind: 'unexpected' }; + return result; + } + if (request.cancellationToken?.isCancellationRequested) { result = { outcome: 'cancelled' }; return result; @@ -329,6 +341,7 @@ class ResourceDebugOperationTelemetry { private readonly _telemetry: ResourceDebugTelemetry, private readonly _clock: ResourceDebugClock, private readonly _source: ResourceDebugRequest['source'], + private readonly _requestedStrategy: ResourceDebugStrategy, ) { this._startedAt = this._getTimestamp(); } @@ -336,7 +349,7 @@ class ResourceDebugOperationTelemetry { recordStart(): void { this._record(() => this._telemetry.recordStart({ source: this._source, - requested_strategy: 'attach', + requested_strategy: this._requestedStrategy, controller: 'editor', })); } @@ -384,7 +397,7 @@ class ResourceDebugOperationTelemetry { source: this._source, provider: this._provider, ...(this._resourceType === undefined ? {} : { resource_type: this._resourceType }), - requested_strategy: 'attach', + requested_strategy: this._requestedStrategy, effective_strategy: result.outcome === 'started' || result.outcome === 'alreadyDebugging' ? 'attach' : 'none', @@ -454,3 +467,17 @@ function getResourceTypeBucket(resourceType: unknown): ResourceDebugResourceType return 'other'; } } + +function getRequestedStrategy(strategy: unknown): ResourceDebugStrategy | undefined { + return strategy === 'auto' || strategy === 'attach' ? strategy : undefined; +} + +function selectEffectiveStrategy(strategy: ResourceDebugStrategy | undefined): 'attach' | undefined { + switch (strategy) { + case 'auto': + case 'attach': + return 'attach'; + default: + return undefined; + } +} diff --git a/extension/src/debugger/resourceDebugTelemetry.ts b/extension/src/debugger/resourceDebugTelemetry.ts index 83f32bd6390..edc0e502e11 100644 --- a/extension/src/debugger/resourceDebugTelemetry.ts +++ b/extension/src/debugger/resourceDebugTelemetry.ts @@ -1,4 +1,10 @@ -import { type ResourceAttachProviderId, type ResourceDebugErrorKind, type ResourceDebugResult, type ResourceDebugSource } from './resourceDebugContracts'; +import { + type ResourceAttachProviderId, + type ResourceDebugErrorKind, + type ResourceDebugResult, + type ResourceDebugSource, + type ResourceDebugStrategy, +} from './resourceDebugContracts'; import { sendTelemetryEvent } from '../utils/telemetry'; export type ResourceDebugResourceType = 'project' | 'executable' | 'container' | 'other'; @@ -11,7 +17,7 @@ export interface ResourceDebugClock { export interface ResourceDebugStartTelemetryProperties { readonly source: ResourceDebugSource; - readonly requested_strategy: 'attach'; + readonly requested_strategy: ResourceDebugStrategy; readonly controller: 'editor'; } @@ -19,7 +25,7 @@ export interface ResourceDebugResultTelemetryProperties { readonly source: ResourceDebugSource; readonly provider: ResourceAttachProviderId | 'none'; readonly resource_type?: ResourceDebugResourceType; - readonly requested_strategy: 'attach'; + readonly requested_strategy: ResourceDebugStrategy; readonly effective_strategy: 'attach' | 'none'; readonly outcome: ResourceDebugResult['outcome']; readonly controller: 'editor'; diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 0ce34f7d341..c2ac15522b3 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -31,6 +31,7 @@ import type { AspireAppHostState, AspireExtensionApi, AspireExtensionStateSnapsh import { AppHostsViewTelemetry } from './views/AppHostsViewTelemetry'; import { initializeCliPathEnvironmentSync } from './utils/cliPathEnvironment'; import { AppHostLifecycleToolService, registerAppHostLifecycleTools } from './lm/appHostLifecycleTools'; +import { AppHostTargetResolverService } from './lm/appHostTargetResolverService'; import { AspireResourceDebugToolService, registerAspireResourceDebugTool } from './lm/resourceDebugTools'; import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; @@ -222,19 +223,23 @@ export async function activate(context: vscode.ExtensionContext) { // Language model tools that let an agent use the same AppHost lifecycle service as the // editor and Aspire tree instead of maintaining a separate start/stop policy. + const appHostTargetResolver = new AppHostTargetResolverService({ + discoveryService: appHostDiscoveryService, + }); const appHostLifecycleToolService = new AppHostLifecycleToolService({ launchService: appHostLaunchService, - discoveryService: appHostDiscoveryService, + targetResolver: appHostTargetResolver, }); context.subscriptions.push(appHostLifecycleToolService); const appHostLifecycleToolRegistration = registerAppHostLifecycleTools(appHostLifecycleToolService); context.subscriptions.push(appHostLifecycleToolRegistration); const resourceDebugToolService = new AspireResourceDebugToolService({ - targetResolver: appHostLifecycleToolService, + targetResolver: appHostTargetResolver, resourceDebugger: resourceDebugService, }); context.subscriptions.push(resourceDebugToolService); - context.subscriptions.push(registerAspireResourceDebugTool(resourceDebugToolService)); + const resourceDebugToolRegistration = registerAspireResourceDebugTool(resourceDebugToolService); + context.subscriptions.push(resourceDebugToolRegistration); const getEnableSettingsFileCreationPromptOnStartup = () => vscode.workspace.getConfiguration('aspire').get('enableSettingsFileCreationPromptOnStartup', true); const setEnableSettingsFileCreationPromptOnStartup = async (value: boolean) => await vscode.workspace.getConfiguration('aspire').update('enableSettingsFileCreationPromptOnStartup', value, vscode.ConfigurationTarget.Workspace); @@ -276,7 +281,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(appHostLaunchService.onDidChangeLaunchingState(fireStateChanged)); context.subscriptions.push(appHostTreeProvider.onDidChangeStoppingState(fireStateChanged)); context.subscriptions.push(aspireExtensionContext.onDidChangeDebugSessions(fireStateChanged)); - const e2eStateFileBridge = createE2eStateFileBridge(context, aspireExtensionContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, onDidChangeStateEmitter.event, appHostLifecycleToolRegistration.tools); + const preparableLanguageModelTools = new Map([ + ...appHostLifecycleToolRegistration.tools, + ...resourceDebugToolRegistration.tools, + ]); + const e2eStateFileBridge = createE2eStateFileBridge(context, aspireExtensionContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, onDidChangeStateEmitter.event, preparableLanguageModelTools); context.subscriptions.push(e2eStateFileBridge); await cliPathEnvironmentInitialization; diff --git a/extension/src/lm/appHostLifecycleToolAdapters.ts b/extension/src/lm/appHostLifecycleToolAdapters.ts index fba63acc3a9..ed6e1f24c3d 100644 --- a/extension/src/lm/appHostLifecycleToolAdapters.ts +++ b/extension/src/lm/appHostLifecycleToolAdapters.ts @@ -21,6 +21,7 @@ import { type PreparableAppHostLifecycleTool, } from './appHostLifecycleToolContracts'; import { AppHostLifecycleToolService } from './appHostLifecycleToolService'; +import { escapeMarkdownForConfirmation } from './markdown'; export class AppHostStartLanguageModelTool implements vscode.LanguageModelTool { constructor(private readonly _service: AppHostLifecycleToolService) { @@ -30,7 +31,7 @@ export class AppHostStartLanguageModelTool implements vscode.LanguageModelTool, token: vscode.CancellationToken): Promise { - const displayPath = escapeMarkdown(await this._service.describeTarget(options.input?.appHostPath, token)); + const displayPath = escapeMarkdownForConfirmation(await this._service.describeTarget(options.input?.appHostPath, token)); const displayMode = describeRequestedMode(options.input?.mode); return { invocationMessage: appHostLifecycleStartInvocationMessage(displayPath), @@ -51,7 +52,7 @@ export class AppHostStopLanguageModelTool implements vscode.LanguageModelTool, token: vscode.CancellationToken): Promise { - const displayPath = escapeMarkdown(await this._service.describeTarget(options.input?.appHostPath, token)); + const displayPath = escapeMarkdownForConfirmation(await this._service.describeTarget(options.input?.appHostPath, token)); return { invocationMessage: appHostLifecycleStopInvocationMessage(displayPath), confirmationMessages: { @@ -137,6 +138,3 @@ function describeRequestedMode(value: unknown): string { * in real project paths. * See https://spec.commonmark.org/0.31.2/#backslash-escapes */ -function escapeMarkdown(value: string): string { - return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); -} diff --git a/extension/src/lm/appHostLifecycleToolContracts.ts b/extension/src/lm/appHostLifecycleToolContracts.ts index d0cd4e9e7fe..1280ea48814 100644 --- a/extension/src/lm/appHostLifecycleToolContracts.ts +++ b/extension/src/lm/appHostLifecycleToolContracts.ts @@ -1,8 +1,17 @@ import * as vscode from 'vscode'; -import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { type AppHostIdentityRelation } from '../utils/appHostIdentity'; import { type AppHostStopResult } from '../services/AppHostLaunchService'; +import type { + AppHostTarget, + AppHostTargetDiscoveryService, + AppHostTargetResolution, + AppHostTargetResolver, +} from './appHostTargetResolverContracts'; +import type { + PreparableLanguageModelTool, + PreparableLanguageModelToolRegistration, +} from './languageModelToolContracts'; /** * Names of the contributed language model tools. These must match the `name` @@ -99,15 +108,6 @@ export interface AppHostLifecycleLaunchService { stopAppHostFromLifecycleOwner(appHostPath: string, token: vscode.CancellationToken): Promise; } -/** - * Narrow view of `AppHostDiscoveryService`. This is the registry the AppHost view, the - * status bar, and the Run/Debug commands already resolve against, and it is populated by - * the CLI's own `aspire ls --format json` output. - */ -export interface AppHostLifecycleDiscoveryService { - discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh?: boolean, cancellationToken?: vscode.CancellationToken): Promise; -} - /** * Editor-created sessions for a requested AppHost, plus whether any session's relationship * to it could not be proven. See {@link AppHostIdentityRelation}. @@ -139,55 +139,17 @@ export interface AppHostLifecycleEditorSession { export interface AppHostLifecycleToolDependencies { readonly launchService: AppHostLifecycleLaunchService; - readonly discoveryService: AppHostLifecycleDiscoveryService; + readonly targetResolver: AppHostTargetResolver; } -/** - * A registry-approved AppHost identity safe for a caller to pass to an editor-owned - * operation. `absolutePath` remains internal; presentation layers may render only - * `displayPath`. - */ -export interface SafeAppHostTarget { - readonly absolutePath: string; - readonly displayPath: string; -} - -/** - * The intentionally small result shape shared by tools that need to select an AppHost - * but must not own AppHost discovery or path-security policy. - */ -export type SafeAppHostTargetResolution = - | { readonly resolved: true; readonly target: SafeAppHostTarget } - | { - readonly resolved: false; - readonly outcome: Extract< - AppHostLifecycleOutcome, - 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'cancelled' - >; - }; - -/** - * Resolves a model selector only against the editor's discovered AppHost registry. - * - * Consumers must not recreate discovery, containment, or multi-root selection logic. - */ -export interface SafeAppHostTargetResolver { - resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise; -} - -export interface AppHostLifecycleToolRegistration extends vscode.Disposable { - readonly registered: boolean; - /** - * The registered tool instances by tool name. VS Code does not surface - * `prepareInvocation` through `vscode.lm`, so E2E automation needs a way to ask the - * extension's own instance for the confirmation it would present. - */ - readonly tools: ReadonlyMap; -} - -export interface PreparableAppHostLifecycleTool { - prepareInvocation(options: { readonly input: Record }, token: vscode.CancellationToken): Promise; -} +export type AppHostLifecycleToolRegistration = PreparableLanguageModelToolRegistration; +export type PreparableAppHostLifecycleTool = PreparableLanguageModelTool; +export type { + AppHostTarget as SafeAppHostTarget, + AppHostTargetDiscoveryService as AppHostLifecycleDiscoveryService, + AppHostTargetResolution as SafeAppHostTargetResolution, + AppHostTargetResolver as SafeAppHostTargetResolver, +}; export function createResult( tool: string, diff --git a/extension/src/lm/appHostLifecycleToolService.ts b/extension/src/lm/appHostLifecycleToolService.ts index b8370d1bc06..52430082491 100644 --- a/extension/src/lm/appHostLifecycleToolService.ts +++ b/extension/src/lm/appHostLifecycleToolService.ts @@ -1,11 +1,14 @@ -import * as path from 'path'; import * as vscode from 'vscode'; import { appHostLifecycleUnresolvedPath } from '../loc/strings'; -import { canonicalizeAppHostPath } from '../utils/appHostIdentity'; import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; +import type { + AppHostTarget, + AppHostTargetResolution, + AppHostTargetResolver, +} from './appHostTargetResolverContracts'; import { aspireAppHostStartToolName, aspireAppHostStopToolName, @@ -21,75 +24,11 @@ import { type AppHostLifecycleToolResult, type AppHostStartToolInput, type AppHostStopToolInput, - type SafeAppHostTarget, - type SafeAppHostTargetResolver, - type SafeAppHostTargetResolution, } from './appHostLifecycleToolContracts'; -/** - * Upper bound on the workspace-relative path a confirmation may show. - * - * A path longer than this is refused outright rather than elided, because an elided path - * no longer identifies one file: two AppHosts sharing a long prefix would produce the same - * prompt. The bound is far above any realistic repository path (Windows' own MAX_PATH is - * 260 for a full path), so refusing beyond it costs nothing in practice. - */ -const maxConfirmationPathLength = 512; - -/** Reject model-supplied selectors large enough to make normalization itself expensive. */ -const maxAppHostSelectorLength = 4096; - -/** Cap on how many AppHost paths an `unknownAppHost` result lists back to the model. */ -const maxReportedKnownAppHosts = 32; - -/** - * Characters that change what a path *is* without changing, or while changing, how it - * looks: C0/C1 controls and DEL, plus every Unicode format character (`\p{Cf}`). - * - * Bidi controls (U+202A-U+202E, U+2066-U+2069) reorder the run that follows them, so a - * path can render as a completely different one. Zero-width characters (U+200B-U+200D) - * are invisible, so two distinct files can produce identical-looking prompts. A registry - * entry carrying one of these is dropped rather than shown with the characters deleted, - * because deleting them would break the one-to-one relationship between the identity the - * user confirms and the file that runs. - * See https://unicode.org/reports/tr9/ and https://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing - */ -const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F]|\p{Cf}/u; - -/** - * One entry of the AppHost registry, projected into the form the tool speaks. - * - * Every field comes from a candidate the discovery service enumerated, so the string the - * confirmation renders and the path the launcher receives originate from the same object. - * The model's input only ever selects one of these; it never contributes to one. - */ -interface ResolvedAppHostTarget extends SafeAppHostTarget { - /** Absolute path exactly as the registry enumerated it, used for launching. */ - absolutePath: string; - /** Path relative to the containing workspace folder, always with `/` separators. */ - relativePath: string; - /** - * The identity shown in the confirmation dialog. Identical to `relativePath` in a - * single-root workspace, and prefixed with the workspace folder name otherwise, so a - * selector that resolves under one root still names that root in the prompt. - */ - displayPath: string; -} - -type AppHostTargetResolution = - | { resolved: true; target: ResolvedAppHostTarget } - | { - resolved: false; - outcome: Extract< - AppHostLifecycleOutcome, - 'invalidInput' | 'unknownAppHost' | 'ambiguousAppHost' | 'discoveryFailed' | 'cancelled' - >; - knownAppHosts?: readonly string[]; - }; - type PreflightResult = | { rejected: true; result: AppHostLifecycleToolResult } - | { rejected: false; target: ResolvedAppHostTarget }; + | { rejected: false; target: AppHostTarget }; /** * Backs the `aspire_apphost_start` / `aspire_apphost_stop` language model tools. @@ -111,7 +50,7 @@ type PreflightResult = * straight to the debug adapter and bypasses the lock, which is why every decision here * is re-validated against live session state rather than the lock alone. */ -export class AppHostLifecycleToolService implements vscode.Disposable, SafeAppHostTargetResolver { +export class AppHostLifecycleToolService implements vscode.Disposable { private readonly _dependencies: AppHostLifecycleToolDependencies; private _disposed = false; @@ -298,151 +237,8 @@ export class AppHostLifecycleToolService implements vscode.Disposable, SafeAppHo effectiveMode); } - /** - * Resolves a model-supplied selector against the AppHost registry. - * - * The selector is only ever *compared* against entries the discovery service - * enumerated; it is never joined onto a directory, never normalized into a path, and - * never reaches the filesystem. That is what makes confirmation spoofing - * unrepresentable rather than merely rejected: whatever the model sends, the target - * carried forward is one of Aspire's own candidates, so the identity shown in the - * prompt and the identity handed to the launcher come from the same object. - * - * Resolution never guesses. A selector that names nothing is `unknownAppHost`, a - * selector matching several candidates is `ambiguousAppHost`, and a registry that - * could not be read is `discoveryFailed` rather than an empty list. - */ async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { - if (typeof rawAppHost !== 'string') { - return { resolved: false, outcome: 'invalidInput' }; - } - - const selector = rawAppHost.trim(); - if (selector.length === 0 || selector.length > maxAppHostSelectorLength) { - return { resolved: false, outcome: 'invalidInput' }; - } - - // The manifest, the README, and the tool description all say the selector is a - // workspace-relative path. An absolute path would still have to match a registry - // entry to do anything, but accepting one would make the implementation contradict - // its own documented contract, so it is refused up front. - if (path.isAbsolute(selector)) { - return { resolved: false, outcome: 'invalidInput' }; - } - - let knownAppHosts: readonly ResolvedAppHostTarget[]; - try { - knownAppHosts = await this.enumerateKnownAppHosts(token); - } - catch (error) { - if (isCommandCancellation(error)) { - return { resolved: false, outcome: 'cancelled' }; - } - - // "The registry could not be read" is not "there are no AppHosts". Reporting - // the latter would tell the agent its target does not exist when the truth is - // that the extension could not find out. - extensionLogOutputChannel.warn(`Aspire language model tools could not enumerate AppHosts: ${String(error)}`); - return { resolved: false, outcome: 'discoveryFailed' }; - } - - const requestedKey = toSelectorKey(selector); - const displayMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.displayPath) === requestedKey); - if ((vscode.workspace.workspaceFolders?.length ?? 0) > 1) { - // A bare relative selector is not stable in a multi-root workspace: a confirmation - // could name the only current match under root A, then a later invocation could - // re-resolve the same text under root B. Require the same folder-qualified identity - // the confirmation displays so each invocation is independently bound to one root. - if (displayMatches.length === 1) { - return { resolved: true, target: displayMatches[0] }; - } - - if (displayMatches.length > 1) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(displayMatches) }; - } - - const relativeMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.relativePath) === requestedKey); - if (relativeMatches.length > 0) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(relativeMatches) }; - } - - return { resolved: false, outcome: 'unknownAppHost', knownAppHosts: describeKnownAppHosts(knownAppHosts) }; - } - - const matches = knownAppHosts.filter(candidate => - toSelectorKey(candidate.relativePath) === requestedKey || - toSelectorKey(candidate.displayPath) === requestedKey); - if (matches.length === 0) { - return { resolved: false, outcome: 'unknownAppHost', knownAppHosts: describeKnownAppHosts(knownAppHosts) }; - } - - // A bare relative path can name candidates under several roots of a multi-root - // workspace. Picking one would launch an AppHost the caller did not identify, so - // the folder-qualified form has to be used instead. - if (matches.length > 1) { - return { resolved: false, outcome: 'ambiguousAppHost', knownAppHosts: describeKnownAppHosts(matches) }; - } - - return { resolved: true, target: matches[0] }; - } - - /** - * Projects the discovery service's candidates into tool targets. - * - * Candidates outside every workspace folder are dropped: the tool's contract is - * expressed in workspace-relative paths, and a candidate with no containing folder - * has no such path to offer or to display. - */ - private async enumerateKnownAppHosts(token: vscode.CancellationToken): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - const candidatesByFolder = await Promise.all(workspaceFolders.map(async folder => ({ - folder, - candidates: await this._dependencies.discoveryService.discover(folder, false, token), - }))); - - const targets = new Map(); - for (const { folder, candidates } of candidatesByFolder) { - // Containment is decided on the real paths, because a link inside the workspace - // can point at a file outside it. The confirmation would show the in-workspace - // link while `startDebugging` executed the external target, so a lexical check - // alone would let the workspace boundary be crossed under an in-workspace name. - const canonicalFolderPath = canonicalizeAppHostPath(folder.uri.fsPath); - for (const candidate of candidates) { - const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path); - if (relativePath === undefined) { - continue; - } - - // The lexical relative path is still what gets displayed: it is the name the - // caller sees in the explorer, and it is the one they can pass back. - if (toContainedPosixRelativePath(canonicalFolderPath, canonicalizeAppHostPath(candidate.path)) === undefined) { - continue; - } - - const displayPath = workspaceFolders.length > 1 - ? `${folder.name}/${relativePath}` - : relativePath; - // Nested workspace folders enumerate the same file twice. Keying by the - // absolute path collapses those into one target so a selector matching both - // is not reported as ambiguous against itself. The deepest folder wins, so - // the displayed path matches the folder the user sees in the explorer. - const key = toSelectorKey(candidate.path); - const existing = targets.get(key); - if (existing && existing.relativePath.length <= relativePath.length) { - continue; - } - - targets.set(key, { absolutePath: candidate.path, relativePath, displayPath }); - } - } - - // A real file or folder name can itself carry invisible or bidi characters, and the - // confirmation must never show an identity it cannot render faithfully. Such an - // entry is dropped from the registry rather than displayed altered, which would - // break the one-to-one relationship between the prompt and the launch target. - return [...targets.values()].filter(target => - !identityChangingCharacters.test(target.displayPath) && - target.displayPath.length <= maxConfirmationPathLength); + return await this._dependencies.targetResolver.resolveTarget(rawAppHost, token); } private async preflight( @@ -525,43 +321,3 @@ export class AppHostLifecycleToolService implements vscode.Disposable, SafeAppHo function getSessionMode(session: AppHostLifecycleEditorSession): AppHostLifecycleMode { return session.configuration?.noDebug === true ? 'run' : 'debug'; } - -/** - * Normalizes a selector or registry path into the key both sides are compared on. - * - * The comparison is deliberately narrow: a leading `./` is dropped because it is noise, - * and Windows separators and casing are normalized to match that filesystem. On POSIX a - * backslash is a valid filename character, so treating it as a separator would alias two - * different registry entries. Nothing else is normalized. `..` segments, for instance, - * are left alone precisely so they can never match anything the registry enumerated. - */ -function toSelectorKey(value: string): string { - if (process.platform === 'win32') { - return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); - } - - return value.replace(/^\.\//, ''); -} - -/** - * Renders the selectors a failed resolution can offer back to the model. - * - * The list is capped because a large monorepo can enumerate hundreds of AppHosts and the - * result is spent from the model's context window. - */ -function describeKnownAppHosts(targets: readonly ResolvedAppHostTarget[]): readonly string[] { - return targets.slice(0, maxReportedKnownAppHosts).map(target => target.displayPath); -} - -/** - * Path relative to `folderPath` with `/` separators, or `undefined` when `candidate` - * is not inside the folder. - */ -function toContainedPosixRelativePath(folderPath: string, candidate: string): string | undefined { - const relative = path.relative(folderPath, candidate); - if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) { - return undefined; - } - - return relative.split(path.sep).join('/'); -} diff --git a/extension/src/lm/appHostTargetResolverContracts.ts b/extension/src/lm/appHostTargetResolverContracts.ts new file mode 100644 index 00000000000..302bfeab4c8 --- /dev/null +++ b/extension/src/lm/appHostTargetResolverContracts.ts @@ -0,0 +1,45 @@ +import type * as vscode from 'vscode'; + +import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; + +/** + * The discovered AppHost identity that an editor-owned operation may use. The absolute + * path remains internal; callers render only `displayPath` and return only `relativePath` + * or `displayPath` in tool results. + */ +export interface AppHostTarget { + readonly absolutePath: string; + readonly relativePath: string; + readonly displayPath: string; +} + +export type AppHostTargetResolutionOutcome = + | 'invalidInput' + | 'unknownAppHost' + | 'ambiguousAppHost' + | 'discoveryFailed' + | 'cancelled'; + +export type AppHostTargetResolution = + | { readonly resolved: true; readonly target: AppHostTarget } + | { + readonly resolved: false; + readonly outcome: AppHostTargetResolutionOutcome; + readonly knownAppHosts?: readonly string[]; + }; + +/** + * Narrow view of the registry the editor uses to discover AppHosts. Resolution never + * turns a model selector into a path; it only compares it with entries from this registry. + */ +export interface AppHostTargetDiscoveryService { + discover( + workspaceFolder: vscode.WorkspaceFolder, + forceRefresh?: boolean, + cancellationToken?: vscode.CancellationToken, + ): Promise; +} + +export interface AppHostTargetResolver { + resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise; +} diff --git a/extension/src/lm/appHostTargetResolverService.ts b/extension/src/lm/appHostTargetResolverService.ts new file mode 100644 index 00000000000..4e4678a6877 --- /dev/null +++ b/extension/src/lm/appHostTargetResolverService.ts @@ -0,0 +1,245 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { canonicalizeAppHostPath } from '../utils/appHostIdentity'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { isCommandCancellation } from '../utils/telemetry'; +import { + type AppHostTarget, + type AppHostTargetDiscoveryService, + type AppHostTargetResolution, + type AppHostTargetResolver, +} from './appHostTargetResolverContracts'; + +/** + * Upper bound on the workspace-relative path a confirmation may show. + * + * A path longer than this is refused outright rather than elided, because an elided path + * no longer identifies one file: two AppHosts sharing a long prefix would produce the same + * prompt. The bound is far above any realistic repository path (Windows' own MAX_PATH is + * 260 for a full path), so refusing beyond it costs nothing in practice. + */ +const maxConfirmationPathLength = 512; + +/** Reject model-supplied selectors large enough to make normalization itself expensive. */ +const maxAppHostSelectorLength = 4096; + +/** Cap on how many AppHost paths an `unknownAppHost` result lists back to the model. */ +const maxReportedKnownAppHosts = 32; + +/** + * Characters that change what a path *is* without changing, or while changing, how it + * looks: C0/C1 controls and DEL, line and paragraph separators, plus every Unicode format + * character (`\p{Cf}`). + * + * Bidi controls (U+202A-U+202E, U+2066-U+2069) reorder the run that follows them, so a + * path can render as a completely different one. Zero-width characters (U+200B-U+200D) + * are invisible, so two distinct files can produce identical-looking prompts. U+2028 and + * U+2029 can create a new rendered line or paragraph in Markdown confirmations. A registry + * entry carrying any of these is dropped rather than shown with the characters deleted, + * because deleting them would break the one-to-one relationship between the identity the + * user confirms and the file that runs. + * See https://unicode.org/reports/tr9/ and https://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing + */ +const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]|\p{Cf}/u; +const confirmationBreakingCharacters = /[\u2028\u2029]/u; + +export interface AppHostTargetResolverServiceDependencies { + readonly discoveryService: AppHostTargetDiscoveryService; +} + +/** + * Resolves a model selector only against the editor's discovered AppHost registry. + * + * Consumers must not recreate discovery, containment, or multi-root selection logic. + */ +export class AppHostTargetResolverService implements AppHostTargetResolver { + constructor(private readonly _dependencies: AppHostTargetResolverServiceDependencies) { + } + + /** + * The selector is only ever compared against entries the discovery service enumerated; + * it is never joined onto a directory, normalized into a path, or passed to the + * filesystem. A resolved target therefore always comes from Aspire's own registry. + */ + async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { + if (typeof rawAppHost !== 'string') { + return { resolved: false, outcome: 'invalidInput' }; + } + + const selector = rawAppHost.trim(); + if (selector.length === 0 || + selector.length > maxAppHostSelectorLength || + confirmationBreakingCharacters.test(selector) || + path.isAbsolute(selector)) { + return { resolved: false, outcome: 'invalidInput' }; + } + + let knownAppHosts: readonly AppHostTarget[]; + try { + knownAppHosts = await this._enumerateKnownAppHosts(token); + } + catch (error) { + if (isCommandCancellation(error) || token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + // Discovery errors can contain CLI and filesystem detail. The tool result carries + // only the bounded outcome while the extension log retains diagnostics. + extensionLogOutputChannel.warn(`Aspire language model tools could not enumerate AppHosts: ${String(error)}`); + return { resolved: false, outcome: 'discoveryFailed' }; + } + + if (token.isCancellationRequested) { + return { resolved: false, outcome: 'cancelled' }; + } + + const requestedKey = toSelectorKey(selector); + const displayMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.displayPath) === requestedKey); + if ((vscode.workspace.workspaceFolders?.length ?? 0) > 1) { + // A bare relative selector is not stable in a multi-root workspace: a confirmation + // could name the only current match under root A, then a later invocation could + // re-resolve the same text under root B. Require the folder-qualified identity + // the confirmation displays so each invocation is independently bound to one root. + if (displayMatches.length === 1) { + return { resolved: true, target: displayMatches[0] }; + } + + if (displayMatches.length > 1) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(displayMatches), + }; + } + + const relativeMatches = knownAppHosts.filter(candidate => toSelectorKey(candidate.relativePath) === requestedKey); + if (relativeMatches.length > 0) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(relativeMatches), + }; + } + + return { + resolved: false, + outcome: 'unknownAppHost', + knownAppHosts: describeKnownAppHosts(knownAppHosts), + }; + } + + const matches = knownAppHosts.filter(candidate => + toSelectorKey(candidate.relativePath) === requestedKey || + toSelectorKey(candidate.displayPath) === requestedKey); + if (matches.length === 0) { + return { + resolved: false, + outcome: 'unknownAppHost', + knownAppHosts: describeKnownAppHosts(knownAppHosts), + }; + } + + if (matches.length > 1) { + return { + resolved: false, + outcome: 'ambiguousAppHost', + knownAppHosts: describeKnownAppHosts(matches), + }; + } + + return { resolved: true, target: matches[0] }; + } + + private async _enumerateKnownAppHosts(token: vscode.CancellationToken): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + const candidatesByFolder = await Promise.all(workspaceFolders.map(async folder => ({ + folder, + candidates: await this._dependencies.discoveryService.discover(folder, false, token), + }))); + + const targets = new Map(); + for (const { folder, candidates } of candidatesByFolder) { + // Containment is decided on the real paths, because a link inside the workspace + // can point at a file outside it. The confirmation would show the in-workspace + // link while `startDebugging` executed the external target, so a lexical check + // alone would let the workspace boundary be crossed under an in-workspace name. + const canonicalFolderPath = canonicalizeAppHostPath(folder.uri.fsPath); + for (const candidate of candidates) { + const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path); + if (relativePath === undefined) { + continue; + } + + // The lexical relative path is still what gets displayed: it is the name the + // caller sees in the explorer, and it is the one they can pass back. + if (toContainedPosixRelativePath(canonicalFolderPath, canonicalizeAppHostPath(candidate.path)) === undefined) { + continue; + } + + const displayPath = workspaceFolders.length > 1 + ? `${folder.name}/${relativePath}` + : relativePath; + // Nested workspace folders enumerate the same file twice. Keying by the + // absolute path collapses those into one target so a selector matching both + // is not reported as ambiguous against itself. The deepest folder wins, so + // the displayed path matches the folder the user sees in the explorer. + const key = toSelectorKey(candidate.path); + const existing = targets.get(key); + if (existing && existing.relativePath.length <= relativePath.length) { + continue; + } + + targets.set(key, { + absolutePath: candidate.path, + relativePath, + displayPath, + }); + } + } + + return [...targets.values()].filter(target => + !identityChangingCharacters.test(target.displayPath) && + target.displayPath.length <= maxConfirmationPathLength); + } +} + +/** + * Normalizes a selector or registry path into the key both sides are compared on. + * + * The comparison is deliberately narrow: a leading `./` is dropped because it is noise, + * and Windows separators and casing are normalized to match that filesystem. On POSIX a + * backslash is a valid filename character, so treating it as a separator would alias two + * different registry entries. Nothing else is normalized. `..` segments, for instance, + * are left alone precisely so they can never match anything the registry enumerated. + */ +function toSelectorKey(value: string): string { + if (process.platform === 'win32') { + return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase(); + } + + return value.replace(/^\.\//, ''); +} + +/** + * Renders the selectors a failed resolution can offer back to the model. + * + * The list is capped because a large monorepo can enumerate hundreds of AppHosts and the + * result is spent from the model's context window. + */ +function describeKnownAppHosts(targets: readonly AppHostTarget[]): readonly string[] { + return targets.slice(0, maxReportedKnownAppHosts).map(target => target.displayPath); +} + +/** + * Path relative to `folderPath` with `/` separators, or `undefined` when `candidate` + * is not inside the folder. + */ +function toContainedPosixRelativePath(folderPath: string, candidate: string): string | undefined { + const relative = path.relative(folderPath, candidate); + if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) { + return undefined; + } + + return relative.split(path.sep).join('/'); +} diff --git a/extension/src/lm/languageModelToolContracts.ts b/extension/src/lm/languageModelToolContracts.ts new file mode 100644 index 00000000000..c4620e71ece --- /dev/null +++ b/extension/src/lm/languageModelToolContracts.ts @@ -0,0 +1,17 @@ +import type * as vscode from 'vscode'; + +/** + * The limited preparation surface exposed to the E2E bridge. It intentionally accepts + * raw JSON-shaped input because each registered tool independently validates every field. + */ +export interface PreparableLanguageModelTool { + prepareInvocation( + options: { readonly input: Record }, + token: vscode.CancellationToken, + ): Promise; +} + +export interface PreparableLanguageModelToolRegistration extends vscode.Disposable { + readonly registered: boolean; + readonly tools: ReadonlyMap; +} diff --git a/extension/src/lm/markdown.ts b/extension/src/lm/markdown.ts new file mode 100644 index 00000000000..528adab88bd --- /dev/null +++ b/extension/src/lm/markdown.ts @@ -0,0 +1,15 @@ +/** + * Escapes the Markdown constructs that change how an identity renders inline. + * + * Confirmation bodies render as Markdown, so an unescaped `*`, `_`, `` ` ``, `[`, or + * `<` in a resolved resource or AppHost identity would show the user something other than + * the identity the tool will act on. Escaping keeps rendered text one-to-one with that + * identity instead of deleting characters, which would break that relationship in the + * other direction. Characters meaningful only at the start of a line (`.`, `-`, `{`, `}`) + * are left alone because callers interpolate values mid-sentence and those characters are + * common in real resource and project names. + * See https://spec.commonmark.org/0.31.2/#backslash-escapes + */ +export function escapeMarkdownForConfirmation(value: string): string { + return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); +} diff --git a/extension/src/lm/resourceDebugToolAdapters.ts b/extension/src/lm/resourceDebugToolAdapters.ts index 4e06d4ab877..f0bd48c73f5 100644 --- a/extension/src/lm/resourceDebugToolAdapters.ts +++ b/extension/src/lm/resourceDebugToolAdapters.ts @@ -4,6 +4,7 @@ import { resourceDebugToolConfirmationMessage, resourceDebugToolConfirmationTitle, resourceDebugToolInvocationMessage, + resourceDebugToolUnresolvedConfirmationMessage, resourceDebugToolUnavailableInvocationMessage, } from '../loc/strings'; import { extensionLogOutputChannel } from '../utils/logging'; @@ -14,6 +15,7 @@ import { type AspireResourceDebugToolResult, } from './resourceDebugToolContracts'; import { AspireResourceDebugToolService } from './resourceDebugToolService'; +import { escapeMarkdownForConfirmation } from './markdown'; export class AspireResourceDebugLanguageModelTool implements vscode.LanguageModelTool { constructor(private readonly _service: AspireResourceDebugToolService) { @@ -25,13 +27,20 @@ export class AspireResourceDebugLanguageModelTool implements vscode.LanguageMode ): Promise { const preparation = await this._service.prepare(options.input, token); if (!preparation.canDebug) { - // There is no safe target to confirm, so never fabricate a path for the - // progress message. Invocation independently resolves and bounds its result. - return { invocationMessage: resourceDebugToolUnavailableInvocationMessage }; + // Do not let a transient discovery failure bypass VS Code's confirmation step. + // The generic message contains no model input or unresolved target; invocation + // resolves again and still applies trust and validation checks. + return { + invocationMessage: resourceDebugToolUnavailableInvocationMessage, + confirmationMessages: { + title: resourceDebugToolConfirmationTitle, + message: resourceDebugToolUnresolvedConfirmationMessage, + }, + }; } - const resourceName = escapeMarkdown(preparation.resourceName); - const appHost = escapeMarkdown(preparation.target.displayPath); + const resourceName = escapeMarkdownForConfirmation(preparation.resourceName); + const appHost = escapeMarkdownForConfirmation(preparation.target.displayPath); return { invocationMessage: resourceDebugToolInvocationMessage(resourceName), confirmationMessages: { @@ -51,12 +60,19 @@ export class AspireResourceDebugLanguageModelTool implements vscode.LanguageMode export function registerAspireResourceDebugTool(service: AspireResourceDebugToolService): AspireResourceDebugToolRegistration { const registrations: vscode.Disposable[] = []; + const tool = new AspireResourceDebugLanguageModelTool(service); + const tools = new Map([ + [aspireResourceDebugToolName, { + prepareInvocation: (options: { readonly input: Record }, token: vscode.CancellationToken) => + tool.prepareInvocation({ input: options.input as unknown as AspireResourceDebugToolInput }, token), + }], + ]); if (typeof vscode.lm?.registerTool !== 'function') { extensionLogOutputChannel.info('Skipping Aspire resource debug language model tool: the language model tool API is unavailable.'); } else { - registrations.push(vscode.lm.registerTool(aspireResourceDebugToolName, new AspireResourceDebugLanguageModelTool(service))); + registrations.push(vscode.lm.registerTool(aspireResourceDebugToolName, tool)); extensionLogOutputChannel.info('Registered Aspire resource debug language model tool.'); } @@ -64,6 +80,7 @@ export function registerAspireResourceDebugTool(service: AspireResourceDebugTool get registered() { return registrations.length > 0; }, + tools, dispose() { registrations.forEach(registration => registration.dispose()); registrations.length = 0; @@ -74,7 +91,3 @@ export function registerAspireResourceDebugTool(service: AspireResourceDebugTool function createToolResult(result: AspireResourceDebugToolResult): vscode.LanguageModelToolResult { return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(JSON.stringify(result))]); } - -function escapeMarkdown(value: string): string { - return value.replace(/[\\`*_[\]()<>#+~|!&]/g, character => `\\${character}`); -} diff --git a/extension/src/lm/resourceDebugToolContracts.ts b/extension/src/lm/resourceDebugToolContracts.ts index 5061bed77ad..9e0affac7f5 100644 --- a/extension/src/lm/resourceDebugToolContracts.ts +++ b/extension/src/lm/resourceDebugToolContracts.ts @@ -1,15 +1,15 @@ -import type * as vscode from 'vscode'; - import type { ResourceDebugErrorKind, ResourceDebugExtensionRequirement, ResourceDebugger, + ResourceDebugStrategy, } from '../debugger/resourceDebugContracts'; -import type { SafeAppHostTarget, SafeAppHostTargetResolver } from './appHostLifecycleToolContracts'; +import type { AppHostTarget, AppHostTargetResolver } from './appHostTargetResolverContracts'; +import type { PreparableLanguageModelToolRegistration } from './languageModelToolContracts'; export const aspireResourceDebugToolName = 'aspire_resource_debug'; -export type AspireResourceDebugStrategy = 'auto' | 'attach'; +export type AspireResourceDebugStrategy = ResourceDebugStrategy; export interface AspireResourceDebugToolInput { readonly appHostPath: string; @@ -53,14 +53,14 @@ export interface AspireResourceDebugToolResult { } export interface AspireResourceDebugToolDependencies { - readonly targetResolver: SafeAppHostTargetResolver; + readonly targetResolver: AppHostTargetResolver; readonly resourceDebugger: ResourceDebugger; } export type AspireResourceDebugToolPreparation = | { readonly canDebug: true; - readonly target: SafeAppHostTarget; + readonly target: AppHostTarget; readonly resourceName: string; readonly requestedStrategy: AspireResourceDebugStrategy; } @@ -69,8 +69,10 @@ export type AspireResourceDebugToolPreparation = readonly result: AspireResourceDebugToolResult; }; -export interface AspireResourceDebugToolRegistration extends vscode.Disposable { - readonly registered: boolean; -} +export type AspireResourceDebugToolRegistration = PreparableLanguageModelToolRegistration; -export type { SafeAppHostTargetResolver, SafeAppHostTargetResolution } from './appHostLifecycleToolContracts'; +export type { + AppHostTarget as SafeAppHostTarget, + AppHostTargetResolution as SafeAppHostTargetResolution, + AppHostTargetResolver as SafeAppHostTargetResolver, +} from './appHostTargetResolverContracts'; diff --git a/extension/src/lm/resourceDebugToolService.ts b/extension/src/lm/resourceDebugToolService.ts index 8ea5e248d67..3b4eda42e6f 100644 --- a/extension/src/lm/resourceDebugToolService.ts +++ b/extension/src/lm/resourceDebugToolService.ts @@ -14,12 +14,13 @@ import { type AspireResourceDebugToolResult, } from './resourceDebugToolContracts'; -const maxInputLength = 4096; +const maxAppHostPathLength = 4096; +const maxResourceNameLength = 256; // Invisible and bidi controls can make a confirmation differ from what the model sent. // Match the AppHost lifecycle resolver's identity boundary before resource names reach // either confirmation text or the resource-debug service. -const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F]|\p{Cf}/u; +const identityChangingCharacters = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]|\p{Cf}/u; interface ParsedInput { readonly appHostPath: string; @@ -63,7 +64,7 @@ export class AspireResourceDebugToolService implements vscode.Disposable { try { const resolution = await this._dependencies.targetResolver.resolveTarget(parsed.appHostPath, token); - if (token.isCancellationRequested) { + if (this._disposed || token.isCancellationRequested) { return this.reject('cancelled', '', parsed); } @@ -89,9 +90,29 @@ export class AspireResourceDebugToolService implements vscode.Disposable { return preparation.result; } + if (this._disposed || token.isCancellationRequested) { + return this.createResult( + 'cancelled', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + try { + // Re-check immediately before crossing into the shared debugger service. A + // deactivating extension must not initiate a new attach after preparation won + // the race with disposal. + if (this._disposed || token.isCancellationRequested) { + return this.createResult( + 'cancelled', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + const result = await this._dependencies.resourceDebugger.debug({ source: 'languageModelTool', + strategy: preparation.requestedStrategy, appHost: preparation.target, resourceName: preparation.resourceName, cancellationToken: token, @@ -164,8 +185,8 @@ function parseInput(value: unknown): ParsedInput | undefined { const appHostPath = input.appHostPath; const resourceName = input.resourceName; const strategy = input.strategy; - if (!isSafeNonBlankString(appHostPath) || - !isSafeNonBlankString(resourceName) || + if (!isSafeNonBlankString(appHostPath, maxAppHostPathLength) || + !isSafeNonBlankString(resourceName, maxResourceNameLength) || (strategy !== undefined && strategy !== 'auto' && strategy !== 'attach')) { return undefined; } @@ -184,10 +205,10 @@ function parseInput(value: unknown): ParsedInput | undefined { } } -function isSafeNonBlankString(value: unknown): value is string { +function isSafeNonBlankString(value: unknown, maxLength: number): value is string { return typeof value === 'string' && value.trim().length > 0 && - value.length <= maxInputLength && + value.length <= maxLength && !identityChangingCharacters.test(value); } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 96879b9a1fa..36d40b11730 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -281,5 +281,6 @@ export const appHostLifecycleBusy = vscode.l10n.t('Another start or stop operati export const appHostLifecycleLaunchAlreadyClaimed = vscode.l10n.t('This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.'); export const resourceDebugToolConfirmationTitle = vscode.l10n.t('Attach debugger to Aspire resource'); export const resourceDebugToolConfirmationMessage = (resourceName: string, appHostPath: string) => vscode.l10n.t('Attach the debugger to resource {0} from Aspire AppHost {1}?', resourceName, appHostPath); +export const resourceDebugToolUnresolvedConfirmationMessage = vscode.l10n.t('Attach the debugger to the requested Aspire resource?'); export const resourceDebugToolInvocationMessage = (resourceName: string) => vscode.l10n.t('Attaching debugger to Aspire resource {0}...', resourceName); export const resourceDebugToolUnavailableInvocationMessage = vscode.l10n.t('Unable to attach debugger to the requested Aspire resource.'); diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index 86b9ae7dd61..7b73001fb15 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -472,6 +472,7 @@ const expectedActivationEvents = [ 'onCommand:aspire-vscode.verifyCliInstalled', 'onLanguageModelTool:aspire_apphost_start', 'onLanguageModelTool:aspire_apphost_stop', + 'onLanguageModelTool:aspire_resource_debug', ]; const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ @@ -483,6 +484,12 @@ const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ stopModelDescription: '%languageModelTool.aspireAppHostStop.modelDescription%', stopUserDescription: '%languageModelTool.aspireAppHostStop.userDescription%', appHostPathDescription: '%languageModelTool.aspireAppHost.appHostPath.description%', + resourceDebugDisplayName: '%languageModelTool.aspireResourceDebug.displayName%', + resourceDebugModelDescription: '%languageModelTool.aspireResourceDebug.modelDescription%', + resourceDebugUserDescription: '%languageModelTool.aspireResourceDebug.userDescription%', + resourceDebugAppHostPathDescription: '%languageModelTool.aspireResourceDebug.appHostPath.description%', + resourceDebugResourceNameDescription: '%languageModelTool.aspireResourceDebug.resourceName.description%', + resourceDebugStrategyDescription: '%languageModelTool.aspireResourceDebug.strategy.description%', }); const expectedInstalledLanguageModelTools = createExpectedLanguageModelTools({ @@ -494,6 +501,12 @@ const expectedInstalledLanguageModelTools = createExpectedLanguageModelTools({ stopModelDescription: 'Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through \'aspire stop --apphost\' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.', stopUserDescription: 'Stop a running Aspire AppHost from this workspace.', appHostPathDescription: 'Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example \'AppHost/AppHost.csproj\' or \'apphost.cs\'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example \'backend/AppHost/AppHost.csproj\').', + resourceDebugDisplayName: 'Debug Aspire resource', + resourceDebugModelDescription: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', + resourceDebugUserDescription: 'Attach the debugger to a running Aspire resource.', + resourceDebugAppHostPathDescription: 'Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.', + resourceDebugResourceNameDescription: 'Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.', + resourceDebugStrategyDescription: 'Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.', }); function createExpectedLanguageModelTools(strings: { @@ -505,6 +518,12 @@ function createExpectedLanguageModelTools(strings: { stopModelDescription: string; stopUserDescription: string; appHostPathDescription: string; + resourceDebugDisplayName: string; + resourceDebugModelDescription: string; + resourceDebugUserDescription: string; + resourceDebugAppHostPathDescription: string; + resourceDebugResourceNameDescription: string; + resourceDebugStrategyDescription: string; }) { return [ { @@ -550,6 +569,38 @@ function createExpectedLanguageModelTools(strings: { additionalProperties: false, }, }, + { + name: 'aspire_resource_debug', + toolReferenceName: 'aspireDebugResource', + displayName: strings.resourceDebugDisplayName, + modelDescription: strings.resourceDebugModelDescription, + userDescription: strings.resourceDebugUserDescription, + icon: '$(debug-alt)', + canBeReferencedInPrompt: true, + when: 'isWorkspaceTrusted', + tags: ['aspire', 'debug', 'resource'], + inputSchema: { + type: 'object', + properties: { + appHostPath: { + type: 'string', + description: strings.resourceDebugAppHostPathDescription, + }, + resourceName: { + type: 'string', + description: strings.resourceDebugResourceNameDescription, + }, + strategy: { + type: 'string', + enum: ['auto', 'attach'], + default: 'auto', + description: strings.resourceDebugStrategyDescription, + }, + }, + required: ['appHostPath', 'resourceName'], + additionalProperties: false, + }, + }, ]; } diff --git a/extension/src/test/appHostLifecycleTools.test.ts b/extension/src/test/appHostLifecycleTools.test.ts index c6d05a1b3a1..cab974c314c 100644 --- a/extension/src/test/appHostLifecycleTools.test.ts +++ b/extension/src/test/appHostLifecycleTools.test.ts @@ -21,6 +21,7 @@ import { type AppHostLifecycleRunningAppHost, type AppHostLifecycleToolResult, } from '../lm/appHostLifecycleTools'; +import { AppHostTargetResolverService } from '../lm/appHostTargetResolverService'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; @@ -330,9 +331,10 @@ suite('AppHost lifecycle language model tools', () => { discoveryService = new FakeDiscoveryService(); discoveryService.registeredPaths.push(appHostProjectPath); editorSessions = []; + const targetResolver = new AppHostTargetResolverService({ discoveryService }); service = new AppHostLifecycleToolService({ launchService, - discoveryService, + targetResolver, }); launchService.editorSessions = editorSessions; }); @@ -504,6 +506,18 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(discoveryService.discoverCalls, 0); }); + test('rejects line and paragraph separators before consulting the AppHost registry', async () => { + for (const separator of ['\u2028', '\u2029']) { + const result = await service.start( + { appHostPath: `AppHost${separator}/AppHost.csproj`, mode: 'run' }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'invalidInput'); + assert.strictEqual(discoveryService.discoverCalls, 0); + assert.strictEqual(launchService.launchCalls.length, 0); + } + }); + test('rejects a selector the AppHost registry does not list', async () => { const result = await service.start({ appHostPath: 'AppHost/Missing.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 74c927ee419..abb40bd3ef3 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -62,6 +62,7 @@ function createAppHost(overrides: Partial = {}): AppHostDisp function createRequest(overrides: Partial = {}): ResourceDebugRequest { return { source: 'tree', + strategy: 'attach', appHost: target, resourceName: 'api', ...overrides, @@ -1141,6 +1142,53 @@ suite('Resource debug service', () => { } }); + test('selects attach centrally for the auto strategy and records the requested strategy', async () => { + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ telemetry }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ source: 'languageModelTool', strategy: 'auto' })), + { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual( + telemetry.events.map(event => ({ + name: event.name, + requestedStrategy: event.properties.requested_strategy, + effectiveStrategy: event.properties.effective_strategy, + })), + [ + { + name: 'aspire/vscode/resourcedebug/start', + requestedStrategy: 'auto', + effectiveStrategy: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + requestedStrategy: 'auto', + effectiveStrategy: 'attach', + }, + ]); + } + finally { + sessions.dispose(); + } + }); + + test('fails closed when a caller bypasses the bounded debug strategy contract', async () => { + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ startDebugging }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ strategy: 'restart' as never })), + { outcome: 'error', errorKind: 'unexpected' }); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + sessions.dispose(); + } + }); + test('emits exactly one bounded result for every resource debug outcome', async () => { const run = async ( create: () => { diff --git a/extension/src/test/resourceDebugTools.test.ts b/extension/src/test/resourceDebugTools.test.ts index 92ce3af1090..cfa70c4c5c0 100644 --- a/extension/src/test/resourceDebugTools.test.ts +++ b/extension/src/test/resourceDebugTools.test.ts @@ -24,6 +24,7 @@ import { type SafeAppHostTargetResolver, type SafeAppHostTargetResolution, } from '../lm/resourceDebugTools'; +import { AppHostTargetResolverService } from '../lm/appHostTargetResolverService'; const absoluteAppHostPath = '/private/workspace/AppHost/AppHost.csproj'; const safeAppHostPath = 'AppHost/AppHost.csproj'; @@ -34,6 +35,7 @@ class FakeTargetResolver implements SafeAppHostTargetResolver { resolved: true, target: { absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, displayPath: safeAppHostPath, }, }]; @@ -150,12 +152,19 @@ suite('Aspire resource debug language model tool', () => { assert.deepStrictEqual(tool.inputSchema, { type: 'object', properties: { - appHostPath: { type: 'string' }, - resourceName: { type: 'string' }, + appHostPath: { + type: 'string', + description: '%languageModelTool.aspireResourceDebug.appHostPath.description%', + }, + resourceName: { + type: 'string', + description: '%languageModelTool.aspireResourceDebug.resourceName.description%', + }, strategy: { type: 'string', enum: ['auto', 'attach'], default: 'auto', + description: '%languageModelTool.aspireResourceDebug.strategy.description%', }, }, required: ['appHostPath', 'resourceName'], @@ -183,6 +192,9 @@ suite('Aspire resource debug language model tool', () => { display: packageNls['languageModelTool.aspireResourceDebug.displayName'], model: packageNls['languageModelTool.aspireResourceDebug.modelDescription'], user: packageNls['languageModelTool.aspireResourceDebug.userDescription'], + appHostPath: packageNls['languageModelTool.aspireResourceDebug.appHostPath.description'], + resourceName: packageNls['languageModelTool.aspireResourceDebug.resourceName.description'], + strategy: packageNls['languageModelTool.aspireResourceDebug.strategy.description'], }, { title: 'Attach debugger to Aspire resource', @@ -191,6 +203,9 @@ suite('Aspire resource debug language model tool', () => { display: 'Debug Aspire resource', model: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', user: 'Attach the debugger to a running Aspire resource.', + appHostPath: 'Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name.', + resourceName: 'Name of a running resource from the selected AppHost. Resource names are limited to 256 characters.', + strategy: 'Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported.', }); }); }); @@ -206,6 +221,7 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual(registration.registered, true); assert.deepStrictEqual(registerToolStub.getCalls().map(call => call.args[0]), [aspireResourceDebugToolName]); + assert.deepStrictEqual([...registration.tools.keys()], [aspireResourceDebugToolName]); registration.dispose(); assert.deepStrictEqual(disposed, [aspireResourceDebugToolName]); }); @@ -243,8 +259,13 @@ suite('Aspire resource debug language model tool', () => { { resourceName: 'api' }, createInput({ appHostPath: ' ' }), createInput({ appHostPath: 'AppHost/\u200bAppHost.csproj' }), + createInput({ appHostPath: 'AppHost\u2028/AppHost.csproj' }), + createInput({ appHostPath: 'AppHost\u2029/AppHost.csproj' }), createInput({ resourceName: '\t' }), createInput({ resourceName: 'api\u200b' }), + createInput({ resourceName: 'api\u2028injected' }), + createInput({ resourceName: 'api\u2029injected' }), + createInput({ resourceName: 'a'.repeat(257) }), createInput({ strategy: 'restart' }), createInput({ unexpected: 'value' }), throwingInput, @@ -293,6 +314,7 @@ suite('Aspire resource debug language model tool', () => { controller: 'editor', }); assert.strictEqual(resourceDebugger.calls[0].source, 'languageModelTool'); + assert.strictEqual(resourceDebugger.calls[0].strategy, requestedStrategy); } }); @@ -338,6 +360,7 @@ suite('Aspire resource debug language model tool', () => { resolved: true, target: { absolutePath: '/private/workspace/backend/AppHost/AppHost.csproj', + relativePath: 'AppHost/AppHost.csproj', displayPath: 'backend/AppHost/AppHost.csproj', }, }]; @@ -360,6 +383,7 @@ suite('Aspire resource debug language model tool', () => { resolved: true, target: { absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, displayPath: 'backend/AppHost/AppHost.csproj', }, }]; @@ -379,18 +403,76 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual(confirmation.includes('debug configuration'), false); }); - test('does not invent an AppHost path when confirmation resolution fails', async () => { + test('always requires a generic confirmation when preparation cannot resolve the AppHost', async () => { + const resolver = new FakeTargetResolver(); + for (const outcome of ['unknownAppHost', 'discoveryFailed', 'cancelled'] as const) { + resolver.calls = 0; + resolver.results = [ + { resolved: false, outcome }, + { + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: safeAppHostPath, + displayPath: safeAppHostPath, + }, + }, + ]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput({ appHostPath: '../private/token=secret' }); + + const prepared = await tool.prepareInvocation( + { input: input as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(prepared.invocationMessage, 'Unable to attach debugger to the requested Aspire resource.'); + assert.strictEqual(JSON.stringify(prepared).includes('../private/token=secret'), false); + assert.strictEqual(JSON.stringify(prepared).includes(absoluteAppHostPath), false); + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(resourceDebugger.calls.length, 1); + } + + const { service } = createService(); + const tool = new AspireResourceDebugLanguageModelTool(service); + const prepared = await tool.prepareInvocation( + { input: createInput({ resourceName: 'api\u2028injected' }) as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(JSON.stringify(prepared).includes('injected'), false); + }); + + test('escapes confirmed resource and AppHost identities with the shared Markdown helper', async () => { const resolver = new FakeTargetResolver(); - resolver.results = [{ resolved: false, outcome: 'unknownAppHost' }]; + resolver.results = [{ + resolved: true, + target: { + absolutePath: absoluteAppHostPath, + relativePath: 'AppHost/[unsafe]*.csproj', + displayPath: 'AppHost/[unsafe]*.csproj', + }, + }]; const { service } = createService(resolver); const tool = new AspireResourceDebugLanguageModelTool(service); const prepared = await tool.prepareInvocation( - { input: createInput({ appHostPath: '../private/token=secret' }) as unknown as AspireResourceDebugToolInput }, + { input: createInput({ resourceName: 'api_[unsafe]*' }) as unknown as AspireResourceDebugToolInput }, new vscode.CancellationTokenSource().token); - assert.strictEqual(prepared.confirmationMessages, undefined); - assert.strictEqual(prepared.invocationMessage, 'Unable to attach debugger to the requested Aspire resource.'); + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Attach the debugger to resource api\\_\\[unsafe\\]\\* from Aspire AppHost AppHost/\\[unsafe\\]\\*.csproj?'); }); test('re-resolves the AppHost immediately after confirmation', async () => { @@ -400,6 +482,7 @@ suite('Aspire resource debug language model tool', () => { resolved: true, target: { absolutePath: '/private/workspace/first/AppHost.csproj', + relativePath: 'first/AppHost.csproj', displayPath: 'first/AppHost.csproj', }, }, @@ -407,6 +490,7 @@ suite('Aspire resource debug language model tool', () => { resolved: true, target: { absolutePath: '/private/workspace/second/AppHost.csproj', + relativePath: 'second/AppHost.csproj', displayPath: 'second/AppHost.csproj', }, }, @@ -446,6 +530,17 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual((await duringDebug.service.debug(createInput(), debugToken.token)).outcome, 'cancelled'); }); + test('fails closed when disposal races AppHost resolution before an attach starts', async () => { + const resolver = new FakeTargetResolver(); + const { service, resourceDebugger } = createService(resolver); + resolver.onResolve = () => service.dispose(); + + const result = await service.debug(createInput(), new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 0); + }); + test('maps every bounded resource debug result', async () => { const cases: Array<{ readonly result: ResourceDebugResult; @@ -536,7 +631,8 @@ suite('Aspire resource debug language model tool', () => { }); }); - test('continues to expose the existing lifecycle resolver without invoking lifecycle policy', () => { - assert.strictEqual(typeof AppHostLifecycleToolService.prototype.resolveTarget, 'function'); + test('uses the neutral AppHost target resolver contract without importing lifecycle policy', () => { + assert.strictEqual(typeof AppHostTargetResolverService.prototype.resolveTarget, 'function'); + assert.strictEqual(AppHostLifecycleToolService.prototype.isPrototypeOf(AppHostTargetResolverService.prototype), false); }); }); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 09ec668db31..615882de1ee 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -9,7 +9,7 @@ import { spawnCliProcess } from '../utils/process/cliProcess'; import { cleanupRun } from '../debugger/runCleanupRegistry'; import type { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration } from '../dcp/types'; import { createStateSnapshot, getSensitiveDashboardUrl, isSamePath } from '../extensionState'; -import type { PreparableAppHostLifecycleTool } from '../lm/appHostLifecycleTools'; +import type { PreparableLanguageModelTool } from '../lm/languageModelToolContracts'; import { AppHostLaunchRequestedEvent, AppHostLaunchService } from '../services/AppHostLaunchService'; import type { AspireDebugConsoleOutputEvent, AspireExtensionE2EBrowserDebugSession, AspireExtensionE2ECommandInvocation, AspireExtensionE2EControlCommand, AspireExtensionE2EControlPayload, AspireExtensionE2EControlStatus, AspireExtensionE2EDebugConsoleOutput, AspireExtensionE2EDebugLaunch, AspireExtensionE2EStoppingPathEvent, AspireExtensionE2ETaskProcessEvent, AspireExtensionE2ETerminalCommand, AspireExtensionStateSnapshot } from '../types/extensionApi'; import { AspireTerminalCommandEvent, AspireTerminalProvider } from '../utils/AspireTerminalProvider'; @@ -30,7 +30,7 @@ export function createE2eStateFileBridge( appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, onDidChangeState: vscode.Event, - appHostLifecycleTools: ReadonlyMap, + preparableLanguageModelTools: ReadonlyMap, ): vscode.Disposable { const stateFile = process.env.ASPIRE_EXTENSION_E2E_STATE_FILE; const controlFile = process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE; @@ -245,7 +245,7 @@ export function createE2eStateFileBridge( } }; - const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, clipboardExpectation, appHostLifecycleTools, payload.command, markCommandStarted); + const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, clipboardExpectation, preparableLanguageModelTools, payload.command, markCommandStarted); controlStatus = { revision, status: 'applied', startedObserved: commandStarted, result }; } else { @@ -361,7 +361,7 @@ async function executeE2eControlCommand( terminalProvider: AspireTerminalProvider, clipboardSnapshot: E2eClipboardSnapshot, clipboardExpectation: E2eClipboardExpectation, - appHostLifecycleTools: ReadonlyMap, + preparableLanguageModelTools: ReadonlyMap, command: AspireExtensionE2EControlCommand, markStarted: () => void ): Promise { @@ -588,7 +588,7 @@ async function executeE2eControlCommand( } case 'prepareLanguageModelToolInvocation': { markStarted(); - const tool = appHostLifecycleTools.get(command.toolName); + const tool = preparableLanguageModelTools.get(command.toolName); if (!tool) { throw new Error(`Language model tool '${command.toolName}' is not registered.`); } diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 9083a5f663c..c593256ae7d 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1024,6 +1024,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider Date: Sat, 15 Aug 2026 07:10:59 -0400 Subject: [PATCH 61/90] fix(extension): address resource debug review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/README.md | 2 +- extension/loc/xlf/aspire-vscode.xlf | 6 +- extension/package.nls.json | 2 +- .../src/debugger/resourceDebugService.ts | 25 +++-- .../debugger/resourceDebugSessionRegistry.ts | 2 - .../src/debugger/resourceDebugTelemetry.ts | 9 +- .../src/lm/appHostLifecycleToolService.ts | 3 +- extension/src/lm/resourceDebugToolService.ts | 102 +++++++++++------- extension/src/loc/strings.ts | 2 +- .../src/test-e2e/packageSurface.e2e.test.ts | 21 ++++ .../src/test/resourceDebugService.test.ts | 89 ++++++++++++++- extension/src/test/resourceDebugTools.test.ts | 98 +++++++++++++++-- 12 files changed, 290 insertions(+), 71 deletions(-) diff --git a/extension/README.md b/extension/README.md index e6914b3296c..c8f9b6ad9f2 100644 --- a/extension/README.md +++ b/extension/README.md @@ -118,7 +118,7 @@ When VS Code is active, agents should prefer these editor operations over runnin All three tools take the workspace-relative path of an AppHost Aspire already discovered — the same list the Aspire view shows — and resolve it against that list rather than against your filesystem. An agent can only name an AppHost Aspire found, so it cannot point a tool at an arbitrary file. Absolute paths are rejected; in a multi-root workspace, prefix the path with the workspace folder name. The resource tool also takes a running resource name from that AppHost. `auto` does not start or restart a resource; it currently resolves to debugger attach. -These tools only work in a [trusted workspace](https://code.visualstudio.com/docs/editing/workspaces/workspace-trust), and VS Code asks the chat user to confirm every invocation. When a target resolves during preparation, the confirmation shows the discovered AppHost identity. If it cannot resolve then, VS Code still shows a generic confirmation with no untrusted path text; invocation resolves the target again and fails safely if it is invalid, untrusted, or unavailable. The tools never pick an AppHost for you: a path that names no discovered AppHost, or more than one, fails and reports the AppHosts you can name. Starting an AppHost that is already starting or running does not launch a second one. Stopping an editor-created AppHost coordinates its Aspire debug session; stopping an AppHost started from a terminal delegates to `aspire stop --apphost` for the same discovered path. The extension does not kill arbitrary processes. Resource attach returns a safe failure when the resource is stopped, unsupported, or missing its debugger extension. +These tools only work in a [trusted workspace](https://code.visualstudio.com/docs/editing/workspaces/workspace-trust), and VS Code asks the chat user to confirm every invocation. When a target resolves during preparation, the confirmation shows the discovered AppHost identity. If it cannot resolve then, VS Code still shows a generic confirmation with no untrusted path text; invocation resolves the target again and fails safely if it is invalid, untrusted, or unavailable. Lifecycle tools never pick an AppHost for you: a path that names no discovered AppHost, or more than one, fails and reports the AppHosts you can name. Starting an AppHost that is already starting or running does not launch a second one. Stopping an editor-created AppHost coordinates its Aspire debug session; stopping an AppHost started from a terminal delegates to `aspire stop --apphost` for the same discovered path. The extension does not kill arbitrary processes. Resource attach returns a safe failure when the resource is stopped, unsupported, or missing its debugger extension. --- diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 551943db433..81ad266ae98 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -109,6 +109,9 @@ Attaching debugger to Aspire resource {0}... + + Attaching debugger to the requested Aspire resource... + Attaching debugger to {0}... @@ -886,9 +889,6 @@ Unable to add folder to workspace: {0} - - Unable to attach debugger to the requested Aspire resource. - Update Aspire CLI diff --git a/extension/package.nls.json b/extension/package.nls.json index e9c7f53875a..18a1350742d 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -325,7 +325,7 @@ "aspire-vscode.strings.resourceDebugToolConfirmationMessage": "Attach the debugger to resource {0} from Aspire AppHost {1}?", "aspire-vscode.strings.resourceDebugToolUnresolvedConfirmationMessage": "Attach the debugger to the requested Aspire resource?", "aspire-vscode.strings.resourceDebugToolInvocationMessage": "Attaching debugger to Aspire resource {0}...", - "aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage": "Unable to attach debugger to the requested Aspire resource.", + "aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage": "Attaching debugger to the requested Aspire resource...", "languageModelTool.aspireAppHostStart.displayName": "Start Aspire AppHost", "languageModelTool.aspireAppHostStart.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started.", "languageModelTool.aspireAppHostStart.userDescription": "Start an Aspire AppHost from this workspace in run or debug mode.", diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 412dfb05ec7..365211488e9 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -22,6 +22,7 @@ import { type ResourceDebugDebuggerRequirement, type ResourceDebugResourceState, type ResourceDebugResourceType, + type ResourceDebugRequestedStrategyTelemetryBucket, type ResourceDebugResultTelemetryMeasurements, type ResourceDebugTelemetry, monotonicResourceDebugClock, @@ -88,12 +89,12 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger this._telemetry, this._clock, request.source, - requestedStrategy ?? 'auto'); + requestedStrategy ?? 'invalid'); telemetry.recordStart(); let result: ResourceDebugResult = { outcome: 'error', errorKind: 'unexpected' }; try { - if (effectiveStrategy === undefined) { + if (requestedStrategy === undefined || effectiveStrategy === undefined) { result = { outcome: 'error', errorKind: 'unexpected' }; return result; } @@ -117,7 +118,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger resolvedTarget, request.resourceName, request.cancellationToken, - async () => await this._debugSerialized(request, resolvedTarget, telemetry), + async () => await this._debugSerialized(request, resolvedTarget, telemetry, requestedStrategy, effectiveStrategy), () => ({ outcome: 'cancelled' })); return result; } @@ -176,6 +177,8 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger request: ResourceDebugRequest, resolvedTarget: ResourceDebugAppHostTarget, telemetry: ResourceDebugOperationTelemetry, + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -229,7 +232,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'resourceNotRunning' }; } - return await this._attach(request, resolvedTarget, resource, provider, telemetry); + return await this._attach(request, resolvedTarget, resource, provider, telemetry, requestedStrategy, effectiveStrategy); } private async _attach( @@ -238,6 +241,8 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger resource: ResourceJson, provider: ResourceAttachProvider, telemetry: ResourceDebugOperationTelemetry, + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', ): Promise { if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; @@ -300,7 +305,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger appHost, resource.name, configuration, - telemetry.createSessionMetadata(provider.id)); + telemetry.createSessionMetadata(provider.id, requestedStrategy, effectiveStrategy)); try { telemetry.recordDebugStart(); const started = await this._dependencies.startDebugging(undefined, attempt.configuration); @@ -341,7 +346,7 @@ class ResourceDebugOperationTelemetry { private readonly _telemetry: ResourceDebugTelemetry, private readonly _clock: ResourceDebugClock, private readonly _source: ResourceDebugRequest['source'], - private readonly _requestedStrategy: ResourceDebugStrategy, + private readonly _requestedStrategy: ResourceDebugRequestedStrategyTelemetryBucket, ) { this._startedAt = this._getTimestamp(); } @@ -384,11 +389,17 @@ class ResourceDebugOperationTelemetry { }); } - createSessionMetadata(provider: ResourceAttachProvider['id']): ResourceDebugAttachSessionMetadata { + createSessionMetadata( + provider: ResourceAttachProvider['id'], + requestedStrategy: ResourceDebugStrategy, + effectiveStrategy: 'attach', + ): ResourceDebugAttachSessionMetadata { return { source: this._source, provider, resource_type: this._resourceType ?? 'other', + requested_strategy: requestedStrategy, + effective_strategy: effectiveStrategy, }; } diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 865c258accc..7c32441a231 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -202,8 +202,6 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { if (attempt.sessionStarted) { this._recordTelemetry(() => this._telemetry.recordSessionEnd({ ...attempt.telemetry, - requested_strategy: 'attach', - effective_strategy: 'attach', controller: 'editor', session_end_reason: 'terminated', }, this._getMeasurements(attempt.sessionStartedAt))); diff --git a/extension/src/debugger/resourceDebugTelemetry.ts b/extension/src/debugger/resourceDebugTelemetry.ts index edc0e502e11..d9bee2c3429 100644 --- a/extension/src/debugger/resourceDebugTelemetry.ts +++ b/extension/src/debugger/resourceDebugTelemetry.ts @@ -10,6 +10,7 @@ import { sendTelemetryEvent } from '../utils/telemetry'; export type ResourceDebugResourceType = 'project' | 'executable' | 'container' | 'other'; export type ResourceDebugResourceState = 'running' | 'notRunning' | 'unknown'; export type ResourceDebugDebuggerRequirement = 'installed' | 'missing' | 'none'; +export type ResourceDebugRequestedStrategyTelemetryBucket = ResourceDebugStrategy | 'invalid'; export interface ResourceDebugClock { now(): number; @@ -17,7 +18,7 @@ export interface ResourceDebugClock { export interface ResourceDebugStartTelemetryProperties { readonly source: ResourceDebugSource; - readonly requested_strategy: ResourceDebugStrategy; + readonly requested_strategy: ResourceDebugRequestedStrategyTelemetryBucket; readonly controller: 'editor'; } @@ -25,7 +26,7 @@ export interface ResourceDebugResultTelemetryProperties { readonly source: ResourceDebugSource; readonly provider: ResourceAttachProviderId | 'none'; readonly resource_type?: ResourceDebugResourceType; - readonly requested_strategy: ResourceDebugStrategy; + readonly requested_strategy: ResourceDebugRequestedStrategyTelemetryBucket; readonly effective_strategy: 'attach' | 'none'; readonly outcome: ResourceDebugResult['outcome']; readonly controller: 'editor'; @@ -44,11 +45,11 @@ export interface ResourceDebugAttachSessionMetadata { readonly source: ResourceDebugSource; readonly provider: ResourceAttachProviderId; readonly resource_type: ResourceDebugResourceType; + readonly requested_strategy: ResourceDebugStrategy; + readonly effective_strategy: 'attach'; } export interface ResourceDebugSessionEndTelemetryProperties extends ResourceDebugAttachSessionMetadata { - readonly requested_strategy: 'attach'; - readonly effective_strategy: 'attach'; readonly controller: 'editor'; readonly session_end_reason: 'terminated'; } diff --git a/extension/src/lm/appHostLifecycleToolService.ts b/extension/src/lm/appHostLifecycleToolService.ts index 52430082491..92b13510561 100644 --- a/extension/src/lm/appHostLifecycleToolService.ts +++ b/extension/src/lm/appHostLifecycleToolService.ts @@ -237,7 +237,7 @@ export class AppHostLifecycleToolService implements vscode.Disposable { effectiveMode); } - async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { + private async resolveTarget(rawAppHost: unknown, token: vscode.CancellationToken): Promise { return await this._dependencies.targetResolver.resolveTarget(rawAppHost, token); } @@ -315,7 +315,6 @@ export class AppHostLifecycleToolService implements vscode.Disposable { extensionLogOutputChannel.error(`Aspire language model tool ${tool} failed: ${String(error)}`); return createResult(tool, 'failed', relativePath, controller, requestedMode, effectiveMode); } - } function getSessionMode(session: AppHostLifecycleEditorSession): AppHostLifecycleMode { diff --git a/extension/src/lm/resourceDebugToolService.ts b/extension/src/lm/resourceDebugToolService.ts index 3b4eda42e6f..1b740a6d6ed 100644 --- a/extension/src/lm/resourceDebugToolService.ts +++ b/extension/src/lm/resourceDebugToolService.ts @@ -33,13 +33,21 @@ interface ParsedInput { * lifecycle policy remain with the shared resolver and ResourceDebugger respectively. */ export class AspireResourceDebugToolService implements vscode.Disposable { + private readonly _operationCancellationSources = new Set(); private _disposed = false; constructor(private readonly _dependencies: AspireResourceDebugToolDependencies) { } dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + for (const cancellationSource of this._operationCancellationSources) { + cancellationSource.cancel(); + } } /** @@ -47,12 +55,51 @@ export class AspireResourceDebugToolService implements vscode.Disposable { * calls this again rather than retaining the absolute path from confirmation. */ async prepare(input: unknown, token: vscode.CancellationToken): Promise { + return await this._runOperation(token, operationToken => this._prepare(input, operationToken)); + } + + async debug(input: unknown, token: vscode.CancellationToken): Promise { + return await this._runOperation(token, async operationToken => { + const preparation = await this._prepare(input, operationToken); + if (!preparation.canDebug) { + return preparation.result; + } + + if (operationToken.isCancellationRequested) { + return this.createResult( + 'cancelled', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + + try { + const result = await this._dependencies.resourceDebugger.debug({ + source: 'languageModelTool', + strategy: preparation.requestedStrategy, + appHost: preparation.target, + resourceName: preparation.resourceName, + cancellationToken: operationToken, + }); + return mapResourceDebugResult(result, preparation.target.displayPath, preparation.resourceName, preparation.requestedStrategy); + } + catch (error) { + return this.createResult( + isCommandCancellation(error) || operationToken.isCancellationRequested ? 'cancelled' : 'failed', + preparation.target.displayPath, + preparation.resourceName, + preparation.requestedStrategy); + } + }); + } + + private async _prepare(input: unknown, token: vscode.CancellationToken): Promise { const parsed = parseInput(input); if (!parsed) { return this.reject('invalidInput'); } - if (this._disposed || token.isCancellationRequested) { + if (token.isCancellationRequested) { return this.reject('cancelled', '', parsed); } @@ -64,7 +111,7 @@ export class AspireResourceDebugToolService implements vscode.Disposable { try { const resolution = await this._dependencies.targetResolver.resolveTarget(parsed.appHostPath, token); - if (this._disposed || token.isCancellationRequested) { + if (token.isCancellationRequested) { return this.reject('cancelled', '', parsed); } @@ -84,47 +131,24 @@ export class AspireResourceDebugToolService implements vscode.Disposable { } } - async debug(input: unknown, token: vscode.CancellationToken): Promise { - const preparation = await this.prepare(input, token); - if (!preparation.canDebug) { - return preparation.result; - } - - if (this._disposed || token.isCancellationRequested) { - return this.createResult( - 'cancelled', - preparation.target.displayPath, - preparation.resourceName, - preparation.requestedStrategy); - } - + private async _runOperation( + callerToken: vscode.CancellationToken, + operation: (token: vscode.CancellationToken) => Promise, + ): Promise { + const cancellationSource = new vscode.CancellationTokenSource(); + this._operationCancellationSources.add(cancellationSource); + const cancellationRegistration = callerToken.onCancellationRequested(() => cancellationSource.cancel()); try { - // Re-check immediately before crossing into the shared debugger service. A - // deactivating extension must not initiate a new attach after preparation won - // the race with disposal. - if (this._disposed || token.isCancellationRequested) { - return this.createResult( - 'cancelled', - preparation.target.displayPath, - preparation.resourceName, - preparation.requestedStrategy); + if (this._disposed || callerToken.isCancellationRequested) { + cancellationSource.cancel(); } - const result = await this._dependencies.resourceDebugger.debug({ - source: 'languageModelTool', - strategy: preparation.requestedStrategy, - appHost: preparation.target, - resourceName: preparation.resourceName, - cancellationToken: token, - }); - return mapResourceDebugResult(result, preparation.target.displayPath, preparation.resourceName, preparation.requestedStrategy); + return await operation(cancellationSource.token); } - catch (error) { - return this.createResult( - isCommandCancellation(error) || token.isCancellationRequested ? 'cancelled' : 'failed', - preparation.target.displayPath, - preparation.resourceName, - preparation.requestedStrategy); + finally { + cancellationRegistration.dispose(); + this._operationCancellationSources.delete(cancellationSource); + cancellationSource.dispose(); } } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 36d40b11730..9b9fae1c8f2 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -283,4 +283,4 @@ export const resourceDebugToolConfirmationTitle = vscode.l10n.t('Attach debugger export const resourceDebugToolConfirmationMessage = (resourceName: string, appHostPath: string) => vscode.l10n.t('Attach the debugger to resource {0} from Aspire AppHost {1}?', resourceName, appHostPath); export const resourceDebugToolUnresolvedConfirmationMessage = vscode.l10n.t('Attach the debugger to the requested Aspire resource?'); export const resourceDebugToolInvocationMessage = (resourceName: string) => vscode.l10n.t('Attaching debugger to Aspire resource {0}...', resourceName); -export const resourceDebugToolUnavailableInvocationMessage = vscode.l10n.t('Unable to attach debugger to the requested Aspire resource.'); +export const resourceDebugToolUnavailableInvocationMessage = vscode.l10n.t('Attaching debugger to the requested Aspire resource...'); diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index 7b73001fb15..c9c42d222f8 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -165,6 +165,27 @@ suite('Aspire package contribution surface E2E', function () { assert.deepStrictEqual(Object.entries(assetStatus).filter(([, exists]) => !exists), []); }); + test('prepares the resource debug tool from the merged preparable tool map when its AppHost is unresolved', async () => { + const prepared = (await executeE2eControlCommand({ + name: 'prepareLanguageModelToolInvocation', + toolName: 'aspire_resource_debug', + input: { + appHostPath: 'unresolved/AppHost.csproj', + resourceName: 'api', + }, + })).result as { + invocationMessage?: string; + confirmationTitle?: string; + confirmationMessage?: string; + }; + + assert.deepStrictEqual(prepared, { + invocationMessage: 'Attaching debugger to the requested Aspire resource...', + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: 'Attach the debugger to the requested Aspire resource?', + }); + }); + test('applies the shared CLI availability path to visible CLI-dependent package commands', async () => { await openAspireView(); await waitForRepositoryIdle(); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index abb40bd3ef3..69fae838d8b 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -896,6 +896,8 @@ suite('Resource debug service', () => { source: 'tree', provider: 'dotnet', resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', }); try { @@ -1040,6 +1042,64 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('does not start debugging when cancellation occurs during the fresh resource snapshot', async () => { + let finishSnapshot: (() => void) | undefined; + let markSnapshotStarted: (() => void) | undefined; + const snapshot = new Promise(resolve => { + finishSnapshot = resolve; + }); + const snapshotStarted = new Promise(resolve => { + markSnapshotStarted = resolve; + }); + const cancellation = new vscode.CancellationTokenSource(); + const startDebugging = sinon.stub().resolves(true); + const { service, repository, sessions } = createService({ startDebugging }); + repository.fetchAppHostResourcesOnce = async () => { + markSnapshotStarted!(); + await snapshot; + return [createResource()]; + }; + + try { + const operation = service.debug(createRequest({ cancellationToken: cancellation.token })); + await snapshotStarted; + cancellation.cancel(); + finishSnapshot!(); + + assert.deepStrictEqual(await operation, { outcome: 'cancelled' }); + assert.strictEqual(startDebugging.callCount, 0); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + + test('keeps an accepted attach session when cancellation arrives after debugging starts', async () => { + const cancellation = new vscode.CancellationTokenSource(); + let startedConfiguration: vscode.DebugConfiguration | undefined; + const { service, sessions, events } = createService({ + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + events.start(configuration); + cancellation.cancel(); + return true; + }, + }); + + try { + assert.deepStrictEqual( + await service.debug(createRequest({ cancellationToken: cancellation.token })), + { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(sessions.hasActiveSession(target, 'api'), true); + } + finally { + cancellation.dispose(); + sessions.dispose(); + } + }); + test('removes a terminated independent attach session without stopping its resource', async () => { let startedConfiguration: vscode.DebugConfiguration | undefined; const { service, sessions, events } = createService({ @@ -1176,13 +1236,32 @@ suite('Resource debug service', () => { test('fails closed when a caller bypasses the bounded debug strategy contract', async () => { const startDebugging = sinon.stub().resolves(true); - const { service, sessions } = createService({ startDebugging }); + const telemetry = new TestResourceDebugTelemetry(); + const { service, sessions } = createService({ startDebugging, telemetry }); try { assert.deepStrictEqual( await service.debug(createRequest({ strategy: 'restart' as never })), { outcome: 'error', errorKind: 'unexpected' }); assert.strictEqual(startDebugging.callCount, 0); + assert.deepStrictEqual( + telemetry.events.map(event => ({ + name: event.name, + requestedStrategy: event.properties.requested_strategy, + effectiveStrategy: event.properties.effective_strategy, + })), + [ + { + name: 'aspire/vscode/resourcedebug/start', + requestedStrategy: 'invalid', + effectiveStrategy: undefined, + }, + { + name: 'aspire/vscode/resourcedebug/result', + requestedStrategy: 'invalid', + effectiveStrategy: 'none', + }, + ]); } finally { sessions.dispose(); @@ -1448,7 +1527,9 @@ suite('Resource debug service', () => { events = fixture.events; try { - assert.deepStrictEqual(await fixture.service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual( + await fixture.service.debug(createRequest({ source: 'languageModelTool', strategy: 'auto' })), + { outcome: 'started', providerId: 'dotnet' }); telemetry.currentTime = 140; assert.ok(events.startedConfiguration); events.terminate(events.startedConfiguration); @@ -1456,10 +1537,10 @@ suite('Resource debug service', () => { assert.deepStrictEqual(telemetry.events.at(-1), { name: 'aspire/vscode/resourcedebug/session/end', properties: { - source: 'tree', + source: 'languageModelTool', provider: 'dotnet', resource_type: 'project', - requested_strategy: 'attach', + requested_strategy: 'auto', effective_strategy: 'attach', controller: 'editor', session_end_reason: 'terminated', diff --git a/extension/src/test/resourceDebugTools.test.ts b/extension/src/test/resourceDebugTools.test.ts index cfa70c4c5c0..221028a91bf 100644 --- a/extension/src/test/resourceDebugTools.test.ts +++ b/extension/src/test/resourceDebugTools.test.ts @@ -40,17 +40,21 @@ class FakeTargetResolver implements SafeAppHostTargetResolver { }, }]; error: Error | undefined; - onResolve: (() => void) | undefined; + errors: Array = []; + tokens: vscode.CancellationToken[] = []; + onResolve: ((token: vscode.CancellationToken) => void | Promise) | undefined; async resolveTarget(_rawAppHost: unknown, token: vscode.CancellationToken): Promise { this.calls++; - this.onResolve?.(); + this.tokens.push(token); + await this.onResolve?.(token); if (token.isCancellationRequested) { return { resolved: false, outcome: 'cancelled' }; } - if (this.error) { - throw this.error; + const error = this.errors[this.calls - 1] ?? this.error; + if (error) { + throw error; } return this.results[Math.min(this.calls - 1, this.results.length - 1)]; @@ -61,11 +65,11 @@ class FakeResourceDebugger implements ResourceDebugger { calls: ResourceDebugRequest[] = []; result: ResourceDebugResult = { outcome: 'started', providerId: 'dotnet' }; error: Error | undefined; - onDebug: (() => void) | undefined; + onDebug: ((request: ResourceDebugRequest) => void | Promise) | undefined; async debug(request: ResourceDebugRequest): Promise { this.calls.push(request); - this.onDebug?.(); + await this.onDebug?.(request); if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; } @@ -189,6 +193,7 @@ suite('Aspire resource debug language model tool', () => { title: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationTitle'], message: packageNls['aspire-vscode.strings.resourceDebugToolConfirmationMessage'], invocation: packageNls['aspire-vscode.strings.resourceDebugToolInvocationMessage'], + unresolvedInvocation: packageNls['aspire-vscode.strings.resourceDebugToolUnavailableInvocationMessage'], display: packageNls['languageModelTool.aspireResourceDebug.displayName'], model: packageNls['languageModelTool.aspireResourceDebug.modelDescription'], user: packageNls['languageModelTool.aspireResourceDebug.userDescription'], @@ -200,6 +205,7 @@ suite('Aspire resource debug language model tool', () => { title: 'Attach debugger to Aspire resource', message: 'Attach the debugger to resource {0} from Aspire AppHost {1}?', invocation: 'Attaching debugger to Aspire resource {0}...', + unresolvedInvocation: 'Attaching debugger to the requested Aspire resource...', display: 'Debug Aspire resource', model: 'Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported.', user: 'Attach the debugger to a running Aspire resource.', @@ -433,7 +439,7 @@ suite('Aspire resource debug language model tool', () => { title: 'Attach debugger to Aspire resource', message: 'Attach the debugger to the requested Aspire resource?', }); - assert.strictEqual(prepared.invocationMessage, 'Unable to attach debugger to the requested Aspire resource.'); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to the requested Aspire resource...'); assert.strictEqual(JSON.stringify(prepared).includes('../private/token=secret'), false); assert.strictEqual(JSON.stringify(prepared).includes(absoluteAppHostPath), false); assert.strictEqual(result.outcome, 'started'); @@ -453,6 +459,29 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual(JSON.stringify(prepared).includes('injected'), false); }); + test('allows an invocation to resolve after preparation fails', async () => { + const resolver = new FakeTargetResolver(); + resolver.errors = [new Error('initial AppHost discovery failure'), undefined]; + const { service, resourceDebugger } = createService(resolver); + const tool = new AspireResourceDebugLanguageModelTool(service); + const input = createInput(); + + const prepared = await tool.prepareInvocation( + { input: input as unknown as AspireResourceDebugToolInput }, + new vscode.CancellationTokenSource().token); + const result = readToolResultPayload(await tool.invoke( + { input: input as unknown as AspireResourceDebugToolInput, toolInvocationToken: undefined }, + new vscode.CancellationTokenSource().token)); + + assert.deepStrictEqual(prepared.confirmationMessages, { + title: 'Attach debugger to Aspire resource', + message: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(prepared.invocationMessage, 'Attaching debugger to the requested Aspire resource...'); + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(resourceDebugger.calls.length, 1); + }); + test('escapes confirmed resource and AppHost identities with the shared Markdown helper', async () => { const resolver = new FakeTargetResolver(); resolver.results = [{ @@ -541,6 +570,61 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual(resourceDebugger.calls.length, 0); }); + test('cancels a resolver operation owned by the service when it is disposed', async () => { + const resolver = new FakeTargetResolver(); + let markResolutionStarted: (() => void) | undefined; + const resolutionStarted = new Promise(resolve => { + markResolutionStarted = resolve; + }); + resolver.onResolve = token => new Promise(resolve => { + markResolutionStarted!(); + token.onCancellationRequested(resolve); + }); + const { service, resourceDebugger } = createService(resolver); + const callerCancellation = new vscode.CancellationTokenSource(); + + try { + const operation = service.debug(createInput(), callerCancellation.token); + await resolutionStarted; + service.dispose(); + + assert.strictEqual((await operation).outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 0); + assert.notStrictEqual(resolver.tokens[0], callerCancellation.token); + assert.strictEqual(resolver.tokens[0].isCancellationRequested, true); + } + finally { + callerCancellation.dispose(); + } + }); + + test('cancels an in-flight debugger operation when the service is disposed', async () => { + const { service, resourceDebugger } = createService(); + let markDebugStarted: (() => void) | undefined; + const debugStarted = new Promise(resolve => { + markDebugStarted = resolve; + }); + resourceDebugger.onDebug = request => new Promise(resolve => { + markDebugStarted!(); + request.cancellationToken?.onCancellationRequested(resolve); + }); + const callerCancellation = new vscode.CancellationTokenSource(); + + try { + const operation = service.debug(createInput(), callerCancellation.token); + await debugStarted; + service.dispose(); + + assert.strictEqual((await operation).outcome, 'cancelled'); + assert.strictEqual(resourceDebugger.calls.length, 1); + assert.notStrictEqual(resourceDebugger.calls[0].cancellationToken, callerCancellation.token); + assert.strictEqual(resourceDebugger.calls[0].cancellationToken?.isCancellationRequested, true); + } + finally { + callerCancellation.dispose(); + } + }); + test('maps every bounded resource debug result', async () => { const cases: Array<{ readonly result: ResourceDebugResult; From 07c0d5845e432837df87bc7683348efb29c415ab Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 07:34:09 -0400 Subject: [PATCH 62/90] fix(extension): document resource debug telemetry strategies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/test/telemetryInventory.test.ts | 107 ++++++++++++++++++ extension/telemetry.json | 8 +- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/extension/src/test/telemetryInventory.test.ts b/extension/src/test/telemetryInventory.test.ts index 6770f19b28f..f7b8be86500 100644 --- a/extension/src/test/telemetryInventory.test.ts +++ b/extension/src/test/telemetryInventory.test.ts @@ -13,6 +13,14 @@ type TelemetryRegistryEvent = { entries: string[]; }; +type ResourceDebugTelemetryPropertyExpectation = { + eventName: string; + interfaceName: string; + propertyName: string; + values: readonly string[]; + comment: string; +}; + // Telemetry events emit verbatim to the wire — the registry-declared name // (e.g. `aspire/vscode/command/invoked`, `aspire/dashboard/operation`) is // what appears in `extension/telemetry.json`. The transport sender strips VS @@ -40,12 +48,78 @@ const platformCommonTelemetryProperties = [ 'common.vscodesessionid', 'common.vscodeversion', ] as const; +const resourceDebugTelemetryPropertyExpectations: readonly ResourceDebugTelemetryPropertyExpectation[] = [ + { + eventName: 'aspire/vscode/resourcedebug/start', + interfaceName: 'ResourceDebugStartTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto', 'invalid'], + comment: 'The bounded resource debug strategy requested by the caller: auto, attach, or invalid.', + }, + { + eventName: 'aspire/vscode/resourcedebug/result', + interfaceName: 'ResourceDebugResultTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto', 'invalid'], + comment: 'The bounded resource debug strategy requested by the caller: auto, attach, or invalid.', + }, + { + eventName: 'aspire/vscode/resourcedebug/result', + interfaceName: 'ResourceDebugResultTelemetryProperties', + propertyName: 'effective_strategy', + values: ['attach', 'none'], + comment: 'The bounded effective resource debug strategy: attach or none.', + }, + { + eventName: 'aspire/vscode/resourcedebug/session/end', + interfaceName: 'ResourceDebugSessionEndTelemetryProperties', + propertyName: 'requested_strategy', + values: ['attach', 'auto'], + comment: 'The bounded resource debug strategy requested by the caller: auto or attach.', + }, + { + eventName: 'aspire/vscode/resourcedebug/session/end', + interfaceName: 'ResourceDebugSessionEndTelemetryProperties', + propertyName: 'effective_strategy', + values: ['attach'], + comment: 'The bounded effective resource debug strategy: attach.', + }, +]; function readTelemetryInventory(): TelemetryInventory { const inventoryPath = path.resolve(__dirname, '../../telemetry.json'); return JSON.parse(fs.readFileSync(inventoryPath, 'utf8')) as TelemetryInventory; } +function readResourceDebugTelemetryPropertyValues(interfaceName: string, propertyName: string): string[] { + const telemetryPath = path.resolve(__dirname, '../../src/debugger/resourceDebugTelemetry.ts'); + const program = ts.createProgram([telemetryPath], { + moduleResolution: ts.ModuleResolutionKind.Node10, + target: ts.ScriptTarget.Latest, + }); + const sourceFile = program.getSourceFile(telemetryPath); + const telemetryInterface = sourceFile?.statements.find((node): node is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(node) && node.name.text === interfaceName); + if (!telemetryInterface) { + return []; + } + + const typeChecker = program.getTypeChecker(); + const property = typeChecker + .getTypeAtLocation(telemetryInterface) + .getProperty(propertyName); + if (!property) { + return []; + } + + const declaration = property.valueDeclaration ?? property.declarations?.[0]; + if (!declaration) { + return []; + } + + return getStringLiteralValues(typeChecker.getTypeOfSymbolAtLocation(property, declaration)); +} + function readTelemetryRegistryEvents(): TelemetryRegistryEvent[] { const registryPath = path.resolve(__dirname, '../../src/utils/telemetryRegistry.ts'); const sourceText = fs.readFileSync(registryPath, 'utf8'); @@ -128,6 +202,16 @@ function getStringLiteralUnion(typeNode: ts.TypeNode): string[] { return []; } +function getStringLiteralValues(type: ts.Type): string[] { + if (type.isUnion()) { + return [...new Set(type.types.flatMap(getStringLiteralValues))].sort(); + } + + return type.flags & ts.TypeFlags.StringLiteral + ? [(type as ts.StringLiteralType).value] + : []; +} + suite('extension/telemetry.json', () => { test('event entity names are lowercase', () => { const inventory = readTelemetryInventory(); @@ -175,4 +259,27 @@ suite('extension/telemetry.json', () => { assert.deepStrictEqual(suspiciousRegistryEntries, []); }); + + test('documents bounded resource debug strategy telemetry', () => { + const inventory = readTelemetryInventory(); + const inconsistencies = resourceDebugTelemetryPropertyExpectations.flatMap(expectation => { + const inventoryProperty = inventory.events[expectation.eventName]?.[expectation.propertyName] as { comment?: unknown } | undefined; + const actualValues = readResourceDebugTelemetryPropertyValues(expectation.interfaceName, expectation.propertyName); + const actualComment = inventoryProperty?.comment; + + return actualComment === expectation.comment && + JSON.stringify(actualValues) === JSON.stringify(expectation.values) + ? [] + : [{ + eventName: expectation.eventName, + propertyName: expectation.propertyName, + expectedValues: expectation.values, + actualValues, + expectedComment: expectation.comment, + actualComment, + }]; + }); + + assert.deepStrictEqual(inconsistencies, []); + }); }); diff --git a/extension/telemetry.json b/extension/telemetry.json index 298b988c293..045c41c3a68 100644 --- a/extension/telemetry.json +++ b/extension/telemetry.json @@ -316,7 +316,7 @@ "requested_strategy": { "classification": "SystemMetaData", "purpose": "FeatureInsight", - "comment": "The bounded resource debug strategy requested by the caller: attach." + "comment": "The bounded resource debug strategy requested by the caller: auto, attach, or invalid." }, "controller": { "classification": "SystemMetaData", @@ -343,12 +343,12 @@ "requested_strategy": { "classification": "SystemMetaData", "purpose": "FeatureInsight", - "comment": "The bounded resource debug strategy requested by the caller: attach." + "comment": "The bounded resource debug strategy requested by the caller: auto, attach, or invalid." }, "effective_strategy": { "classification": "SystemMetaData", "purpose": "FeatureInsight", - "comment": "Whether a bounded attach strategy was effective, or no strategy was used." + "comment": "The bounded effective resource debug strategy: attach or none." }, "outcome": { "classification": "SystemMetaData", @@ -410,7 +410,7 @@ "requested_strategy": { "classification": "SystemMetaData", "purpose": "FeatureInsight", - "comment": "The bounded resource debug strategy requested by the caller: attach." + "comment": "The bounded resource debug strategy requested by the caller: auto or attach." }, "effective_strategy": { "classification": "SystemMetaData", From 98abae366b78220bb62a703be09b50cb60ed8358 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 08:09:42 -0400 Subject: [PATCH 63/90] test(extension): cover resource debug LM tool E2E Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .github/workflows/extension-e2e-tests.yml | 16 ++ .../appHostLifecycleTools.e2e.test.ts | 113 +++------ .../test-e2e/helpers/languageModelTools.ts | 84 +++++++ .../test-e2e/resourceDebugTools.e2e.test.ts | 235 ++++++++++++++++++ extension/src/testing/e2eStateFileBridge.ts | 62 ++++- extension/src/types/extensionApi.ts | 2 +- 6 files changed, 420 insertions(+), 92 deletions(-) create mode 100644 extension/src/test-e2e/helpers/languageModelTools.ts create mode 100644 extension/src/test-e2e/resourceDebugTools.e2e.test.ts diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 4077a3bf5cc..581254ef1f4 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -248,6 +248,22 @@ jobs: archivePattern: aspire-cli-win-x64*.zip cliBinary: aspire.exe useXvfb: false + - name: Linux + shardName: resource-debug-tools + spec: out/test-e2e/test-e2e/resourceDebugTools.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + - name: Windows + shardName: resource-debug-tools + spec: out/test-e2e/test-e2e/resourceDebugTools.e2e.test.js + runner: windows-latest + rid: win-x64 + archivePattern: aspire-cli-win-x64*.zip + cliBinary: aspire.exe + useXvfb: false - name: Linux shardName: edge-cases spec: out/test-e2e/test-e2e/edgeCases.e2e.test.js diff --git a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts index 54776ecbb97..2a2147c4071 100644 --- a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts +++ b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts @@ -4,9 +4,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { findRunningAppHost, getDebugLaunchCount, isSamePath, readStateFile, waitForDebugSessionStartup, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; import { executeE2eControlCommand, runE2eTeardown, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { invokeLanguageModelTool, prepareLanguageModelToolInvocation } from './helpers/languageModelTools'; import { runProcess, terminateProcessTree } from './helpers/process'; import { ensureDiagnosticsDir, getCliPath, getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; -import { acceptModalDialog, openAspireView, type AcceptedModalDialog } from './helpers/vscode'; +import { openAspireView } from './helpers/vscode'; interface LifecycleToolResult { tool: string; @@ -17,12 +18,6 @@ interface LifecycleToolResult { controller: string; } -interface PreparedInvocation { - invocationMessage?: string; - confirmationTitle?: string; - confirmationMessage?: string; -} - interface RegisteredTool { name: string; tags: string[]; @@ -58,22 +53,22 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); const relativeAppHostPath = path.relative(getWorkspaceRoot(), appHostPath).split(path.sep).join('/'); - const registeredTools = await invokeControlCommand({ name: 'getRegisteredLanguageModelTools' }); - assert.deepStrictEqual(registeredTools.map(tool => tool.name), [startToolName, stopToolName]); + const registeredTools = (await executeE2eControlCommand({ name: 'getRegisteredLanguageModelTools' })).result as RegisteredTool[]; + assert.deepStrictEqual( + registeredTools + .filter(tool => tool.name === startToolName || tool.name === stopToolName) + .map(tool => tool.name), + [startToolName, stopToolName]); // The prepared invocation is also captured directly from the registered tool // instance so the exact confirmation strings are asserted, not just what the // modal renders. - const preparedStart = await invokeControlCommand({ - name: 'prepareLanguageModelToolInvocation', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'debug' }, - }); - const preparedStop = await invokeControlCommand({ - name: 'prepareLanguageModelToolInvocation', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }); + const preparedStart = await prepareLanguageModelToolInvocation( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'debug' }); + const preparedStop = await prepareLanguageModelToolInvocation( + stopToolName, + { appHostPath: relativeAppHostPath }); assert.strictEqual(preparedStart.confirmationTitle, 'Start Aspire AppHost'); assert.strictEqual(preparedStart.confirmationMessage, `Start the Aspire AppHost ${relativeAppHostPath} in debug mode?`); @@ -83,12 +78,10 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { const debugLaunchesBeforeStart = getDebugLaunchCount(); // Both calls are fired concurrently inside the extension host: the tool must // serialize them per AppHost path so only one of them launches a process. - const concurrentStartInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'debug' }, - times: 2, - }, 600000, 2, 'apphost-lifecycle-start-confirmation'); + const concurrentStartInvocation = await invokeLanguageModelTool( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'debug' }, + { timeoutMs: 600000, times: 2, expectedConfirmations: 2, screenshotName: 'apphost-lifecycle-start-confirmation' }); const concurrentStarts = concurrentStartInvocation.results; assert.strictEqual(concurrentStartInvocation.dialogs.length, 2, 'Expected each concurrent start call to require its own confirmation.'); @@ -112,11 +105,10 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { const startedSessions = readStateFile().state.debugSessions.filter(session => session.appHostPath !== undefined && isSamePath(session.appHostPath, appHostPath)); assert.strictEqual(startedSessions.length, 1, 'Expected exactly one editor-owned debug session after the concurrent start calls.'); - const repeatedStartInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: startToolName, - input: { appHostPath: relativeAppHostPath, mode: 'run' }, - }, 180000, 1); + const repeatedStartInvocation = await invokeLanguageModelTool( + startToolName, + { appHostPath: relativeAppHostPath, mode: 'run' }, + { timeoutMs: 180000 }); const repeatedStart = repeatedStartInvocation.results; assert.strictEqual(repeatedStartInvocation.dialogs[0].details, `Start the Aspire AppHost ${relativeAppHostPath} in run mode?`); assert.strictEqual(repeatedStart.length, 1); @@ -132,11 +124,10 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { assert.deepStrictEqual(await findAppHostProcessIds(appHostPath), [appHostPid], 'Expected the repeated start call to leave the original AppHost process running.'); assert.strictEqual(getDebugLaunchCount() - debugLaunchesBeforeStart, 1, 'Expected exactly one AppHost launch across all start calls.'); - const stopInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 300000, 1, 'apphost-lifecycle-stop-confirmation'); + const stopInvocation = await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 300000, screenshotName: 'apphost-lifecycle-stop-confirmation' }); const stopResults = stopInvocation.results; assert.strictEqual(stopInvocation.dialogs[0].message, 'Stop Aspire AppHost'); assert.strictEqual(stopInvocation.dialogs[0].details, `Stop the Aspire AppHost ${relativeAppHostPath}?`); @@ -150,11 +141,10 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { assert.strictEqual(readStateFile().state.debugSessions.length, 0, 'Expected no debug sessions after the stop tool call.'); assert.deepStrictEqual(await waitForAppHostProcessCount(appHostPath, 0, 180000), [], 'Expected no AppHost processes after the stop tool call.'); - const stopAgainResults = (await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 120000, 1)).results; + const stopAgainResults = (await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 120000 })).results; assert.strictEqual(stopAgainResults[0].outcome, 'notRunning'); assert.strictEqual(stopAgainResults[0].controller, 'none'); @@ -189,11 +179,10 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { externalAppHostPid = await waitForExternalAppHost(externalRun, appHostPath, 600000); assert.strictEqual(readStateFile().state.debugSessions.length, 0, 'Expected a CLI-started AppHost to have no editor debug session.'); - const stopInvocation = await invokeLifecycleTool({ - name: 'invokeLanguageModelTool', - toolName: stopToolName, - input: { appHostPath: relativeAppHostPath }, - }, 300000, 1, 'apphost-lifecycle-external-stop-confirmation'); + const stopInvocation = await invokeLanguageModelTool( + stopToolName, + { appHostPath: relativeAppHostPath }, + { timeoutMs: 300000, screenshotName: 'apphost-lifecycle-external-stop-confirmation' }); assert.strictEqual(stopInvocation.dialogs[0].message, 'Stop Aspire AppHost'); assert.strictEqual(stopInvocation.dialogs[0].details, `Stop the Aspire AppHost ${relativeAppHostPath}?`); @@ -357,40 +346,6 @@ function isProcessRunning(pid: number): boolean { } } -async function invokeControlCommand(command: Parameters[0], timeoutMs = 120000): Promise { - const status = await executeE2eControlCommand(command, { timeoutMs }); - if (status.errorMessage) { - throw new Error(`E2E control command '${command.name}' failed: ${status.errorMessage}`); - } - - return status.result as T; -} - -/** - * Invokes a lifecycle tool and accepts the confirmation VS Code raises for each - * invocation. `vscode.lm.invokeTool` blocks on that modal, so the control command must - * be started before the dialogs are answered rather than awaited first. - */ -async function invokeLifecycleTool( - command: Parameters[0], - timeoutMs: number, - expectedConfirmations: number, - screenshotName?: string -): Promise<{ results: LifecycleToolResult[]; dialogs: AcceptedModalDialog[] }> { - const invocation = invokeControlCommand<{ results: string[] }>(command, timeoutMs); - // Keep the rejection observed while the dialogs are being answered; the real failure - // is reported when the invocation is awaited below. - invocation.catch(() => undefined); - - const dialogs: AcceptedModalDialog[] = []; - for (let index = 0; index < expectedConfirmations; index++) { - dialogs.push(await acceptModalDialog('Yes', 180000, index === 0 ? screenshotName : undefined)); - } - - const result = await invocation; - return { results: result.results.map(item => JSON.parse(item) as LifecycleToolResult), dialogs }; -} - async function waitForAppHostProcessCount(appHostPath: string, expectedCount: number, timeoutMs: number): Promise { const started = Date.now(); let pids: number[] = []; diff --git a/extension/src/test-e2e/helpers/languageModelTools.ts b/extension/src/test-e2e/helpers/languageModelTools.ts new file mode 100644 index 00000000000..700afe8197e --- /dev/null +++ b/extension/src/test-e2e/helpers/languageModelTools.ts @@ -0,0 +1,84 @@ +import type { AspireExtensionE2EControlCommand } from '../../types/extensionApi'; +import { executeE2eControlCommand } from './fixtures'; +import { acceptModalDialog, type AcceptedModalDialog } from './vscode'; + +export interface PreparedLanguageModelToolInvocation { + invocationMessage?: string; + confirmationTitle?: string; + confirmationMessage?: string; +} + +export interface LanguageModelToolInvocationOptions { + expectedConfirmations?: number; + confirmationButtonTitle?: string; + screenshotName?: string; + timeoutMs?: number; + times?: number; + cancelAfterMs?: number; +} + +export interface LanguageModelToolInvocation { + results: T[]; + dialogs: AcceptedModalDialog[]; + cancelled: boolean; +} + +export async function prepareLanguageModelToolInvocation( + toolName: string, + input: Record, + timeoutMs = 120000, +): Promise { + return await invokeControlCommand({ + name: 'prepareLanguageModelToolInvocation', + toolName, + input, + }, timeoutMs); +} + +/** + * Drives any registered language-model tool through VS Code's public invocation API. + * Invocation begins before confirmation is accepted because `vscode.lm.invokeTool` waits + * for the modal. The state bridge stores only the tool's bounded text result. + */ +export async function invokeLanguageModelTool( + toolName: string, + input: Record, + options: LanguageModelToolInvocationOptions = {}, +): Promise> { + const expectedConfirmations = options.expectedConfirmations ?? 1; + const invocation = invokeControlCommand<{ results: string[]; cancelled?: boolean }>({ + name: 'invokeLanguageModelTool', + toolName, + input, + times: options.times, + cancelAfterMs: options.cancelAfterMs, + }, options.timeoutMs ?? 120000); + invocation.catch(() => undefined); + + const dialogs: AcceptedModalDialog[] = []; + for (let index = 0; index < expectedConfirmations; index++) { + dialogs.push(await acceptModalDialog( + options.confirmationButtonTitle ?? 'Yes', + 180000, + index === 0 ? options.screenshotName : undefined)); + } + + const result = await invocation; + return { + results: result.results.map(item => JSON.parse(item) as T), + dialogs, + cancelled: result.cancelled === true, + }; +} + +async function invokeControlCommand( + command: AspireExtensionE2EControlCommand, + timeoutMs: number, +): Promise { + const status = await executeE2eControlCommand(command, { timeoutMs }); + if (status.errorMessage) { + throw new Error(`E2E control command '${command.name}' failed: ${status.errorMessage}`); + } + + return status.result as T; +} diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts new file mode 100644 index 00000000000..b9a6d2a7338 --- /dev/null +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -0,0 +1,235 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { findResource, waitForCommandOutcome, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResourceState, waitForWorkspaceAppHost } from './helpers/assertions'; +import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { invokeLanguageModelTool, prepareLanguageModelToolInvocation } from './helpers/languageModelTools'; +import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; +import { openAspireView } from './helpers/vscode'; + +interface ResourceDebugToolResult { + tool: 'aspire_resource_debug'; + success: boolean; + outcome: string; + appHost: string; + resourceName: string; + requestedStrategy: 'auto' | 'attach'; + effectiveStrategy: 'attach' | 'none'; + controller: 'editor' | 'none'; + provider?: 'dotnet' | 'go'; + debuggerExtensions?: Array<{ id: string; label: string }>; +} + +const resourceDebugToolName = 'aspire_resource_debug'; + +// VS Code does not expose its telemetry transport to an Extension Host test, and the E2E bridge +// intentionally persists only bounded tool results. resourceDebugService.test.ts asserts the exact +// languageModelTool telemetry payload; this suite proves that source invokes the real registered tool. +suite('Aspire resource debug language model tool E2E', function () { + this.timeout(360000); + + teardown(async () => { + await runE2eTeardown([ + () => stopPrimaryAppHostIfRunning(), + () => waitForNoRunningAppHost(), + () => restoreWorkspaceCliPath(), + ], 'Resource debug language model tool E2E teardown failed.'); + }); + + test('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + const unresolved = await prepareLanguageModelToolInvocation(resourceDebugToolName, { + appHostPath: 'missing/AppHost.csproj', + resourceName: 'e2e-worker', + }); + assert.deepStrictEqual(unresolved, { + invocationMessage: 'Attaching debugger to the requested Aspire resource...', + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: 'Attach the debugger to the requested Aspire resource?', + }); + + const cases: Array<{ input: Record; outcome: string }> = [ + { + input: { + appHostPath: relativeAppHostPath, + resourceName: ' ', + }, + outcome: 'invalidInput', + }, + { + input: { + appHostPath: relativeAppHostPath, + resourceName: 'e2e-worker', + unexpected: 'value', + }, + outcome: 'invalidInput', + }, + { + input: { + appHostPath: 'missing/AppHost.csproj', + resourceName: 'e2e-worker', + }, + outcome: 'unknownAppHost', + }, + ]; + + for (const testCase of cases) { + const invocation = await invokeLanguageModelTool( + resourceDebugToolName, + testCase.input, + { expectedConfirmations: 1 }); + + assert.deepStrictEqual(invocation.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: 'Attach the debugger to the requested Aspire resource?', + }); + assert.strictEqual(invocation.results.length, 1); + assert.strictEqual(invocation.results[0].outcome, testCase.outcome); + assertSafeResourceDebugResult(invocation.results[0]); + } + }); + + test('requires explicit confirmation and returns safe running-resource outcomes', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + const runBefore = await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); + assert.ok(runBefore.startedObserved); + await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + const running = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(running.state, 'e2e-worker'); + assert.ok(worker); + + const prepared = await prepareLanguageModelToolInvocation(resourceDebugToolName, { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }); + assert.deepStrictEqual(prepared, { + invocationMessage: `Attaching debugger to Aspire resource ${worker.name}...`, + confirmationTitle: 'Attach debugger to Aspire resource', + confirmationMessage: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + + const invocation = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }, + { expectedConfirmations: 1, screenshotName: 'resource-debug-confirmation' }); + + assert.deepStrictEqual(invocation.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + assert.deepStrictEqual(invocation.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'debuggerExtensionMissing', + appHost: relativeAppHostPath, + resourceName: worker.name, + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + }]); + assertSafeResourceDebugResult(invocation.results[0]); + + const missingResource = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: 'missing-resource', + }, + { expectedConfirmations: 1 }); + assert.deepStrictEqual(missingResource.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'resourceNotFound', + appHost: relativeAppHostPath, + resourceName: 'missing-resource', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }]); + assertSafeResourceDebugResult(missingResource.results[0]); + + const unsupportedResource = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: 'e2e-no-commands', + }, + { expectedConfirmations: 1 }); + assert.deepStrictEqual(unsupportedResource.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'unsupportedResource', + appHost: relativeAppHostPath, + resourceName: 'e2e-no-commands', + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + }]); + assertSafeResourceDebugResult(unsupportedResource.results[0]); + }); + + test('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + const relativeAppHostPath = toWorkspaceRelativePath(appHostPath); + + await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); + await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + const running = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(running.state, 'e2e-worker'); + assert.ok(worker); + + const cancelled = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }, + { cancelAfterMs: 0, expectedConfirmations: 0 }); + assert.strictEqual(cancelled.cancelled, true); + assert.deepStrictEqual(cancelled.results, []); + + await executeE2eControlCommand({ name: 'stopResource', appHostPath, resourceName: worker.name }); + await waitForResourceState(worker.name, ['Exited', 'Finished', 'Stopped'], 90000); + + const stopped = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + strategy: 'attach', + }, + { expectedConfirmations: 1 }); + assert.strictEqual(stopped.results.length, 1); + assert.strictEqual(stopped.results[0].outcome, 'resourceNotRunning'); + assertSafeResourceDebugResult(stopped.results[0]); + }); +}); + +function toWorkspaceRelativePath(filePath: string): string { + const relativePath = path.relative(getWorkspaceRoot(), filePath); + assert.ok(relativePath.length > 0 && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + return relativePath.split(path.sep).join('/'); +} + +function assertSafeResourceDebugResult(result: ResourceDebugToolResult): void { + const serialized = JSON.stringify(result); + assert.deepStrictEqual(JSON.parse(serialized), result); + assert.ok(!path.isAbsolute(result.appHost)); + assert.doesNotMatch(serialized, /\b(?:pid|process|configuration|args|env|token)\b|https?:\/\/|\/(?:Users|private|var|tmp)\b/i); +} diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 615882de1ee..565dd9421e9 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -16,7 +16,7 @@ import { AspireTerminalCommandEvent, AspireTerminalProvider } from '../utils/Asp import { delay } from '../utils/async'; import { dashboardDefaultChangedNotificationKey } from '../utils/dashboardNotificationState'; import { extensionLogOutputChannel } from '../utils/logging'; -import { onDidInvokeCommand } from '../utils/telemetry'; +import { isCommandCancellation, onDidInvokeCommand } from '../utils/telemetry'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository } from '../data/AppHostDataRepository'; @@ -349,7 +349,11 @@ async function processE2eControlFile( } function getE2eErrorMessage(error: unknown): string { - return error instanceof Error ? (error.stack ?? error.message) : String(error); + // State files are copied to E2E diagnostics. Preserve only whether the bridge command was + // cancelled or failed; error messages can include paths, process data, and command arguments. + return isCommandCancellation(error) + ? 'E2E control command cancelled.' + : 'E2E control command failed.'; } async function executeE2eControlCommand( @@ -603,17 +607,39 @@ async function executeE2eControlCommand( case 'invokeLanguageModelTool': { markStarted(); const invocationCount = Math.max(1, command.times ?? 1); - const invocationResults = await Promise.all(Array.from({ length: invocationCount }, () => vscode.lm.invokeTool(command.toolName, { - input: command.input, - toolInvocationToken: undefined, - }))); + const cancellationDelayMs = getE2eCancellationDelay(command.cancelAfterMs); + const cancellationSource = cancellationDelayMs === undefined + ? undefined + : new vscode.CancellationTokenSource(); + const cancellationTimer = cancellationSource + ? setTimeout(() => cancellationSource.cancel(), cancellationDelayMs) + : undefined; + try { + const invocationResults = await Promise.all(Array.from({ length: invocationCount }, () => vscode.lm.invokeTool(command.toolName, { + input: command.input, + toolInvocationToken: undefined, + }, cancellationSource?.token))); + + return { + results: invocationResults.map(invocationResult => invocationResult.content + .filter((part): part is vscode.LanguageModelTextPart => part instanceof vscode.LanguageModelTextPart) + .map(part => part.value) + .join('')), + }; + } + catch (error) { + if (isCommandCancellation(error)) { + return { results: [], cancelled: true }; + } - return { - results: invocationResults.map(invocationResult => invocationResult.content - .filter((part): part is vscode.LanguageModelTextPart => part instanceof vscode.LanguageModelTextPart) - .map(part => part.value) - .join('')), - }; + throw error; + } + finally { + if (cancellationTimer !== undefined) { + clearTimeout(cancellationTimer); + } + cancellationSource?.dispose(); + } } case 'getDebugSessionProcessInfo': { markStarted(); @@ -1469,6 +1495,18 @@ function getE2ePositiveInteger(value: unknown, defaultValue: number, propertyNam return value; } +function getE2eCancellationDelay(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 10000) { + throw new Error('Aspire extension E2E language-model cancellation delay must be an integer between 0 and 10000.'); + } + + return value; +} + function getE2eAspireCommandId(commandId: unknown): string { if (typeof commandId !== 'string' || !commandId.startsWith('aspire-vscode.')) { throw new Error('Aspire extension E2E executeAspireCommand requires an aspire-vscode command id.'); diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index 5b59bff816e..3575da302e0 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -212,7 +212,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'getRegisteredAspireCommands' } | { name: 'getRegisteredLanguageModelTools' } | { name: 'prepareLanguageModelToolInvocation'; toolName: string; input: Record } - | { name: 'invokeLanguageModelTool'; toolName: string; input: Record; times?: number } + | { name: 'invokeLanguageModelTool'; toolName: string; input: Record; times?: number; cancelAfterMs?: number } | { name: 'getDebugSessionProcessInfo'; appHostPath?: string } | { name: 'getExtensionPackageJson' } | { name: 'getExtensionFileStatus'; relativePaths: readonly string[] } From 7ea01d0de51138c650447ff555a723a976f97043 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 08:23:36 -0400 Subject: [PATCH 64/90] test(extension): accept resource debug cancellation confirmation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/test-e2e/resourceDebugTools.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index b9a6d2a7338..6efae2fda98 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -200,7 +200,7 @@ suite('Aspire resource debug language model tool E2E', function () { appHostPath: relativeAppHostPath, resourceName: worker.name, }, - { cancelAfterMs: 0, expectedConfirmations: 0 }); + { cancelAfterMs: 0, expectedConfirmations: 1 }); assert.strictEqual(cancelled.cancelled, true); assert.deepStrictEqual(cancelled.results, []); From 2285e3e9f95eed2b0aa5fd5aa35215e608c1a7fd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 09:55:17 -0400 Subject: [PATCH 65/90] fix(extension): harden resource debug attachment Preserve AppHost process identity, detect Aspire-owned debuggee processes, and publish non-sensitive .NET launch metadata for exact attach target resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- docs/specs/cli-output-formats.md | 2 +- extension/src/debugger/AspireDebugSession.ts | 7 ++ extension/src/debugger/languages/dotnet.ts | 21 ++-- .../src/debugger/resourceDebugContracts.ts | 4 +- .../src/debugger/resourceDebugService.ts | 25 ++++- .../debugger/resourceDebugSessionRegistry.ts | 11 +- extension/src/extension.ts | 2 + .../test-e2e/resourceDebugTools.e2e.test.ts | 2 +- extension/src/test/appHostTreeView.test.ts | 1 + extension/src/test/aspireDebugSession.test.ts | 37 +++++++ extension/src/test/dotnetDebugger.test.ts | 44 ++++++++ .../src/test/resourceDebugService.test.ts | 99 ++++++++++++++++- .../src/views/AspireAppHostTreeProvider.ts | 1 + .../Dcp/ResourceSnapshotBuilder.cs | 103 ++++++++++++++++++ src/Shared/Model/KnownProperties.cs | 2 + .../ResourceSnapshotMapperTests.cs | 4 + .../Commands/DescribeCommandTests.cs | 4 + .../Dcp/ResourceSnapshotBuilderTests.cs | 49 +++++++++ 18 files changed, 398 insertions(+), 20 deletions(-) diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index fc59eec5035..83f711983e9 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -169,7 +169,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream | `relationships` | Related resources as `{ "type": "...", "resourceName": "..." }`. | | `urls` | Endpoint objects with `name`, `displayName`, `url`, and `isInternal`. | | `volumes` | Volume objects with `source`, `target`, `mountType`, and `isReadOnly`. | -| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, and `resource.launchConfigurationType`. | +| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. | | `environment` | Environment variables keyed by variable name. | | `healthReports` | Health report objects keyed by report name. | | `commands` | Resource command metadata keyed by command name. | diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 7b68658a14b..591d1e1d542 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -229,6 +229,13 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche return this._cliProcess?.pid; } + // Already-started debugger integrations report the actual debuggee PID back to DCP. + // Resource attach must recognize that PID as editor-owned rather than treating it as a launcher. + hasResourceDebugSessionProcess(processId: number): boolean { + return this._resourceDebugSessions.some( + session => (session as Partial).processId === processId); + } + constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void, trackAppHostDebugSession: AppHostDebugSessionTracker = () => { }, debugSessionId: string = generateDcpIdPrefix(), operationKind?: AspireOperationKind) { this._session = session; this._rpcServer = rpcServer; diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index f96bab80547..f12c54c09b1 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -72,6 +72,8 @@ const executableArgsPropertyName = 'executable.args'; const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; +const projectConfigurationPropertyName = 'project.configuration'; +const projectTargetFrameworkPropertyName = 'project.targetFramework'; const resourceParentNamePropertyName = 'resource.parentName'; const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; const dotNetProjectFileExtensions = new Set(['.csproj', '.fsproj', '.vbproj']); @@ -626,17 +628,17 @@ function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourc } function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): Pick { + let configuration = getNonEmptyStringProperty(resource, projectConfigurationPropertyName); + let framework = getNonEmptyStringProperty(resource, projectTargetFrameworkPropertyName); const executableArgs: unknown = resource.properties?.[executableArgsPropertyName]; if (!Array.isArray(executableArgs)) { - return {}; + return { configuration, framework }; } // Project launcher arguments have the shape: // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--no-launch-profile", "--", ...appArgs] // Stop at the application-argument separator so an app's own --configuration value is not mistaken // for the MSBuild configuration DCP used to launch the project. - let configuration: string | undefined; - let framework: string | undefined; for (let index = 0; index < executableArgs.length; index++) { const argument = executableArgs[index]; if (typeof argument !== 'string') { @@ -649,24 +651,24 @@ function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): if (argument === '--configuration' || argument === '-c') { const nextConfiguration = executableArgs[index + 1]; - if (typeof nextConfiguration === 'string' && nextConfiguration.trim().length > 0) { + if (configuration === undefined && typeof nextConfiguration === 'string' && nextConfiguration.trim().length > 0) { configuration = nextConfiguration.trim(); } } if (argument === '--framework' || argument === '-f') { const nextFramework = executableArgs[index + 1]; - if (typeof nextFramework === 'string' && nextFramework.trim().length > 0) { + if (framework === undefined && typeof nextFramework === 'string' && nextFramework.trim().length > 0) { framework = nextFramework.trim(); } } const [option, value] = argument.split('=', 2); - if ((option === '--configuration' || option === '-c') && value?.trim()) { + if (configuration === undefined && (option === '--configuration' || option === '-c') && value?.trim()) { configuration = value.trim(); } - if ((option === '--framework' || option === '-f') && value?.trim()) { + if (framework === undefined && (option === '--framework' || option === '-f') && value?.trim()) { framework = value.trim(); } } @@ -674,6 +676,11 @@ function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): return { configuration, framework }; } +function getNonEmptyStringProperty(resource: ResourceDebugResourceSnapshot, propertyName: string): string | undefined { + const value: unknown = resource.properties?.[propertyName]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + function getResourceParentName(resource: ResourceDebugResourceSnapshot): string | null { const value: unknown = resource.properties?.[resourceParentNamePropertyName]; return typeof value === 'string' ? value : null; diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index bd443e294b7..fcbbc9d0a24 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -13,11 +13,13 @@ export type ResourceDebugStrategy = 'auto' | 'attach'; /** * An AppHost selected by a caller. The absolute path remains internal to the editor - * control plane; only the safe display path may be used by presentation layers. + * control plane; only the safe display path may be used by presentation layers. The + * optional process ID preserves exact tree-item identity when one path has overlapping runs. */ export interface ResourceDebugAppHostTarget { readonly absolutePath: string; readonly displayPath: string; + readonly appHostPid?: number; } export interface ResourceDebugRequest { diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 365211488e9..9dd2d1929dd 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -45,6 +45,7 @@ export interface ResourceDebugServiceDependencies { readonly sessionRegistry: ResourceDebugSessionRegistry; readonly startDebugging: ResourceDebugStartDebugging; readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; + readonly isProcessAlreadyDebugged?: (processId: number) => boolean; readonly telemetry?: ResourceDebugTelemetry; readonly clock?: ResourceDebugClock; } @@ -113,6 +114,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger const resolvedTarget: ResourceDebugAppHostTarget = { absolutePath: resolvedAppHost.appHostPath, displayPath: request.appHost.displayPath, + appHostPid: resolvedAppHost.appHostPid, }; result = await this._dependencies.sessionRegistry.runSerialized( resolvedTarget, @@ -165,7 +167,9 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger const matchingAppHosts = appHostMatches .filter(match => match.relation === 'same') - .map(match => match.appHost); + .map(match => match.appHost) + .filter(appHost => request.appHost.appHostPid === undefined + || appHost.appHostPid === request.appHost.appHostPid); if (matchingAppHosts.length !== 1) { return { outcome: 'appHostNotFound' }; } @@ -252,6 +256,11 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'alreadyDebugging' }; } + const processId = getResourceProcessId(resource); + if (processId !== undefined && this._dependencies.isProcessAlreadyDebugged?.(processId)) { + return { outcome: 'alreadyDebugging' }; + } + let missingDebuggerExtensions: readonly ResourceDebugExtensionRequirement[]; try { if (!provider.canAttachToResource(resource)) { @@ -333,6 +342,20 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } } +function getResourceProcessId(resource: ResourceJson): number | undefined { + const value: unknown = resource.properties?.['executable.pid']; + if (typeof value === 'number') { + return Number.isInteger(value) && value > 0 ? value : undefined; + } + + if (typeof value === 'string') { + const processId = Number(value); + return Number.isInteger(processId) && processId > 0 ? processId : undefined; + } + + return undefined; +} + class ResourceDebugOperationTelemetry { private readonly _startedAt: number | undefined; private _resourceType: ResourceDebugResourceType | undefined; diff --git a/extension/src/debugger/resourceDebugSessionRegistry.ts b/extension/src/debugger/resourceDebugSessionRegistry.ts index 7c32441a231..6ffa5e982ec 100644 --- a/extension/src/debugger/resourceDebugSessionRegistry.ts +++ b/extension/src/debugger/resourceDebugSessionRegistry.ts @@ -81,7 +81,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { } hasActiveSession(appHost: ResourceDebugAppHostTarget, resourceName: string): boolean { - const attemptMarkers = this._attemptsByResource.get(this._getResourceKey(appHost.absolutePath, resourceName)); + const attemptMarkers = this._attemptsByResource.get(this._getResourceKey(appHost, resourceName)); if (!attemptMarkers) { return false; } @@ -99,7 +99,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { operation: () => Promise, getCancelledResult: () => T, ): Promise { - const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); + const resourceKey = this._getResourceKey(appHost, resourceName); const precedingOperation = this._resourceLocks.get(resourceKey); let releaseCurrentOperation: (() => void) | undefined; const currentOperationGate = new Promise(resolve => { @@ -137,7 +137,7 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { configuration: vscode.DebugConfiguration, telemetry: ResourceDebugAttachSessionMetadata, ): ResourceDebugSessionAttempt { - const resourceKey = this._getResourceKey(appHost.absolutePath, resourceName); + const resourceKey = this._getResourceKey(appHost, resourceName); const marker = ++this._nextMarker; const attempt: TrackedAttachAttempt = { marker, @@ -311,7 +311,8 @@ export class ResourceDebugSessionRegistry implements vscode.Disposable { }); } - private _getResourceKey(appHostPath: string, resourceName: string): string { - return `${getAppHostIdentityKey(appHostPath)}\u0000${resourceName}`; + private _getResourceKey(appHost: ResourceDebugAppHostTarget, resourceName: string): string { + const appHostProcessIdentity = appHost.appHostPid?.toString() ?? ''; + return `${getAppHostIdentityKey(appHost.absolutePath)}\u0000${appHostProcessIdentity}\u0000${resourceName}`; } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index c2ac15522b3..9aca4e6da65 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -126,6 +126,8 @@ export async function activate(context: vscode.ExtensionContext) { sessionRegistry: resourceDebugSessionRegistry, startDebugging: (workspaceFolder, configuration) => vscode.debug.startDebugging(workspaceFolder, configuration), + isProcessAlreadyDebugged: processId => + aspireExtensionContext.aspireDebugSessions.some(session => session.hasResourceDebugSessionProcess(processId)), telemetry: resourceDebugTelemetry, clock: resourceDebugClock, }); diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index 6efae2fda98..5c9e7838be3 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -231,5 +231,5 @@ function assertSafeResourceDebugResult(result: ResourceDebugToolResult): void { const serialized = JSON.stringify(result); assert.deepStrictEqual(JSON.parse(serialized), result); assert.ok(!path.isAbsolute(result.appHost)); - assert.doesNotMatch(serialized, /\b(?:pid|process|configuration|args|env|token)\b|https?:\/\/|\/(?:Users|private|var|tmp)\b/i); + assert.doesNotMatch(serialized, /(?:pid|process|configuration|arguments?|args|environment|env|secret|token|executable)|https?:\/\/|\/(?:Users|private|var|tmp)\b/i); } diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 6848f7976a8..3a79c5c79d6 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -2916,6 +2916,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const debugRequest = request as ResourceDebugRequest; assert.strictEqual(debugRequest.appHost.absolutePath, appHostPath); + assert.strictEqual(debugRequest.appHost.appHostPid, 2222); assert.strictEqual(debugRequest.resourceName, 'api'); const workspaceResourceItem = new ResourceItem(makeResource({ name: 'workspace-api' }), null, false, undefined, appHostPath); diff --git a/extension/src/test/aspireDebugSession.test.ts b/extension/src/test/aspireDebugSession.test.ts index 6b5c2e79f44..b501e7d0313 100644 --- a/extension/src/test/aspireDebugSession.test.ts +++ b/extension/src/test/aspireDebugSession.test.ts @@ -4017,6 +4017,43 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(stopSession.calledOnce, true); }); + test('reports whether an Aspire-owned resource debug session has a process ID', () => { + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/AppHost/AppHost.csproj', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const terminalProvider = { + isDebugConfigEnvironmentLoggingEnabled: () => false, + }; + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + (aspireDebugSession as any)._resourceDebugSessions = [{ + id: 'run-1', + processId: 4242, + session: { id: 'run-1' } as vscode.DebugSession, + stopSession: sinon.stub(), + }]; + + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(4242), + true); + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(5252), + false); + aspireDebugSession.dispose(); + }); + test('retries MAUI resource debug sessions when the first start attempt is canceled', async () => { let startSessionCallback: ((session: vscode.DebugSession) => void) | undefined; const parentDebugSession = { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 5cc1dbe476f..76f66490dce 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -883,6 +883,50 @@ suite('Dotnet Debugger Extension Tests', () => { '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); }); + test('attach configuration uses safe snapshot properties when executable arguments are redacted', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + }, + }); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); + }); + + test('attach configuration prefers safe snapshot properties over legacy executable arguments', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + }, + }); + + assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); + }); + test('attach configuration passes cancellation to target discovery', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); const cancellation = new vscode.CancellationTokenSource(); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 69fae838d8b..758b4dd84b5 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -14,6 +14,10 @@ const target: ResourceDebugAppHostTarget = { absolutePath: '/repo/AppHost.csproj', displayPath: 'AppHost.csproj', }; +const resolvedTarget: ResourceDebugAppHostTarget = { + ...target, + appHostPid: 42, +}; function createResource(overrides: Partial = {}): ResourceJson { return { @@ -180,6 +184,7 @@ function createService(options: { telemetry?: TestResourceDebugTelemetry; clock?: { now(): number }; pendingStartTimeoutMs?: number; + isProcessAlreadyDebugged?: (processId: number) => boolean; } = {}): { service: ResourceDebugService; repository: ResourceDebugAppHostRepository; @@ -211,6 +216,7 @@ function createService(options: { compareAppHostIdentity: options.compareAppHostIdentity, telemetry, clock, + isProcessAlreadyDebugged: options.isProcessAlreadyDebugged, } as unknown as ResourceDebugServiceDependencies); return { service, repository, sessions, events, telemetry }; @@ -457,6 +463,36 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('resolves the selected AppHost process when one path has overlapping snapshots', async () => { + const appHosts = [ + createAppHost({ appHostPid: 1111 }), + createAppHost({ appHostPid: 2222 }), + ]; + const { service, sessions } = createService({ appHosts }); + + assert.deepStrictEqual(await service.debug(createRequest({ + appHost: { + ...target, + appHostPid: 2222, + }, + })), { outcome: 'started', providerId: 'dotnet' }); + sessions.dispose(); + }); + + test('rejects an AppHost process that no longer matches the selected tree item', async () => { + const { service, sessions } = createService({ + appHosts: [createAppHost({ appHostPid: 2222 })], + }); + + assert.deepStrictEqual(await service.debug(createRequest({ + appHost: { + ...target, + appHostPid: 1111, + }, + })), { outcome: 'appHostNotFound' }); + sessions.dispose(); + }); + test('fails closed when a resource is stale or duplicated', async () => { const missing = createService({ appHosts: [createAppHost({ resources: [] })], @@ -483,6 +519,27 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('returns alreadyDebugging when Aspire already owns the reported resource process', async () => { + const startDebugging = sinon.stub().resolves(true); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + })], + })], + isProcessAlreadyDebugged: processId => processId === 4242, + startDebugging, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + assert.strictEqual(startDebugging.called, false); + sessions.dispose(); + }); + test('reports the missing Go debugger extension using only its requirement metadata', async () => { const resolver = { resolveApplicationPid: sinon.stub().rejects(new Error('/private/go-build123/b001/exe/api 4567')), @@ -911,6 +968,40 @@ suite('Resource debug service', () => { } }); + test('tracks attach sessions separately for overlapping AppHost processes', () => { + const events = new TestDebugSessionEvents(); + const sessions = new ResourceDebugSessionRegistry(events); + const firstTarget = { + ...target, + appHostPid: 1111, + }; + const secondTarget = { + ...target, + appHostPid: 2222, + }; + const attempt = sessions.createAttempt(firstTarget, 'api', { + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + }, { + source: 'tree', + provider: 'dotnet', + resource_type: 'project', + requested_strategy: 'attach', + effective_strategy: 'attach', + }); + + try { + attempt.markStarted(); + + assert.strictEqual(sessions.hasActiveSession(firstTarget, 'api'), true); + assert.strictEqual(sessions.hasActiveSession(secondTarget, 'api'), false); + } + finally { + sessions.dispose(); + } + }); + test('serializes aliases that resolve to the same running AppHost', async () => { let completeStart: ((value: boolean) => void) | undefined; let signalStart: (() => void) | undefined; @@ -1092,7 +1183,7 @@ suite('Resource debug service', () => { await service.debug(createRequest({ cancellationToken: cancellation.token })), { outcome: 'started', providerId: 'dotnet' }); assert.ok(startedConfiguration); - assert.strictEqual(sessions.hasActiveSession(target, 'api'), true); + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), true); } finally { cancellation.dispose(); @@ -1112,11 +1203,11 @@ suite('Resource debug service', () => { assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); assert.ok(startedConfiguration); - assert.strictEqual(sessions.hasActiveSession(target, 'api'), true); + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), true); events.terminate(startedConfiguration!); - assert.strictEqual(sessions.hasActiveSession(target, 'api'), false); + assert.strictEqual(sessions.hasActiveSession(resolvedTarget, 'api'), false); sessions.dispose(); }); @@ -1700,7 +1791,7 @@ suite('Resource debug service', () => { events.terminate(events.startedConfiguration); assert.strictEqual(recordSessionEnd.callCount, 1); - assert.strictEqual(fixture.sessions.hasActiveSession(target, 'api'), false); + assert.strictEqual(fixture.sessions.hasActiveSession(resolvedTarget, 'api'), false); } finally { fixture.sessions.dispose(); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index c593256ae7d..c14df63b96a 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1028,6 +1028,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider launchConfigurationProperties = launchConfigurationType is null ? [] : [new(KnownProperties.Resource.LaunchConfigurationType, launchConfigurationType)]; + var dotNetRunProperties = GetDotNetRunProperties(executable.Spec.ExecutablePath, effectiveArgs); if (projectPath is not null) { @@ -196,6 +197,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, + .. dotNetRunProperties, .. launchConfigurationProperties, ]), EnvironmentVariables = environment, @@ -235,6 +237,107 @@ private static bool IsNotStartedExecutableState(string? state) return string.IsNullOrEmpty(state) || state == ExecutableState.Unknown; } + private static ImmutableArray GetDotNetRunProperties(string? executablePath, IReadOnlyList? effectiveArgs) + { + var executableName = Path.GetFileName(executablePath); + if (!string.Equals(executableName, "dotnet", StringComparison.OrdinalIgnoreCase) && + !string.Equals(executableName, "dotnet.exe", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + if (effectiveArgs is not [var command, ..] || + !string.Equals(command, "run", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + string? configuration = null; + string? targetFramework = null; + + // DCP reports dotnet-run arguments as: + // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--framework=net10.0", "--", ...appArgs] + // Only launcher arguments before "--" are safe launch metadata; application arguments can + // contain unrelated values and remain sensitive in executable.args. + for (var index = 1; index < effectiveArgs.Count; index++) + { + var argument = effectiveArgs[index]; + if (argument == "--") + { + break; + } + + if (TryReadOptionValue(argument, "--configuration", "-c", out var inlineConfiguration)) + { + configuration = inlineConfiguration; + continue; + } + + if (TryReadOptionValue(argument, "--framework", "-f", out var inlineTargetFramework)) + { + targetFramework = inlineTargetFramework; + continue; + } + + if (argument is "--configuration" or "-c") + { + configuration = ReadNextValue(effectiveArgs, ref index); + continue; + } + + if (argument is "--framework" or "-f") + { + targetFramework = ReadNextValue(effectiveArgs, ref index); + } + } + + var properties = ImmutableArray.CreateBuilder(2); + if (configuration is not null) + { + properties.Add(new(KnownProperties.Project.Configuration, configuration)); + } + + if (targetFramework is not null) + { + properties.Add(new(KnownProperties.Project.TargetFramework, targetFramework)); + } + + return properties.ToImmutable(); + + static bool TryReadOptionValue(string argument, string longOption, string shortOption, out string? value) + { + foreach (var option in new[] { longOption, shortOption }) + { + var prefix = option + "="; + if (argument.StartsWith(prefix, StringComparison.Ordinal)) + { + value = NormalizeValue(argument[prefix.Length..]); + return true; + } + } + + value = null; + return false; + } + + static string? ReadNextValue(IReadOnlyList arguments, ref int index) + { + if (index + 1 >= arguments.Count || arguments[index + 1] == "--") + { + return null; + } + + index++; + return NormalizeValue(arguments[index]); + } + + static string? NormalizeValue(string value) + { + var normalized = value.Trim(); + return normalized.Length > 0 ? normalized : null; + } + } + private static (ImmutableArray Args, ImmutableArray? ArgsAreSensitive, bool IsSensitive)? GetLaunchArgs(CustomResource resource, IReadOnlyList? effectiveArgs) { if (!resource.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out List? launchArgumentAnnotations)) diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 86f6d0e7cd9..1eb2ea60557 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -57,6 +57,8 @@ public static class Project { public const string Path = "project.path"; public const string LaunchProfile = "project.launchProfile"; + public const string Configuration = "project.configuration"; + public const string TargetFramework = "project.targetFramework"; } public static class Terminal diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 5b208f7be35..2d29c4cbbb1 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -45,6 +45,8 @@ public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() { [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), + [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), [KnownProperties.Resource.ParentName] = JsonValue.Create("mauiapp"), } @@ -53,6 +55,8 @@ public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); Assert.Equal("maui", result.Properties![KnownProperties.Resource.LaunchConfigurationType]!.GetValue()); + Assert.Equal("Release", result.Properties[KnownProperties.Project.Configuration]!.GetValue()); + Assert.Equal("net10.0", result.Properties[KnownProperties.Project.TargetFramework]!.GetValue()); Assert.Equal("MauiApp.csproj", result.Source); } diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index f5bfee3eeed..9a71cd043be 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -275,8 +275,10 @@ public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForPar { [KnownProperties.Executable.Args] = null, [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), + [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), [KnownProperties.Project.LaunchProfile] = JsonValue.Create("https"), [KnownProperties.Project.Path] = JsonValue.Create("/repo/api/Api.csproj"), + [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("project"), [KnownProperties.Resource.ParentName] = JsonValue.Create("group"), } @@ -316,8 +318,10 @@ public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForPar "properties": { "executable.args": null, "executable.path": "dotnet", + "project.configuration": "Release", "project.launchProfile": "https", "project.path": "/repo/api/Api.csproj", + "project.targetFramework": "net10.0", "resource.launchConfigurationType": "project", "resource.parentName": "group" } diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 8a831425134..2983573a372 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -111,6 +111,55 @@ public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject() Assert.Equal("project", Assert.IsType(launchConfigurationType.Value)); } + [Theory] + [InlineData("--configuration", "--framework=net10.0")] + [InlineData("-c", "-f")] + [InlineData("--configuration=Release", "--framework")] + [InlineData("-c=Release", "-f=net10.0")] + public void ProjectSnapshotIncludesSafeDotNetRunConfigurationAndTargetFramework( + string configurationArgument, + string targetFrameworkArgument) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + "run", + "--project", + "/app/project.csproj", + configurationArgument + }; + if (!configurationArgument.Contains('=')) + { + effectiveArgs.Add("Release"); + } + effectiveArgs.Add(targetFrameworkArgument); + if (!targetFrameworkArgument.Contains('=')) + { + effectiveArgs.Add("net10.0"); + } + effectiveArgs.AddRange(["--", "--configuration", "Private", "--framework", "private"]); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Equal("Release", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.Configuration).Value)); + Assert.Equal("net10.0", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value)); + Assert.False(GetProperty(snapshot, KnownProperties.Project.Configuration).IsSensitive); + Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); + } + [Fact] public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations() { From 03c927b02a2d1a75b539df1db3a7f8fb30e20422 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 12:33:50 -0400 Subject: [PATCH 66/90] Fix extension E2E error reporting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/testing/e2eStateFileBridge.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 565dd9421e9..9aa6e60048b 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -349,10 +349,12 @@ async function processE2eControlFile( } function getE2eErrorMessage(error: unknown): string { - // State files are copied to E2E diagnostics. Preserve only whether the bridge command was - // cancelled or failed; error messages can include paths, process data, and command arguments. - return isCommandCancellation(error) - ? 'E2E control command cancelled.' + if (isCommandCancellation(error)) { + return 'E2E control command cancelled.'; + } + + return error instanceof Error + ? error.message : 'E2E control command failed.'; } From ce3cc0b39be14157ed6f674b38a66171349e4fe2 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 12:47:49 -0400 Subject: [PATCH 67/90] Add safe dotnet launch metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- docs/specs/cli-output-formats.md | 2 +- .../Dcp/ResourceSnapshotBuilder.cs | 21 +++++++++++-------- src/Shared/Model/KnownProperties.cs | 1 + .../ResourceSnapshotMapperTests.cs | 4 ++++ .../Commands/DescribeCommandTests.cs | 2 ++ .../Dcp/ResourceSnapshotBuilderTests.cs | 19 +++++++++++------ 6 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index 83f711983e9..c47459521aa 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -169,7 +169,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream | `relationships` | Related resources as `{ "type": "...", "resourceName": "..." }`. | | `urls` | Endpoint objects with `name`, `displayName`, `url`, and `isInternal`. | | `volumes` | Volume objects with `source`, `target`, `mountType`, and `isReadOnly`. | -| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. | +| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.launchCommand`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. | | `environment` | Environment variables keyed by variable name. | | `healthReports` | Health report objects keyed by report name. | | `commands` | Resource command metadata keyed by command name. | diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index 897f257d043..c6fd14fba3b 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -179,7 +179,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ImmutableArray launchConfigurationProperties = launchConfigurationType is null ? [] : [new(KnownProperties.Resource.LaunchConfigurationType, launchConfigurationType)]; - var dotNetRunProperties = GetDotNetRunProperties(executable.Spec.ExecutablePath, effectiveArgs); + var dotNetLaunchProperties = GetDotNetLaunchProperties(executable.Spec.ExecutablePath, effectiveArgs); if (projectPath is not null) { @@ -197,7 +197,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn ResourcePropertySnapshotMetadata.Create(KnownResourceTypes.Project, KnownProperties.Project.LaunchProfile, launchProfileName), new(KnownProperties.Resource.AppArgs, launchArguments?.Args) { IsSensitive = launchArguments?.IsSensitive ?? false }, new(KnownProperties.Resource.AppArgsSensitivity, launchArguments?.ArgsAreSensitive) { IsSensitive = launchArguments?.IsSensitive ?? false }, - .. dotNetRunProperties, + .. dotNetLaunchProperties, .. launchConfigurationProperties, ]), EnvironmentVariables = environment, @@ -237,7 +237,7 @@ private static bool IsNotStartedExecutableState(string? state) return string.IsNullOrEmpty(state) || state == ExecutableState.Unknown; } - private static ImmutableArray GetDotNetRunProperties(string? executablePath, IReadOnlyList? effectiveArgs) + private static ImmutableArray GetDotNetLaunchProperties(string? executablePath, IReadOnlyList? effectiveArgs) { var executableName = Path.GetFileName(executablePath); if (!string.Equals(executableName, "dotnet", StringComparison.OrdinalIgnoreCase) && @@ -247,7 +247,8 @@ private static ImmutableArray GetDotNetRunProperties(s } if (effectiveArgs is not [var command, ..] || - !string.Equals(command, "run", StringComparison.OrdinalIgnoreCase)) + (!string.Equals(command, "run", StringComparison.OrdinalIgnoreCase) && + !string.Equals(command, "watch", StringComparison.OrdinalIgnoreCase))) { return []; } @@ -255,10 +256,10 @@ private static ImmutableArray GetDotNetRunProperties(s string? configuration = null; string? targetFramework = null; - // DCP reports dotnet-run arguments as: - // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--framework=net10.0", "--", ...appArgs] - // Only launcher arguments before "--" are safe launch metadata; application arguments can - // contain unrelated values and remain sensitive in executable.args. + // DCP reports dotnet launch arguments as: + // ["watch", "--project", "/repo/api.csproj", "--configuration", "Release", "--framework=net10.0", "--", ...appArgs] + // Only launcher arguments before "--" are safe to publish as launch metadata. Application + // arguments after the separator can contain unrelated values and remain sensitive in executable.args. for (var index = 1; index < effectiveArgs.Count; index++) { var argument = effectiveArgs[index]; @@ -291,7 +292,9 @@ private static ImmutableArray GetDotNetRunProperties(s } } - var properties = ImmutableArray.CreateBuilder(2); + var launchCommand = command.ToLowerInvariant(); + var properties = ImmutableArray.CreateBuilder(3); + properties.Add(new(KnownProperties.Project.LaunchCommand, launchCommand)); if (configuration is not null) { properties.Add(new(KnownProperties.Project.Configuration, configuration)); diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 1eb2ea60557..5623554fc43 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -57,6 +57,7 @@ public static class Project { public const string Path = "project.path"; public const string LaunchProfile = "project.launchProfile"; + public const string LaunchCommand = "project.launchCommand"; public const string Configuration = "project.configuration"; public const string TargetFramework = "project.targetFramework"; } diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 2d29c4cbbb1..b8f93b44c92 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -43,8 +43,10 @@ public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() State = "Running", Properties = { + [KnownProperties.Executable.Args] = null, [KnownProperties.Project.Path] = JsonValue.Create("/repo/maui/MauiApp.csproj"), [KnownProperties.Project.LaunchProfile] = JsonValue.Create("AndroidEmulator"), + [KnownProperties.Project.LaunchCommand] = JsonValue.Create("watch"), [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), [KnownProperties.Resource.LaunchConfigurationType] = JsonValue.Create("maui"), @@ -54,7 +56,9 @@ public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]); + Assert.Null(result.Properties![KnownProperties.Executable.Args]); Assert.Equal("maui", result.Properties![KnownProperties.Resource.LaunchConfigurationType]!.GetValue()); + Assert.Equal("watch", result.Properties[KnownProperties.Project.LaunchCommand]!.GetValue()); Assert.Equal("Release", result.Properties[KnownProperties.Project.Configuration]!.GetValue()); Assert.Equal("net10.0", result.Properties[KnownProperties.Project.TargetFramework]!.GetValue()); Assert.Equal("MauiApp.csproj", result.Source); diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index 9a71cd043be..3d7d4799dab 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -276,6 +276,7 @@ public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForPar [KnownProperties.Executable.Args] = null, [KnownProperties.Executable.Path] = JsonValue.Create("dotnet"), [KnownProperties.Project.Configuration] = JsonValue.Create("Release"), + [KnownProperties.Project.LaunchCommand] = JsonValue.Create("watch"), [KnownProperties.Project.LaunchProfile] = JsonValue.Create("https"), [KnownProperties.Project.Path] = JsonValue.Create("/repo/api/Api.csproj"), [KnownProperties.Project.TargetFramework] = JsonValue.Create("net10.0"), @@ -319,6 +320,7 @@ public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForPar "executable.args": null, "executable.path": "dotnet", "project.configuration": "Release", + "project.launchCommand": "watch", "project.launchProfile": "https", "project.path": "/repo/api/Api.csproj", "project.targetFramework": "net10.0", diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 2983573a372..d0d4897448e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -112,11 +112,16 @@ public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject() } [Theory] - [InlineData("--configuration", "--framework=net10.0")] - [InlineData("-c", "-f")] - [InlineData("--configuration=Release", "--framework")] - [InlineData("-c=Release", "-f=net10.0")] - public void ProjectSnapshotIncludesSafeDotNetRunConfigurationAndTargetFramework( + [InlineData("run", "--configuration", "--framework=net10.0")] + [InlineData("run", "-c", "-f")] + [InlineData("run", "--configuration=Release", "--framework")] + [InlineData("run", "-c=Release", "-f=net10.0")] + [InlineData("watch", "--configuration", "--framework=net10.0")] + [InlineData("watch", "-c", "-f")] + [InlineData("watch", "--configuration=Release", "--framework")] + [InlineData("watch", "-c=Release", "-f=net10.0")] + public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( + string command, string configurationArgument, string targetFrameworkArgument) { @@ -125,7 +130,7 @@ public void ProjectSnapshotIncludesSafeDotNetRunConfigurationAndTargetFramework( var effectiveArgs = new List { - "run", + command, "--project", "/app/project.csproj", configurationArgument @@ -154,8 +159,10 @@ public void ProjectSnapshotIncludesSafeDotNetRunConfigurationAndTargetFramework( [project.Name] = project }).ToSnapshot(executable, CreatePreviousSnapshot()); + Assert.Equal(command, Assert.IsType(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value)); Assert.Equal("Release", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.Configuration).Value)); Assert.Equal("net10.0", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value)); + Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive); Assert.False(GetProperty(snapshot, KnownProperties.Project.Configuration).IsSensitive); Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); } From d981a6fce63ff832f0321bdf741db769afff709b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 13:00:02 -0400 Subject: [PATCH 68/90] Strengthen launch metadata tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../ResourceSnapshotMapperTests.cs | 2 +- .../Commands/DescribeCommandTests.cs | 2 +- .../Dcp/ResourceSnapshotBuilderTests.cs | 24 ++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index b8f93b44c92..8e635926743 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -33,7 +33,7 @@ public void ResourceSnapshotDeserialization_WithNumericPropertyValue_PreservesJs } [Fact] - public void MapToResourceJson_WithLaunchConfigurationType_PreservesProperty() + public void MapToResourceJson_WithDebugProperties_PreservesProperties() { var snapshot = new ResourceSnapshot { diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index 3d7d4799dab..2a6eaf6e398 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -258,7 +258,7 @@ public void DescribeCommand_SnapshotFormat_OutputsWrappedJsonArray() } [Fact] - public void DescribeCommand_SnapshotFormat_IncludesLaunchConfigurationTypeForParentedProjectAndMauiResources() + public void DescribeCommand_SnapshotFormat_IncludesDebugPropertiesForParentedProjectAndMauiResources() { var resourcesOutput = new ResourcesOutput { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index d0d4897448e..d4d3e05a05a 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -112,16 +112,17 @@ public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject() } [Theory] - [InlineData("run", "--configuration", "--framework=net10.0")] - [InlineData("run", "-c", "-f")] - [InlineData("run", "--configuration=Release", "--framework")] - [InlineData("run", "-c=Release", "-f=net10.0")] - [InlineData("watch", "--configuration", "--framework=net10.0")] - [InlineData("watch", "-c", "-f")] - [InlineData("watch", "--configuration=Release", "--framework")] - [InlineData("watch", "-c=Release", "-f=net10.0")] + [InlineData("Run", "run", "--configuration", "--framework=net10.0")] + [InlineData("run", "run", "-c", "-f")] + [InlineData("run", "run", "--configuration=Release", "--framework")] + [InlineData("run", "run", "-c=Release", "-f=net10.0")] + [InlineData("WATCH", "watch", "--configuration", "--framework=net10.0")] + [InlineData("watch", "watch", "-c", "-f")] + [InlineData("watch", "watch", "--configuration=Release", "--framework")] + [InlineData("watch", "watch", "-c=Release", "-f=net10.0")] public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( - string command, + string launchCommand, + string expectedLaunchCommand, string configurationArgument, string targetFrameworkArgument) { @@ -130,7 +131,7 @@ public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( var effectiveArgs = new List { - command, + launchCommand, "--project", "/app/project.csproj", configurationArgument @@ -159,9 +160,10 @@ public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( [project.Name] = project }).ToSnapshot(executable, CreatePreviousSnapshot()); - Assert.Equal(command, Assert.IsType(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value)); + Assert.Equal(expectedLaunchCommand, Assert.IsType(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value)); Assert.Equal("Release", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.Configuration).Value)); Assert.Equal("net10.0", Assert.IsType(GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value)); + Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive); Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive); Assert.False(GetProperty(snapshot, KnownProperties.Project.Configuration).IsSensitive); Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); From deb220ca9f48375db2f65b6cd6889c135327675c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 13:18:26 -0400 Subject: [PATCH 69/90] Harden dotnet resource attach identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 168 ++++++--- extension/src/test/dotnetDebugger.test.ts | 390 +++++++++++++++++---- 2 files changed, 446 insertions(+), 112 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index f12c54c09b1..147ce12a3d5 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -43,17 +43,22 @@ interface IDotNetService { getDotNetRunApiOutput(projectFile: string, environment?: NodeJS.ProcessEnv): Promise; } +type DotNetLaunchCommand = 'run' | 'watch'; + interface DotNetAttachTargetInfo { targetPath: string; + targetName?: string; useAppHost: boolean; } interface DotNetAttachDebuggerResourceInfo { configuration?: string; framework?: string; + launchCommand?: DotNetLaunchCommand; launcherPid: number; projectPath: string; resourceLabel: string; + useTargetNameFallback: boolean; } interface LaunchedChildProcessResolver { @@ -73,6 +78,7 @@ const executablePidPropertyName = 'executable.pid'; const executablePathPropertyName = 'executable.path'; const projectPathPropertyName = 'project.path'; const projectConfigurationPropertyName = 'project.configuration'; +const projectLaunchCommandPropertyName = 'project.launchCommand'; const projectTargetFrameworkPropertyName = 'project.targetFramework'; const resourceParentNamePropertyName = 'resource.parentName'; const resourceLaunchConfigurationTypePropertyName = 'resource.launchConfigurationType'; @@ -165,6 +171,7 @@ export class DotNetService implements IDotNetService { projectFile, '-nologo', '-getProperty:TargetPath', + '-getProperty:TargetName', '-getProperty:UseAppHost', '-v:q', '-property:GenerateFullPaths=true' @@ -179,7 +186,7 @@ export class DotNetService implements IDotNetService { try { const stdout = await this._runDotNetMsbuild(args, path.dirname(projectFile), cancellationToken); // Multiple -getProperty switches return: - // { "Properties": { "TargetPath": "/repo/bin/Release/net10.0/Api.dll", "UseAppHost": "false" } } + // { "Properties": { "TargetPath": "/repo/bin/Release/net10.0/Api.dll", "TargetName": "Api", "UseAppHost": "false" } } const payload: unknown = JSON.parse(stdout); const properties = typeof payload === 'object' && payload !== null && 'Properties' in payload ? (payload as { Properties?: unknown }).Properties @@ -187,6 +194,9 @@ export class DotNetService implements IDotNetService { const targetPath = typeof properties === 'object' && properties !== null && 'TargetPath' in properties ? (properties as { TargetPath?: unknown }).TargetPath : undefined; + const targetName = typeof properties === 'object' && properties !== null && 'TargetName' in properties + ? (properties as { TargetName?: unknown }).TargetName + : undefined; const useAppHost = typeof properties === 'object' && properties !== null && 'UseAppHost' in properties ? (properties as { UseAppHost?: unknown }).UseAppHost : undefined; @@ -196,6 +206,9 @@ export class DotNetService implements IDotNetService { return { targetPath: targetPath.trim(), + targetName: typeof targetName === 'string' && targetName.trim().length > 0 + ? targetName.trim() + : undefined, useAppHost: typeof useAppHost === 'string' && useAppHost.trim().toLowerCase() === 'true', }; } catch (err) { @@ -588,9 +601,14 @@ function getDotNetAttachDebuggerResourceInfo(resource: ResourceDebugResourceSnap return undefined; } + const launchMetadata = getDotNetLaunchMetadata(resource); + if (launchMetadata === undefined) { + return undefined; + } + const projectPath = resource.properties?.[projectPathPropertyName] as string; return { - ...getDotNetLaunchConfiguration(resource), + ...launchMetadata, launcherPid, projectPath, resourceLabel: resource.displayName ?? resource.name, @@ -627,53 +645,28 @@ function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourc return true; } -function getDotNetLaunchConfiguration(resource: ResourceDebugResourceSnapshot): Pick { - let configuration = getNonEmptyStringProperty(resource, projectConfigurationPropertyName); - let framework = getNonEmptyStringProperty(resource, projectTargetFrameworkPropertyName); - const executableArgs: unknown = resource.properties?.[executableArgsPropertyName]; - if (!Array.isArray(executableArgs)) { - return { configuration, framework }; - } - - // Project launcher arguments have the shape: - // ["run", "--project", "/repo/api.csproj", "--configuration", "Release", "--no-launch-profile", "--", ...appArgs] - // Stop at the application-argument separator so an app's own --configuration value is not mistaken - // for the MSBuild configuration DCP used to launch the project. - for (let index = 0; index < executableArgs.length; index++) { - const argument = executableArgs[index]; - if (typeof argument !== 'string') { - continue; - } - - if (argument === '--') { - break; - } - - if (argument === '--configuration' || argument === '-c') { - const nextConfiguration = executableArgs[index + 1]; - if (configuration === undefined && typeof nextConfiguration === 'string' && nextConfiguration.trim().length > 0) { - configuration = nextConfiguration.trim(); - } - } - - if (argument === '--framework' || argument === '-f') { - const nextFramework = executableArgs[index + 1]; - if (framework === undefined && typeof nextFramework === 'string' && nextFramework.trim().length > 0) { - framework = nextFramework.trim(); - } - } - - const [option, value] = argument.split('=', 2); - if (configuration === undefined && (option === '--configuration' || option === '-c') && value?.trim()) { - configuration = value.trim(); - } - - if (framework === undefined && (option === '--framework' || option === '-f') && value?.trim()) { - framework = value.trim(); - } +function getDotNetLaunchMetadata( + resource: ResourceDebugResourceSnapshot, +): Pick | undefined { + const configuration = getNonEmptyStringProperty(resource, projectConfigurationPropertyName); + const framework = getNonEmptyStringProperty(resource, projectTargetFrameworkPropertyName); + const properties = resource.properties; + const hasLaunchCommand = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectLaunchCommandPropertyName); + const launchCommandValue = properties?.[projectLaunchCommandPropertyName]; + if (hasLaunchCommand && launchCommandValue !== 'run' && launchCommandValue !== 'watch') { + return undefined; } - return { configuration, framework }; + return { + configuration, + framework, + launchCommand: launchCommandValue as DotNetLaunchCommand | undefined, + useTargetNameFallback: !hasLaunchCommand && + properties?.[executableArgsPropertyName] === null && + configuration === undefined && + framework === undefined, + }; } function getNonEmptyStringProperty(resource: ResourceDebugResourceSnapshot, propertyName: string): string | undefined { @@ -721,13 +714,30 @@ function isDotNetExecutable(resource: ResourceDebugResourceSnapshot): boolean { async function createDotNetProcessIdentity( targetInfo: DotNetAttachTargetInfo, + attachInfo: DotNetAttachDebuggerResourceInfo, fileSystem: DotNetAttachFileSystem, ): Promise { + const requiresDirectChild = attachInfo.launchCommand !== 'watch'; + if (attachInfo.useTargetNameFallback) { + const targetName = targetInfo.targetName; + if (targetName === undefined) { + throw new Error(attachDebuggerUnavailable); + } + + return { + requiresDirectChild, + isLauncher: process => isDotNetProcess(process), + isCandidate: process => targetInfo.useAppHost + ? isAppHostProcessForTargetName(process, targetName) + : isFrameworkDependentProcessForTargetName(process, targetName), + }; + } + const appHostPaths = targetInfo.useAppHost ? await getCanonicalAppHostPaths(targetInfo.targetPath, fileSystem) : undefined; return { - requiresDirectChild: true, + requiresDirectChild, isLauncher: process => isDotNetProcess(process), isCandidate: process => targetInfo.useAppHost ? isAppHostProcessForTarget(process, appHostPaths!) @@ -744,6 +754,10 @@ function isAppHostProcessForTarget(process: LaunchedChildProcess, appHostPaths: return appHostPaths.some(appHostPath => areProcessPathsEqual(process.executable, appHostPath)); } +function isAppHostProcessForTargetName(process: LaunchedChildProcess, targetName: string): boolean { + return doesProcessPathStemMatchTargetName(process.executable, targetName, '.exe'); +} + function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, targetPath: string): boolean { if (!isDotNetProcess(process)) { return false; @@ -756,6 +770,60 @@ function isFrameworkDependentProcessForTarget(process: LaunchedChildProcess, tar return commandContainsPathArgumentAfterDotNetExec(process.command, targetPath); } +function isFrameworkDependentProcessForTargetName(process: LaunchedChildProcess, targetName: string): boolean { + if (!isDotNetProcess(process)) { + return false; + } + + const targetArgument = process.commandLineArguments + ? getFirstDllArgumentAfterExec(process.commandLineArguments) + : getFirstDllArgumentAfterDotNetExec(process.command); + return targetArgument !== undefined && + doesProcessPathStemMatchTargetName(targetArgument, targetName, '.dll'); +} + +function getFirstDllArgumentAfterExec(argumentsList: readonly string[]): string | undefined { + const execIndex = argumentsList.indexOf('exec'); + if (execIndex < 1) { + return undefined; + } + + return argumentsList.slice(execIndex + 1).find(argument => /\.dll$/i.test(argument)); +} + +function getFirstDllArgumentAfterDotNetExec(command: string): string | undefined { + const dotNetExec = /^\s*(?:"[^"]+"|'[^']+'|\S+)\s+exec(?:\s+|$)/.exec(command); + if (!dotNetExec) { + return undefined; + } + + // Raw process text has the shape: + // dotnet exec "/repo/bin/Release/net10.0/Api.dll" --flag /app/Other.dll + // Only the first DLL token is the host target; later DLL values are application arguments. + const dllArgument = /(?:^|\s)(?:"([^"]+\.dll)"|'([^']+\.dll)'|(\S+\.dll))(?=$|\s)/i.exec( + command.slice(dotNetExec[0].length)); + return dllArgument?.[1] ?? dllArgument?.[2] ?? dllArgument?.[3]; +} + +function doesProcessPathStemMatchTargetName( + processPath: string, + targetName: string, + extension: '.dll' | '.exe', +): boolean { + const fileName = processPath.split(/[\\/]/).pop(); + if (fileName === undefined) { + return false; + } + + const stem = fileName.toLowerCase().endsWith(extension) + ? fileName.slice(0, -extension.length) + : fileName; + const isWindowsPath = /^(?:[a-z]:[\\/]|\\\\)/i.test(processPath); + return isWindowsPath + ? stem.toLowerCase() === targetName.toLowerCase() + : stem === targetName; +} + function areProcessPathsEqual(left: string, right: string): boolean { const normalizedLeft = left.replace(/\\/g, '/'); const normalizedRight = right.replace(/\\/g, '/'); @@ -843,7 +911,7 @@ export async function createDotNetAttachDebugSessionConfiguration( ): Promise { const attachInfo = getDotNetAttachDebuggerResourceInfo(resource); if (!attachInfo) { - throw new ResourceAttachConfigurationError('resourceNotAttachable', invalidLaunchConfiguration(JSON.stringify(resource))); + throw new ResourceAttachConfigurationError('resourceNotAttachable', invalidLaunchConfiguration(resource.name)); } let targetInfo: DotNetAttachTargetInfo; @@ -860,7 +928,7 @@ export async function createDotNetAttachDebugSessionConfiguration( try { applicationPid = await childProcessResolver.resolveProcessId( attachInfo.launcherPid, - await createDotNetProcessIdentity(targetInfo, fileSystem), + await createDotNetProcessIdentity(targetInfo, attachInfo, fileSystem), cancellationToken); } catch (error) { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 76f66490dce..f2ef55e9f96 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -48,7 +48,7 @@ class TestDotNetService { this._hasDevKit = hasDevKit; } - getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise<{ targetPath: string, useAppHost: boolean }> { + getDotNetAttachTargetInfo(projectFile: string, configuration?: string, cancellationToken?: vscode.CancellationToken, framework?: string): Promise<{ targetPath: string, targetName?: string, useAppHost: boolean }> { return framework ? this.getDotNetAttachTargetInfoStub(projectFile, configuration, cancellationToken, framework) : cancellationToken @@ -201,6 +201,7 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'run', }, }); @@ -245,6 +246,40 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); + test('watch attach permits a transitive TargetPath descendant', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: false }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'watch', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.requiresDirectChild, false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 5678, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec ${targetPath}`, + }), true); + }); + test('matches a spaced evaluated TargetPath from the raw framework-dependent command without matching a prefix sibling', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); @@ -329,6 +364,236 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); + test('older redacted framework-dependent snapshots match TargetName instead of the default TargetPath', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.requiresDirectChild, true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Api.dll --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec "/repo/bin/Release/net10.0/Api.dll" --urls http://localhost:5000', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Api.Worker.dll', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec /repo/bin/Release/net10.0/Other.dll /repo/app-arguments/Api.dll', + }), false); + }); + + test('older structured framework-dependent snapshots use the first DLL target after dotnet exec', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Release/net10.0/Other.dll', + '/repo/app-arguments/Api.dll', + ], + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Release/net10.0/Api.dll', + '/repo/app-arguments/Other.dll', + ], + }), true); + }); + + test('older redacted apphost snapshots match the TargetName executable basename', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: true, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + }, + }); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/Api', + command: '/repo/bin/Release/net10.0/Api', + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/Api.Worker', + command: '/repo/bin/Release/net10.0/Api.Worker', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/repo/bin/Release/net10.0/api', + command: '/repo/bin/Release/net10.0/api', + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', + command: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', + }), true); + }); + + test('older TargetName fallback remains scoped to the selected launcher tree', async () => { + const targetPath = '/repo/bin/Debug/net10.0/Api.dll'; + const dotNetService = new TestDotNetService(targetPath, null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath, + targetName: 'Api', + useAppHost: false, + }); + const resolver = new LaunchedChildProcessResolver( + new StaticLaunchedChildProcessQuery([ + createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Release/net10.0/Api.dll'), + createLaunchedProcess(5678, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), + createLaunchedProcess(8765, 5678, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Release/net10.0/Api.dll'), + ]), + immediateProcessClock, + { timeoutMs: 20, retryDelayMs: 10 }); + const attachProvider = createProjectResourceAttachProvider(() => dotNetService, resolver); + + const configuration = await attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + }, + }); + + assert.strictEqual(configuration.processId, 4321); + }); + + test('older TargetName fallback fails closed when MSBuild omits TargetName', async () => { + const { dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + + await assert.rejects( + attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + }, + }), + (error: unknown) => error instanceof Error + && error.message === 'This resource cannot be attached to a debugger.'); + + assert.strictEqual(resolver.resolveProcessId.called, false); + }); + test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); @@ -841,31 +1106,9 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('attach configuration evaluates TargetPath with the launched project configuration', async () => { + test('attach configuration uses safe properties when executable arguments are redacted', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], - 'project.path': '/repo/api/Api.csproj', - }, - }); - - assert.strictEqual(configuration.processId, 4321); - assert.strictEqual(configuration.processName, undefined); - assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly('/repo/api/Api.csproj', 'Release')); - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); - }); - - test('attach configuration evaluates TargetPath with the launched target framework', async () => { - const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); - await attachProvider.createDebugConfiguration({ name: 'api', displayName: 'API', @@ -874,8 +1117,11 @@ suite('Dotnet Debugger Extension Tests', () => { properties: { 'executable.pid': '1234', 'executable.path': 'dotnet', - 'executable.args': ['run', '--configuration', 'Release', '--framework', 'net10.0', '--', '--framework', 'not-a-tfm'], + 'executable.args': null, 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', }, }); @@ -883,8 +1129,8 @@ suite('Dotnet Debugger Extension Tests', () => { '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); }); - test('attach configuration uses safe snapshot properties when executable arguments are redacted', async () => { - const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); + test('attach configuration prefers safe properties and does not parse executable arguments', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); await attachProvider.createDebugConfiguration({ name: 'api', @@ -894,37 +1140,58 @@ suite('Dotnet Debugger Extension Tests', () => { properties: { 'executable.pid': '1234', 'executable.path': 'dotnet', - 'executable.args': null, + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], 'project.path': '/repo/api/Api.csproj', 'project.configuration': 'Release', 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', }, }); - assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( - '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); - }); - - test('attach configuration prefers safe snapshot properties over legacy executable arguments', async () => { - const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/Api.dll', null, true, true); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', + name: 'args-only', + displayName: 'Args only', resourceType: 'Project', state: 'Running', properties: { - 'executable.pid': '1234', + 'executable.pid': '5678', 'executable.path': 'dotnet', 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], 'project.path': '/repo/api/Api.csproj', - 'project.configuration': 'Release', - 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', }, }); - assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( - '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); + assert.deepStrictEqual(dotNetService.getDotNetAttachTargetInfoStub.firstCall.args, [ + '/repo/api/Api.csproj', 'Release', undefined, 'net10.0', + ]); + assert.deepStrictEqual(dotNetService.getDotNetAttachTargetInfoStub.secondCall.args, [ + '/repo/api/Api.csproj', undefined, + ]); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + + test('attach configuration rejects an explicitly malformed launch command', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + + await assert.rejects( + attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'publish', + }, + }), + (error: unknown) => error instanceof Error + && error.message === 'Invalid launch configuration for api.'); + + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); }); test('attach configuration passes cancellation to target discovery', async () => { @@ -1059,7 +1326,7 @@ suite('Dotnet Debugger Extension Tests', () => { } }); - test('attach configuration supports framework-dependent projects', async () => { + test('target discovery returns TargetName for framework-dependent projects', async () => { const dotNetService = new DotNetService(undefined); const msbuildProcess = createMsbuildProcess(); const spawn = sinon.stub(childProcess, 'spawn').callsFake(() => { @@ -1067,6 +1334,7 @@ suite('Dotnet Debugger Extension Tests', () => { msbuildProcess.stdout.emit('data', JSON.stringify({ Properties: { TargetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + TargetName: ' ReleaseApi ', UseAppHost: 'false', }, })); @@ -1074,29 +1342,20 @@ suite('Dotnet Debugger Extension Tests', () => { }); return msbuildProcess.process; }); - const attachProvider = createProjectResourceAttachProvider(() => dotNetService, { - resolveProcessId: async () => 4321, - }); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': ['run', '--project', '/repo/api/Api.csproj', '--configuration', 'Release', '--no-launch-profile'], - 'project.path': '/repo/api/Api.csproj', - }, - }); + const targetInfo = await dotNetService.getDotNetAttachTargetInfo('/repo/api/Api.csproj', 'Release'); - assert.strictEqual(configuration.processId, 4321); + assert.deepStrictEqual(targetInfo, { + targetPath: '/repo/bin/Release/net10.0/ReleaseApi.dll', + targetName: 'ReleaseApi', + useAppHost: false, + }); assert.deepStrictEqual(spawn.firstCall.args[1], [ 'msbuild', '/repo/api/Api.csproj', '-nologo', '-getProperty:TargetPath', + '-getProperty:TargetName', '-getProperty:UseAppHost', '-v:q', '-property:GenerateFullPaths=true', @@ -1117,11 +1376,18 @@ suite('Dotnet Debugger Extension Tests', () => { 'executable.pid': '1234', 'executable.path': 'dotnet', 'project.path': '/repo/api/Api.cs', + 'secret.snapshot.property': 'top-secret', }, }), - (error: unknown) => error instanceof Error - && error.name === 'ResourceAttachConfigurationError' - && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for api.'); + assert.strictEqual(error.name, 'ResourceAttachConfigurationError'); + assert.strictEqual( + (error as Error & { errorKind?: string }).errorKind, + 'resourceNotAttachable'); + return true; + }); assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); From 938849550740ec5b5408d9c48cdfbbe1d1685417 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 13:36:18 -0400 Subject: [PATCH 70/90] Fix dotnet attach identity edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/languages/dotnet.ts | 51 +++++++++++----- extension/src/test/dotnetDebugger.test.ts | 67 ++++++++++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 147ce12a3d5..ebd0687996e 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -648,9 +648,18 @@ function canRecognizeDotNetAttachDebuggerResource(resource: ResourceDebugResourc function getDotNetLaunchMetadata( resource: ResourceDebugResourceSnapshot, ): Pick | undefined { + const properties = resource.properties; + const hasConfiguration = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectConfigurationPropertyName); + const hasFramework = properties !== null && properties !== undefined && + Object.prototype.hasOwnProperty.call(properties, projectTargetFrameworkPropertyName); const configuration = getNonEmptyStringProperty(resource, projectConfigurationPropertyName); const framework = getNonEmptyStringProperty(resource, projectTargetFrameworkPropertyName); - const properties = resource.properties; + if ((hasConfiguration && configuration === undefined) || + (hasFramework && framework === undefined)) { + return undefined; + } + const hasLaunchCommand = properties !== null && properties !== undefined && Object.prototype.hasOwnProperty.call(properties, projectLaunchCommandPropertyName); const launchCommandValue = properties?.[projectLaunchCommandPropertyName]; @@ -664,8 +673,8 @@ function getDotNetLaunchMetadata( launchCommand: launchCommandValue as DotNetLaunchCommand | undefined, useTargetNameFallback: !hasLaunchCommand && properties?.[executableArgsPropertyName] === null && - configuration === undefined && - framework === undefined, + !hasConfiguration && + !hasFramework, }; } @@ -797,12 +806,15 @@ function getFirstDllArgumentAfterDotNetExec(command: string): string | undefined return undefined; } + const dllArgument = getFirstDllArgumentMatch(command.slice(dotNetExec[0].length)); + return dllArgument?.[1] ?? dllArgument?.[2] ?? dllArgument?.[3]; +} + +function getFirstDllArgumentMatch(command: string): RegExpExecArray | null { // Raw process text has the shape: // dotnet exec "/repo/bin/Release/net10.0/Api.dll" --flag /app/Other.dll // Only the first DLL token is the host target; later DLL values are application arguments. - const dllArgument = /(?:^|\s)(?:"([^"]+\.dll)"|'([^']+\.dll)'|(\S+\.dll))(?=$|\s)/i.exec( - command.slice(dotNetExec[0].length)); - return dllArgument?.[1] ?? dllArgument?.[2] ?? dllArgument?.[3]; + return /(?:^|\s)(?:"([^"]+\.dll)"|'([^']+\.dll)'|(\S+\.dll))(?=$|\s)/i.exec(command); } function doesProcessPathStemMatchTargetName( @@ -818,8 +830,12 @@ function doesProcessPathStemMatchTargetName( const stem = fileName.toLowerCase().endsWith(extension) ? fileName.slice(0, -extension.length) : fileName; - const isWindowsPath = /^(?:[a-z]:[\\/]|\\\\)/i.test(processPath); - return isWindowsPath + // Windows CIM can omit ExecutablePath and return only Name, such as `API.EXE`, so the + // executable suffix must also identify Windows semantics when no path is available. + const isWindowsIdentity = /^(?:[a-z]:[\\/]|\\\\)/i.test(processPath) || + processPath.includes('\\') || + /\.exe$/i.test(fileName); + return isWindowsIdentity ? stem.toLowerCase() === targetName.toLowerCase() : stem === targetName; } @@ -875,9 +891,8 @@ async function getCanonicalAppHostPaths( } function commandLineArgumentsContainTargetPath(argumentsList: readonly string[], targetPath: string): boolean { - const execIndex = argumentsList.indexOf('exec'); - return execIndex >= 1 && - argumentsList.slice(execIndex + 1).some(argument => areProcessPathsEqual(argument, targetPath)); + const targetArgument = getFirstDllArgumentAfterExec(argumentsList); + return targetArgument !== undefined && areProcessPathsEqual(targetArgument, targetPath); } function commandContainsPathArgumentAfterDotNetExec(command: string, targetPath: string): boolean { @@ -886,16 +901,22 @@ function commandContainsPathArgumentAfterDotNetExec(command: string, targetPath: return false; } - return commandContainsPathArgument(command.slice(dotNetExec[0].length), targetPath); + const commandAfterExec = command.slice(dotNetExec[0].length); + const targetPathIndex = getPathArgumentIndex(commandAfterExec, targetPath); + const firstDllArgumentIndex = getFirstDllArgumentMatch(commandAfterExec)?.index; + return targetPathIndex !== undefined && + firstDllArgumentIndex !== undefined && + targetPathIndex <= firstDllArgumentIndex; } -function commandContainsPathArgument(command: string, targetPath: string): boolean { +function getPathArgumentIndex(command: string, targetPath: string): number | undefined { const normalizedCommand = command.replace(/\\/g, '/'); const normalizedTargetPath = targetPath.replace(/\\/g, '/'); const isWindowsPath = /^[a-z]:\//i.test(normalizedCommand) || /^[a-z]:\//i.test(normalizedTargetPath); - return new RegExp( + const match = new RegExp( `(?:^|\\s|["'])${escapeRegularExpression(normalizedTargetPath)}(?=$|\\s|["'])`, - isWindowsPath ? 'i' : undefined).test(normalizedCommand); + isWindowsPath ? 'i' : undefined).exec(normalizedCommand); + return match?.index; } function escapeRegularExpression(value: string): string { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index f2ef55e9f96..859e7565331 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -322,6 +322,12 @@ suite('Dotnet Debugger Extension Tests', () => { executable: '/usr/local/share/dotnet/dotnet', command: `dotnet run ${targetPath}`, }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4324, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: `dotnet exec /repo/bin/Debug/net10.0/Other.dll ${targetPath}`, + }), false); }); test('matches a structured framework-dependent TargetPath without accepting other dotnet children', async () => { @@ -362,6 +368,18 @@ suite('Dotnet Debugger Extension Tests', () => { command: 'dotnet exec malformed-posix-command', commandLineArguments: ['dotnet', 'exec', `${targetPath}.bak`], }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: '/usr/local/share/dotnet/dotnet', + command: 'dotnet exec malformed-posix-command', + commandLineArguments: [ + 'dotnet', + 'exec', + '/repo/bin/Debug/net10.0/Other.dll', + targetPath, + ], + }), false); }); test('older redacted framework-dependent snapshots match TargetName instead of the default TargetPath', async () => { @@ -525,6 +543,12 @@ suite('Dotnet Debugger Extension Tests', () => { executable: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', command: 'C:\\repo\\bin\\Release\\net10.0\\API.EXE', }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4325, + parentPid: 1234, + executable: 'API.EXE', + command: 'API.EXE', + }), true); }); test('older TargetName fallback remains scoped to the selected launcher tree', async () => { @@ -594,6 +618,49 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(resolver.resolveProcessId.called, false); }); + test('older TargetName fallback rejects present invalid safe metadata', async () => { + const dotNetService = new TestDotNetService('/repo/bin/Debug/net10.0/Api.dll', null, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ + targetPath: '/repo/bin/Debug/net10.0/Api.dll', + targetName: 'Api', + useAppHost: false, + }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createProjectResourceAttachProvider( + () => dotNetService, + resolver as unknown as LaunchedChildProcessResolver); + const invalidProperties = [ + ['project.configuration', ''], + ['project.targetFramework', null], + ['project.configuration', 42], + ] as const; + + for (const [index, [propertyName, propertyValue]] of invalidProperties.entries()) { + const resourceName = `api-${index}`; + await assert.rejects( + attachProvider.createDebugConfiguration({ + name: resourceName, + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + [propertyName]: propertyValue, + }, + }), + (error: unknown) => error instanceof Error + && error.message === `Invalid launch configuration for ${resourceName}.`); + } + + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); + assert.strictEqual(resolver.resolveProcessId.called, false); + }); + test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); From 4e83ee1320edf2b596d10f59b5ca28e51126a2fe Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 13:46:48 -0400 Subject: [PATCH 71/90] Reduce resource attach process queries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../debugger/launchedChildProcessDiscovery.ts | 32 ++++- .../launchedChildProcessDiscovery.test.ts | 127 ++++++++++++++++++ 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 1b747d1f228..1466f8dc41c 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -11,6 +11,7 @@ export interface LaunchedChildProcess { } export interface LaunchedChildProcessQuery { + readonly canTrustListedProcessIdentity?: boolean; listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; getProcess?(processId: number, cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise; } @@ -255,13 +256,25 @@ export class LaunchedChildProcessResolver { return true; } + if (identity.requiresDirectChild === true) { + const candidate = await this._getProcess( + candidatePid, + undefined, + cancellationToken, + deadline, + true); + return candidate !== undefined && + candidate.parentPid === launcherPid && + identity.isCandidate(candidate); + } + let processId = candidatePid; const visited = new Set(); // `ps` renders command arguments verbatim, including newlines. A malicious command can // therefore forge a plausible extra row in an all-process listing. Re-query every PID in - // the selected ancestry immediately before returning so topology and command identity come - // from the kernel's actual process record rather than a synthetic line. + // the selected transitive ancestry immediately before returning so topology and command + // identity come from the kernel's actual process record rather than a synthetic line. while (true) { if (visited.has(processId) || this._clock.now() > deadline) { return false; @@ -273,9 +286,7 @@ export class LaunchedChildProcessResolver { return false; } - if (processId === candidatePid && - (!identity.isCandidate(process) || - (identity.requiresDirectChild === true && process.parentPid !== launcherPid))) { + if (processId === candidatePid && !identity.isCandidate(process)) { return false; } @@ -296,8 +307,14 @@ export class LaunchedChildProcessResolver { topologyProcess: LaunchedChildProcess | undefined, cancellationToken: vscode.CancellationToken | undefined, deadline: number, + requireFresh = false, ): Promise { - if (!this._processQuery.getProcess) { + if (!this._processQuery.getProcess || + (!requireFresh && + this._processQuery.canTrustListedProcessIdentity === true && + topologyProcess !== undefined && + topologyProcess.executable.length > 0 && + topologyProcess.command.length > 0)) { return topologyProcess; } @@ -319,11 +336,14 @@ export class LaunchedChildProcessResolver { } export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuery { + readonly canTrustListedProcessIdentity: boolean; + constructor( private readonly _platform: NodeJS.Platform = process.platform, private readonly _commandRunner: LaunchedChildProcessCommandRunner = new SystemLaunchedChildProcessCommandRunner(), private readonly _fileSystem: LaunchedChildProcessFileSystem = systemLaunchedChildProcessFileSystem, ) { + this.canTrustListedProcessIdentity = this._platform === 'win32'; } async listProcesses(cancellationToken?: vscode.CancellationToken, timeoutMs?: number): Promise { diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index cece14873a7..ac8eb183f05 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -120,6 +120,12 @@ suite('Launched child process discovery', () => { ]); }); + test('trusts listed process identity only on Windows', () => { + assert.strictEqual(new SystemLaunchedChildProcessQuery('win32').canTrustListedProcessIdentity, true); + assert.strictEqual(new SystemLaunchedChildProcessQuery('darwin').canTrustListedProcessIdentity, false); + assert.strictEqual(new SystemLaunchedChildProcessQuery('linux').canTrustListedProcessIdentity, false); + }); + test('parses UTF-8 BOM-prefixed Windows CIM output with non-ASCII command text', () => { assert.deepStrictEqual(parseWindowsProcessList(`\uFEFF${JSON.stringify({ ProcessId: 42, @@ -560,6 +566,127 @@ suite('Launched child process discovery', () => { await assert.rejects(cyclic.resolveProcessId(10, identity)); }); + test('uses trusted complete list identity until the final direct candidate read', async () => { + const getProcess = sinon.stub().callsFake(async (processId: number) => + processId === 42 ? process(42, 10, '/target/api') : undefined); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + ], + getProcess, + }; + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42); + assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42]); + }); + + test('falls back to targeted identity queries for incomplete trusted list records', async () => { + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const cases = [ + { + processes: [ + process(10, 1, '/tool/launcher', ''), + process(42, 10, '/target/api'), + ], + expectedProcessReads: [10, 10, 42], + }, + { + processes: [ + process(10, 1, '/tool/launcher'), + process(42, 10, '', '/target/api'), + ], + expectedProcessReads: [42, 42, 42], + }, + ]; + + for (const testCase of cases) { + const getProcess = sinon.stub().callsFake(async (processId: number) => + processId === 10 + ? process(10, 1, '/tool/launcher') + : process(42, 10, '/target/api')); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => testCase.processes, + getProcess, + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42); + assert.deepStrictEqual( + getProcess.getCalls().map(call => call.args[0]), + testCase.expectedProcessReads); + } + }); + + test('rejects direct candidates that exit, change PID, or are reparented before return', async () => { + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const finalCandidates = [ + undefined, + process(43, 10, '/target/api'), + process(42, 99, '/target/api'), + ]; + + for (const finalCandidate of finalCandidates) { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + process(42, 10, '/target/api'), + ], + getProcess: async () => finalCandidate, + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, directIdentity)); + } + }); + + test('freshly re-reads the full transitive candidate ancestry', async () => { + const getProcess = sinon.stub().callsFake(async (processId: number) => new Map([ + [10, process(10, 1, '/tool/launcher')], + [22, process(22, 10, '/tool/intermediate')], + [42, process(42, 22, '/target/api')], + ]).get(processId)); + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher'), + process(22, 10, '/tool/intermediate'), + process(42, 22, '/target/api'), + ], + getProcess, + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); + assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42, 22, 10]); + }); + test('re-verifies selected PID ancestry before accepting a process-list candidate', async () => { const injectedCandidate = process(42, 10, '/target/api', '/target/api'); const query: LaunchedChildProcessQuery = { From c7495cdca966a454bc7cbb90b979b8dc66e4d9fe Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 14:15:26 -0400 Subject: [PATCH 72/90] Fix process identity verification gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../debugger/launchedChildProcessDiscovery.ts | 45 ++++- .../launchedChildProcessDiscovery.test.ts | 175 ++++++++++++++++-- 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 1466f8dc41c..fbb1a3956f4 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -8,6 +8,7 @@ export interface LaunchedChildProcess { readonly executable: string; readonly command: string; readonly commandLineArguments?: readonly string[]; + readonly hasCompleteIdentity?: boolean; } export interface LaunchedChildProcessQuery { @@ -86,13 +87,15 @@ export function parseWindowsProcessList(output: string): readonly LaunchedChildP } const values = row as Record; + const executablePath = getNonEmptyString(values.ExecutablePath); + const commandLine = getNonEmptyString(values.CommandLine); const process = createProcessInfo( values.ProcessId, values.ParentProcessId, - typeof values.ExecutablePath === 'string' && values.ExecutablePath.length > 0 - ? values.ExecutablePath - : values.Name, - values.CommandLine); + executablePath ?? values.Name, + commandLine, + undefined, + executablePath !== undefined && commandLine !== undefined); if (process) { processes.push(process); } @@ -257,12 +260,22 @@ export class LaunchedChildProcessResolver { } if (identity.requiresDirectChild === true) { + throwIfCancelled(cancellationToken); + if (this._clock.now() > deadline) { + return false; + } + const candidate = await this._getProcess( candidatePid, undefined, cancellationToken, deadline, true); + throwIfCancelled(cancellationToken); + if (this._clock.now() > deadline) { + return false; + } + return candidate !== undefined && candidate.parentPid === launcherPid && identity.isCandidate(candidate); @@ -313,8 +326,7 @@ export class LaunchedChildProcessResolver { (!requireFresh && this._processQuery.canTrustListedProcessIdentity === true && topologyProcess !== undefined && - topologyProcess.executable.length > 0 && - topologyProcess.command.length > 0)) { + topologyProcess.hasCompleteIdentity === true)) { return topologyProcess; } @@ -525,12 +537,22 @@ const systemLaunchedChildProcessFileSystem: LaunchedChildProcessFileSystem = { export const launchedChildProcessResolver = new LaunchedChildProcessResolver( new SystemLaunchedChildProcessQuery()); +function getNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +} + function createProcessInfo( pidValue: unknown, parentPidValue: unknown, executableValue: unknown, commandValue: unknown, commandLineArguments?: readonly string[], + hasCompleteIdentity?: boolean, ): LaunchedChildProcess | undefined { const pid = parsePid(pidValue); const parentPid = parseParentPid(parentPidValue); @@ -540,13 +562,22 @@ function createProcessInfo( return undefined; } - return { + const process: LaunchedChildProcess = { pid, parentPid, executable, command: command.length > 0 ? command : executable, ...(commandLineArguments ? { commandLineArguments } : {}), }; + if (hasCompleteIdentity !== undefined) { + // This is resolver bookkeeping rather than process identity exposed to callers. Keep it + // non-enumerable so adding the marker does not change the parsed process value shape. + Object.defineProperty(process, 'hasCompleteIdentity', { + value: hasCompleteIdentity, + }); + } + + return process; } function parseLinuxCommandLine(commandLine: Buffer): readonly string[] { diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index ac8eb183f05..8991ae760cd 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -66,6 +66,18 @@ function process( }; } +function listedProcess( + pid: number, + parentPid: number, + executable: string, + command = executable, +): LaunchedChildProcess { + return { + ...process(pid, parentPid, executable, command), + hasCompleteIdentity: executable.trim().length > 0 && command.trim().length > 0, + }; +} + function createCommandProcess(): childProcess.ChildProcessWithoutNullStreams { const child = new EventEmitter() as childProcess.ChildProcessWithoutNullStreams; const stdout = Object.assign(new EventEmitter(), { setEncoding: () => { } }); @@ -126,6 +138,68 @@ suite('Launched child process discovery', () => { assert.strictEqual(new SystemLaunchedChildProcessQuery('linux').canTrustListedProcessIdentity, false); }); + test('target-queries Windows processes when bulk CIM identity is incomplete', async () => { + const targetedProcessReads: number[] = []; + const commandRunner: LaunchedChildProcessCommandRunner = { + async run(_command, args): Promise { + const command = args.at(-1) ?? ''; + const processIdMatch = /ProcessId = (\d+)/.exec(command); + if (processIdMatch) { + const processId = Number(processIdMatch[1]); + targetedProcessReads.push(processId); + return JSON.stringify(processId === 10 + ? { + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: 'C:\\tool\\launcher.exe', + CommandLine: '"C:\\tool\\launcher.exe" --run', + } + : { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: '"C:\\target\\api.exe" --listen', + }); + } + + return JSON.stringify([ + { + ProcessId: 10, + ParentProcessId: 1, + Name: 'launcher.exe', + ExecutablePath: null, + CommandLine: '"C:\\tool\\launcher.exe" --run', + }, + { + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: '', + }, + ]); + }, + }; + const resolver = new LaunchedChildProcessResolver( + new SystemLaunchedChildProcessQuery('win32', commandRunner), + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + const windowsIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + isLauncher: candidate => + candidate.executable === 'C:\\tool\\launcher.exe' && + candidate.command === '"C:\\tool\\launcher.exe" --run', + isCandidate: candidate => + candidate.executable === 'C:\\target\\api.exe' && + candidate.command === '"C:\\target\\api.exe" --listen', + }; + + assert.strictEqual(await resolver.resolveProcessId(10, windowsIdentity), 42); + assert.deepStrictEqual(targetedProcessReads, [10, 42, 10, 42, 42]); + }); + test('parses UTF-8 BOM-prefixed Windows CIM output with non-ASCII command text', () => { assert.deepStrictEqual(parseWindowsProcessList(`\uFEFF${JSON.stringify({ ProcessId: 42, @@ -572,8 +646,8 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - process(10, 1, '/tool/launcher'), - process(42, 10, '/target/api'), + listedProcess(10, 1, '/tool/launcher'), + listedProcess(42, 10, '/target/api'), ], getProcess, }; @@ -598,15 +672,15 @@ suite('Launched child process discovery', () => { const cases = [ { processes: [ - process(10, 1, '/tool/launcher', ''), - process(42, 10, '/target/api'), + listedProcess(10, 1, '/tool/launcher', ''), + listedProcess(42, 10, '/target/api'), ], expectedProcessReads: [10, 10, 42], }, { processes: [ - process(10, 1, '/tool/launcher'), - process(42, 10, '', '/target/api'), + listedProcess(10, 1, '/tool/launcher'), + listedProcess(42, 10, '', '/target/api'), ], expectedProcessReads: [42, 42, 42], }, @@ -634,14 +708,14 @@ suite('Launched child process discovery', () => { } }); - test('rejects direct candidates that exit, change PID, or are reparented before return', async () => { + test('rejects direct candidates that exit, are reused, or are reparented before return', async () => { const directIdentity: LaunchedChildProcessIdentity = { requiresDirectChild: true, ...identity, }; const finalCandidates = [ undefined, - process(43, 10, '/target/api'), + process(42, 10, '/other/api'), process(42, 99, '/target/api'), ]; @@ -649,8 +723,8 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - process(10, 1, '/tool/launcher'), - process(42, 10, '/target/api'), + listedProcess(10, 1, '/tool/launcher'), + listedProcess(42, 10, '/target/api'), ], getProcess: async () => finalCandidate, }; @@ -663,6 +737,37 @@ suite('Launched child process discovery', () => { } }); + test('rejects a direct candidate when its final identity read completes after the deadline', async () => { + let now = 0; + const clock: LaunchedChildProcessClock = { + now: () => now, + sleep: async milliseconds => { + now += milliseconds; + }, + }; + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + listedProcess(10, 1, '/tool/launcher'), + listedProcess(42, 10, '/target/api'), + ], + getProcess: async () => { + now = 21; + return process(42, 10, '/target/api'); + }, + }; + const directIdentity: LaunchedChildProcessIdentity = { + requiresDirectChild: true, + ...identity, + }; + const resolver = new LaunchedChildProcessResolver( + query, + clock, + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, directIdentity)); + }); + test('freshly re-reads the full transitive candidate ancestry', async () => { const getProcess = sinon.stub().callsFake(async (processId: number) => new Map([ [10, process(10, 1, '/tool/launcher')], @@ -672,9 +777,9 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - process(10, 1, '/tool/launcher'), - process(22, 10, '/tool/intermediate'), - process(42, 22, '/target/api'), + listedProcess(10, 1, '/tool/launcher'), + listedProcess(22, 10, '/tool/intermediate'), + listedProcess(42, 22, '/target/api'), ], getProcess, }; @@ -687,6 +792,50 @@ suite('Launched child process discovery', () => { assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42, 22, 10]); }); + test('rejects a transitive candidate when the freshly queried launcher identity changes', async () => { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + listedProcess(10, 1, '/tool/launcher'), + listedProcess(22, 10, '/tool/intermediate'), + listedProcess(42, 22, '/target/api'), + ], + getProcess: async processId => new Map([ + [10, process(10, 1, '/tool/other')], + [22, process(22, 10, '/tool/intermediate')], + [42, process(42, 22, '/target/api')], + ]).get(processId), + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + }); + + test('rejects a transitive candidate when a freshly queried intermediate is reparented', async () => { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + listedProcess(10, 1, '/tool/launcher'), + listedProcess(22, 10, '/tool/intermediate'), + listedProcess(42, 22, '/target/api'), + ], + getProcess: async processId => new Map([ + [10, process(10, 1, '/tool/launcher')], + [22, process(22, 99, '/tool/intermediate')], + [42, process(42, 22, '/target/api')], + ]).get(processId), + }; + const resolver = new LaunchedChildProcessResolver( + query, + new TestClock(), + { timeoutMs: 20, retryDelayMs: 10 }); + + await assert.rejects(resolver.resolveProcessId(10, identity)); + }); + test('re-verifies selected PID ancestry before accepting a process-list candidate', async () => { const injectedCandidate = process(42, 10, '/target/api', '/target/api'); const query: LaunchedChildProcessQuery = { From 610041049e7bd83648426bf38a9e5e9dcd600530 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 15:48:00 -0400 Subject: [PATCH 73/90] Fix resource debug compatibility boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- docs/specs/cli-output-formats.md | 2 +- extension/src/debugger/languages/dotnet.ts | 6 +- .../debugger/launchedChildProcessDiscovery.ts | 7 +- extension/src/test-e2e/edgeCases.e2e.test.ts | 12 ++++ extension/src/test/dotnetDebugger.test.ts | 71 +++++++++++++++++++ .../launchedChildProcessDiscovery.test.ts | 10 ++- extension/src/testing/e2eStateFileBridge.ts | 2 +- .../Dcp/ResourceSnapshotBuilder.cs | 2 +- .../Dcp/ResourceSnapshotBuilderTests.cs | 47 ++++++++++++ 9 files changed, 147 insertions(+), 12 deletions(-) diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index c47459521aa..f2c8517ef58 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -169,7 +169,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream | `relationships` | Related resources as `{ "type": "...", "resourceName": "..." }`. | | `urls` | Endpoint objects with `name`, `displayName`, `url`, and `isInternal`. | | `volumes` | Volume objects with `source`, `target`, `mountType`, and `isReadOnly`. | -| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.launchCommand`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. | +| `properties` | Resource properties keyed by property name. Common debug-related keys include `project.path`, `project.launchProfile`, `project.launchCommand`, `project.configuration`, `project.targetFramework`, and `resource.launchConfigurationType`. For current AppHosts, `project.launchCommand` is `run`, `watch`, or `null` when a dotnet project launch command cannot be classified. | | `environment` | Environment variables keyed by variable name. | | `healthReports` | Health report objects keyed by report name. | | `commands` | Resource command metadata keyed by command name. | diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index ebd0687996e..b5fd3adc4af 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -984,12 +984,12 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess return launchConfig.project_path; } - throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); + throw new Error(invalidLaunchConfiguration(launchConfig.type)); }, createDebugSessionConfigurationCallback: async (launchConfig, args, env, launchOptions, debugConfiguration: AspireResourceExtendedDebugConfiguration): Promise => { if (!isProjectLaunchConfiguration(launchConfig)) { - extensionLogOutputChannel.info(`The resource type was not project for ${JSON.stringify(launchConfig)}`); - throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); + extensionLogOutputChannel.info(`The resource type was not project for ${launchConfig.type}`); + throw new Error(invalidLaunchConfiguration(launchConfig.type)); } const projectPath = launchConfig.project_path; diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index fbb1a3956f4..eb9d4b1c509 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -284,10 +284,9 @@ export class LaunchedChildProcessResolver { let processId = candidatePid; const visited = new Set(); - // `ps` renders command arguments verbatim, including newlines. A malicious command can - // therefore forge a plausible extra row in an all-process listing. Re-query every PID in - // the selected transitive ancestry immediately before returning so topology and command - // identity come from the kernel's actual process record rather than a synthetic line. + // POSIX process-list rows contain only PID/PPID topology, while Windows CIM rows can also + // carry trusted identity for candidate discovery. Regardless of the listing source, re-read + // every PID in the selected transitive ancestry from the OS immediately before returning. while (true) { if (visited.has(processId) || this._clock.now() > deadline) { return false; diff --git a/extension/src/test-e2e/edgeCases.e2e.test.ts b/extension/src/test-e2e/edgeCases.e2e.test.ts index e3026419905..07868ab6962 100644 --- a/extension/src/test-e2e/edgeCases.e2e.test.ts +++ b/extension/src/test-e2e/edgeCases.e2e.test.ts @@ -2,6 +2,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import type { AspireExtensionE2EControlCommand } from '../types/extensionApi'; +import type { ExecutableLaunchConfiguration } from '../dcp/types'; import { getCommandInvocationCount, getDebugLaunchCount, isSamePath, waitForCommandOutcome, waitForDebugLaunch, waitForDebugSessionStartup, waitForExtensionState, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForRunningAppHost, waitForWorkspaceAppHost } from './helpers/assertions'; import { createExternalSingleFileAppHost, executeE2eControlCommand, isProcessAlive, removeExternalSingleFileAppHost, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setDebugLaunchSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, waitForKnownProcessExit } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; @@ -62,6 +63,17 @@ suite('Aspire extension edge case E2E', function () { executeE2eControlCommand({ name: 'publishAppHost' }), /publishAppHost requires appHostPath/); assert.strictEqual(getDebugLaunchCount(), beforePublishLaunch); + + await assert.rejects( + executeE2eControlCommand({ + name: 'createResourceDebugConfiguration', + launchConfig: { + type: 'browser', + browser: 'safari', + url: 'https://top-secret.invalid', + } as ExecutableLaunchConfiguration, + }), + /E2E control command failed\./); }); test('keeps CLI-independent settings commands available when the CLI is unavailable', async () => { diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 859e7565331..f2074525c4c 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -12,6 +12,7 @@ import type { ResourceAttachProvider } from '../debugger/resourceDebugContracts' import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; import * as cliProcess from '../utils/process/cliProcess'; +import { extensionLogOutputChannel } from '../utils/logging'; import { LaunchedChildProcessResolver, type LaunchedChildProcess, @@ -1261,6 +1262,30 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); }); + test('attach configuration rejects a present null launch command before older fallback', async () => { + const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + + await assert.rejects( + attachProvider.createDebugConfiguration({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: 'Running', + properties: { + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': null, + }, + }), + (error: unknown) => error instanceof Error + && error.message === 'Invalid launch configuration for api.'); + + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + }); + test('attach configuration passes cancellation to target discovery', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); const cancellation = new vscode.CancellationTokenSource(); @@ -2006,6 +2031,52 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(projectDebuggerExtension.getProjectFile(fileBasedConfig), '/tmp/app.cs'); }); + test('invalid project launch configurations do not expose arbitrary properties', async () => { + const secret = 'top-secret'; + const invalidLaunchConfig = { + type: 'node', + name: 'api', + secret, + } as unknown as ExecutableLaunchConfiguration; + const info = sinon.stub(extensionLogOutputChannel, 'info'); + + assert.throws( + () => projectDebuggerExtension.getProjectFile(invalidLaunchConfig), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for node.'); + return true; + }); + + await assert.rejects( + projectDebuggerExtension.createDebugSessionConfigurationCallback!( + invalidLaunchConfig, + [], + [], + { + debug: true, + runId: '1', + debugSessionId: '1', + isApphost: false, + debugSession: sinon.createStubInstance(AspireDebugSession), + }, + { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Test Debug Config', + request: 'launch', + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Invalid launch configuration for node.'); + return true; + }); + + assert.strictEqual(info.calledOnceWithExactly('The resource type was not project for node'), true); + assert.strictEqual(info.firstCall.args.join(' ').includes(secret), false); + }); + test('file-based AppHost follows CLI build ownership', async () => { const executablePath = '/tmp/obj/Debug/net10.0/apphost'; const { extension, dotNetService } = createDebuggerExtension('unused-build-output', null, true, true); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 8991ae760cd..14d67c39fd3 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -121,15 +121,21 @@ suite('Launched child process discovery', () => { }); test('parses Windows CIM process listings', () => { - assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify({ + const processes = parseWindowsProcessList(JSON.stringify({ ProcessId: 42, ParentProcessId: 10, Name: 'api.exe', ExecutablePath: 'C:\\target\\api.exe', CommandLine: 'C:\\target\\api.exe', - })), [ + })); + + assert.deepStrictEqual(processes, [ process(42, 10, 'C:\\target\\api.exe', 'C:\\target\\api.exe'), ]); + assert.strictEqual(processes.length, 1); + const parsedProcess = processes[0]; + assert.strictEqual(parsedProcess.hasCompleteIdentity, true); + assert.strictEqual(Object.keys(parsedProcess).includes('hasCompleteIdentity'), false); }); test('trusts listed process identity only on Windows', () => { diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 9aa6e60048b..56e606379b9 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -353,7 +353,7 @@ function getE2eErrorMessage(error: unknown): string { return 'E2E control command cancelled.'; } - return error instanceof Error + return error instanceof Error && error.message.startsWith('Aspire extension E2E ') ? error.message : 'E2E control command failed.'; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index c6fd14fba3b..de803e81f2e 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -250,7 +250,7 @@ private static ImmutableArray GetDotNetLaunchPropertie (!string.Equals(command, "run", StringComparison.OrdinalIgnoreCase) && !string.Equals(command, "watch", StringComparison.OrdinalIgnoreCase))) { - return []; + return [new(KnownProperties.Project.LaunchCommand, null)]; } string? configuration = null; diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index d4d3e05a05a..956f2ecb7e0 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -169,6 +169,53 @@ public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); } + [Fact] + public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetArgumentsAreMissing() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand); + Assert.Null(launchCommand.Value); + Assert.False(launchCommand.IsSensitive); + } + + [Fact] + public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetCommandIsUnsupported() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet.exe"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = ["publish", "--configuration", "Release"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand); + Assert.Null(launchCommand.Value); + Assert.False(launchCommand.IsSensitive); + } + [Fact] public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations() { From 88e17a5a3c2ca9f89ad0a45b408c13f6a4f43f0e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 16:29:59 -0400 Subject: [PATCH 74/90] Simplify listed process identity tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../debugger/launchedChildProcessDiscovery.ts | 23 ++---- .../launchedChildProcessDiscovery.test.ts | 76 ++++++++++--------- 2 files changed, 44 insertions(+), 55 deletions(-) diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index eb9d4b1c509..9bee0f17c8c 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -8,7 +8,6 @@ export interface LaunchedChildProcess { readonly executable: string; readonly command: string; readonly commandLineArguments?: readonly string[]; - readonly hasCompleteIdentity?: boolean; } export interface LaunchedChildProcessQuery { @@ -88,14 +87,11 @@ export function parseWindowsProcessList(output: string): readonly LaunchedChildP const values = row as Record; const executablePath = getNonEmptyString(values.ExecutablePath); - const commandLine = getNonEmptyString(values.CommandLine); const process = createProcessInfo( values.ProcessId, values.ParentProcessId, executablePath ?? values.Name, - commandLine, - undefined, - executablePath !== undefined && commandLine !== undefined); + values.CommandLine); if (process) { processes.push(process); } @@ -325,7 +321,8 @@ export class LaunchedChildProcessResolver { (!requireFresh && this._processQuery.canTrustListedProcessIdentity === true && topologyProcess !== undefined && - topologyProcess.hasCompleteIdentity === true)) { + topologyProcess.executable.length > 0 && + topologyProcess.command.length > 0)) { return topologyProcess; } @@ -551,7 +548,6 @@ function createProcessInfo( executableValue: unknown, commandValue: unknown, commandLineArguments?: readonly string[], - hasCompleteIdentity?: boolean, ): LaunchedChildProcess | undefined { const pid = parsePid(pidValue); const parentPid = parseParentPid(parentPidValue); @@ -561,22 +557,13 @@ function createProcessInfo( return undefined; } - const process: LaunchedChildProcess = { + return { pid, parentPid, executable, - command: command.length > 0 ? command : executable, + command, ...(commandLineArguments ? { commandLineArguments } : {}), }; - if (hasCompleteIdentity !== undefined) { - // This is resolver bookkeeping rather than process identity exposed to callers. Keep it - // non-enumerable so adding the marker does not change the parsed process value shape. - Object.defineProperty(process, 'hasCompleteIdentity', { - value: hasCompleteIdentity, - }); - } - - return process; } function parseLinuxCommandLine(commandLine: Buffer): readonly string[] { diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 14d67c39fd3..24127b014c4 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -66,18 +66,6 @@ function process( }; } -function listedProcess( - pid: number, - parentPid: number, - executable: string, - command = executable, -): LaunchedChildProcess { - return { - ...process(pid, parentPid, executable, command), - hasCompleteIdentity: executable.trim().length > 0 && command.trim().length > 0, - }; -} - function createCommandProcess(): childProcess.ChildProcessWithoutNullStreams { const child = new EventEmitter() as childProcess.ChildProcessWithoutNullStreams; const stdout = Object.assign(new EventEmitter(), { setEncoding: () => { } }); @@ -132,10 +120,24 @@ suite('Launched child process discovery', () => { assert.deepStrictEqual(processes, [ process(42, 10, 'C:\\target\\api.exe', 'C:\\target\\api.exe'), ]); - assert.strictEqual(processes.length, 1); - const parsedProcess = processes[0]; - assert.strictEqual(parsedProcess.hasCompleteIdentity, true); - assert.strictEqual(Object.keys(parsedProcess).includes('hasCompleteIdentity'), false); + assert.deepStrictEqual(Object.keys(processes[0]), [ + 'pid', + 'parentPid', + 'executable', + 'command', + ]); + }); + + test('preserves an empty command for incomplete Windows CIM process listings', () => { + assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: null, + })), [ + process(42, 10, 'C:\\target\\api.exe', ''), + ]); }); test('trusts listed process identity only on Windows', () => { @@ -175,7 +177,7 @@ suite('Launched child process discovery', () => { ProcessId: 10, ParentProcessId: 1, Name: 'launcher.exe', - ExecutablePath: null, + ExecutablePath: 'C:\\tool\\launcher.exe', CommandLine: '"C:\\tool\\launcher.exe" --run', }, { @@ -203,7 +205,7 @@ suite('Launched child process discovery', () => { }; assert.strictEqual(await resolver.resolveProcessId(10, windowsIdentity), 42); - assert.deepStrictEqual(targetedProcessReads, [10, 42, 10, 42, 42]); + assert.deepStrictEqual(targetedProcessReads, [42, 42, 42]); }); test('parses UTF-8 BOM-prefixed Windows CIM output with non-ASCII command text', () => { @@ -652,8 +654,8 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(42, 10, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), ], getProcess, }; @@ -678,15 +680,15 @@ suite('Launched child process discovery', () => { const cases = [ { processes: [ - listedProcess(10, 1, '/tool/launcher', ''), - listedProcess(42, 10, '/target/api'), + process(10, 1, '/tool/launcher', ''), + process(42, 10, '/target/api', '/target/api'), ], expectedProcessReads: [10, 10, 42], }, { processes: [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(42, 10, '', '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '', '/target/api'), ], expectedProcessReads: [42, 42, 42], }, @@ -729,8 +731,8 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(42, 10, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), ], getProcess: async () => finalCandidate, }; @@ -754,8 +756,8 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(42, 10, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(42, 10, '/target/api', '/target/api'), ], getProcess: async () => { now = 21; @@ -783,9 +785,9 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(22, 10, '/tool/intermediate'), - listedProcess(42, 22, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), ], getProcess, }; @@ -802,9 +804,9 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(22, 10, '/tool/intermediate'), - listedProcess(42, 22, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), ], getProcess: async processId => new Map([ [10, process(10, 1, '/tool/other')], @@ -824,9 +826,9 @@ suite('Launched child process discovery', () => { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ - listedProcess(10, 1, '/tool/launcher'), - listedProcess(22, 10, '/tool/intermediate'), - listedProcess(42, 22, '/target/api'), + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), ], getProcess: async processId => new Map([ [10, process(10, 1, '/tool/launcher')], From 9931b989f0ce0542cac38fd79c5c5c9bf4111001 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 16:39:23 -0400 Subject: [PATCH 75/90] Document name-only process identity trust Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/debugger/launchedChildProcessDiscovery.ts | 2 ++ extension/src/test/launchedChildProcessDiscovery.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 9bee0f17c8c..20b1db5550d 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -87,6 +87,8 @@ export function parseWindowsProcessList(output: string): readonly LaunchedChildP const values = row as Record; const executablePath = getNonEmptyString(values.ExecutablePath); + // CIM can omit ExecutablePath. Name plus CommandLine is still usable listed identity: + // exact-path matchers fail closed on Name, and selected ancestry is freshly queried. const process = createProcessInfo( values.ProcessId, values.ParentProcessId, diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 24127b014c4..7708d101d52 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -146,7 +146,7 @@ suite('Launched child process discovery', () => { assert.strictEqual(new SystemLaunchedChildProcessQuery('linux').canTrustListedProcessIdentity, false); }); - test('target-queries Windows processes when bulk CIM identity is incomplete', async () => { + test('trusts the Windows Name fallback when ExecutablePath is unavailable', async () => { const targetedProcessReads: number[] = []; const commandRunner: LaunchedChildProcessCommandRunner = { async run(_command, args): Promise { @@ -177,7 +177,7 @@ suite('Launched child process discovery', () => { ProcessId: 10, ParentProcessId: 1, Name: 'launcher.exe', - ExecutablePath: 'C:\\tool\\launcher.exe', + ExecutablePath: null, CommandLine: '"C:\\tool\\launcher.exe" --run', }, { @@ -197,7 +197,7 @@ suite('Launched child process discovery', () => { const windowsIdentity: LaunchedChildProcessIdentity = { requiresDirectChild: true, isLauncher: candidate => - candidate.executable === 'C:\\tool\\launcher.exe' && + candidate.executable === 'launcher.exe' && candidate.command === '"C:\\tool\\launcher.exe" --run', isCandidate: candidate => candidate.executable === 'C:\\target\\api.exe' && From 754126cd04819b8f0958d2c716307b6aeb9f514e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 16:57:17 -0400 Subject: [PATCH 76/90] Consolidate resource debug regression tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/test/dotnetDebugger.test.ts | 742 ++++++------------ .../launchedChildProcessDiscovery.test.ts | 273 +++---- 2 files changed, 379 insertions(+), 636 deletions(-) diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index f2074525c4c..66199a455b7 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -146,6 +146,36 @@ function createLaunchedProcess(pid: number, parentPid: number, executable: strin return { pid, parentPid, executable, command }; } +type TestResource = Parameters[0]; + +function createProjectResource( + properties: TestResource['properties'], + name = 'api', + displayName = 'API', +): TestResource { + return { + name, + displayName, + resourceType: 'Project', + state: 'Running', + properties, + }; +} + +function createAttachProvider( + dotNetService: TestDotNetService, + childProcessResolver: TestLaunchedChildProcessResolver, + fileSystem?: { realpath(path: string): Promise }, +): ResourceAttachProvider { + const factory = createProjectResourceAttachProvider as unknown as ( + dotNetServiceProducer: () => TestDotNetService, + resolver: TestLaunchedChildProcessResolver, + fileSystem?: { realpath(path: string): Promise }, + ) => ResourceAttachProvider; + + return factory(() => dotNetService, childProcessResolver, fileSystem); +} + suite('Dotnet Debugger Extension Tests', () => { let getHotReloadDiagnostics: sinon.SinonStub; let logHotReloadDiagnostics: sinon.SinonStub; @@ -187,24 +217,14 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider(() => dotNetService, resolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - 'project.launchCommand': 'run', - }, - }); + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'run', + })); assert.deepStrictEqual(configuration, { type: 'coreclr', @@ -254,22 +274,14 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - 'project.launchCommand': 'watch', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'watch', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.requiresDirectChild, false); @@ -288,21 +300,13 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.isCandidate({ @@ -338,21 +342,13 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.isCandidate({ @@ -393,22 +389,14 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.requiresDirectChild, true); @@ -448,22 +436,14 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.isCandidate({ @@ -502,22 +482,14 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.isCandidate({ @@ -569,20 +541,14 @@ suite('Dotnet Debugger Extension Tests', () => { ]), immediateProcessClock, { timeoutMs: 20, retryDelayMs: 10 }); - const attachProvider = createProjectResourceAttachProvider(() => dotNetService, resolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - }, - }); + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + })); assert.strictEqual(configuration.processId, 4321); }); @@ -596,23 +562,15 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { + attachProvider.createDebugConfiguration(createProjectResource({ 'executable.pid': '1234', 'executable.path': 'dotnet', 'executable.args': null, 'project.path': '/repo/api/Api.csproj', - }, - }), + })), (error: unknown) => error instanceof Error && error.message === 'This resource cannot be attached to a debugger.'); @@ -629,33 +587,26 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); const invalidProperties = [ - ['project.configuration', ''], - ['project.targetFramework', null], - ['project.configuration', 42], + { label: 'empty configuration', propertyName: 'project.configuration', propertyValue: '' }, + { label: 'null target framework', propertyName: 'project.targetFramework', propertyValue: null }, + { label: 'non-string configuration', propertyName: 'project.configuration', propertyValue: 42 }, ] as const; - for (const [index, [propertyName, propertyValue]] of invalidProperties.entries()) { + for (const [index, { label, propertyName, propertyValue }] of invalidProperties.entries()) { const resourceName = `api-${index}`; await assert.rejects( - attachProvider.createDebugConfiguration({ - name: resourceName, - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - [propertyName]: propertyValue, - }, - }), + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + [propertyName]: propertyValue, + }, resourceName)), (error: unknown) => error instanceof Error - && error.message === `Invalid launch configuration for ${resourceName}.`); + && error.message === `Invalid launch configuration for ${resourceName}.`, + label); } assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); @@ -669,23 +620,13 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider(() => dotNetService, resolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': 1234, - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); assert.strictEqual(configuration.processId, 4321); assert.strictEqual(configuration.processName, undefined); @@ -742,27 +683,13 @@ suite('Dotnet Debugger Extension Tests', () => { throw new Error('ENOENT'); }); - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - fileSystem: { realpath(path: string): Promise }, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider( - () => dotNetService, - resolver, - { realpath }); - - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/workspace/link/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(appHostIdentity.isCandidate({ @@ -818,27 +745,13 @@ suite('Dotnet Debugger Extension Tests', () => { throw new Error('ENOENT'); }); - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - fileSystem: { realpath(path: string): Promise }, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider( - () => dotNetService, - resolver, - { realpath }); - - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/workspace/link/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(appHostIdentity.isCandidate({ @@ -873,27 +786,13 @@ suite('Dotnet Debugger Extension Tests', () => { throw new Error('ENOENT'); }); - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - fileSystem: { realpath(path: string): Promise }, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider( - () => dotNetService, - resolver, - { realpath }); - - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/workspace/link/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(appHostIdentity.isCandidate({ @@ -920,27 +819,13 @@ suite('Dotnet Debugger Extension Tests', () => { resolveProcessId: sinon.stub().resolves(4321), }; const realpath = sinon.stub().rejects(new Error('ENOENT')); - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - fileSystem: { realpath(path: string): Promise }, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider( - () => dotNetService, - resolver, - { realpath }); - - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/workspace/link/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver, { realpath }); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/workspace/link/api/Api.csproj', + })); const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(appHostIdentity.isCandidate({ @@ -967,21 +852,13 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const attachProvider = createProjectResourceAttachProvider( - () => dotNetService, - resolver as unknown as LaunchedChildProcessResolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); const appHostIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(appHostIdentity.isCandidate({ @@ -992,17 +869,11 @@ suite('Dotnet Debugger Extension Tests', () => { }), true); dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath: 'C:\\Repo\\Api.dll', useAppHost: false }); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); const frameworkDependentIdentity = resolver.resolveProcessId.secondCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(frameworkDependentIdentity.isCandidate({ @@ -1018,23 +889,13 @@ suite('Dotnet Debugger Extension Tests', () => { const resolver = { resolveProcessId: sinon.stub().resolves(4321), }; - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider(() => dotNetService, resolver); + const attachProvider = createAttachProvider(dotNetService, resolver); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': 1234, - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; assert.strictEqual(processIdentity.isCandidate({ @@ -1058,25 +919,15 @@ suite('Dotnet Debugger Extension Tests', () => { .onFirstCall().resolves(4321) .onSecondCall().resolves(4322), }; - const createAttachProvider = createProjectResourceAttachProvider as unknown as ( - dotNetServiceProducer: () => TestDotNetService, - childProcessResolver: TestLaunchedChildProcessResolver, - ) => ResourceAttachProvider; - const attachProvider = createAttachProvider(() => dotNetService, resolver); - const createResource = (pid: number): Parameters[0] => ({ - name: `api-${pid}`, - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': pid, - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver); + const resource = (pid: number) => createProjectResource({ + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, `api-${pid}`); - const firstConfiguration = await attachProvider.createDebugConfiguration(createResource(1234)); - const secondConfiguration = await attachProvider.createDebugConfiguration(createResource(5678)); + const firstConfiguration = await attachProvider.createDebugConfiguration(resource(1234)); + const secondConfiguration = await attachProvider.createDebugConfiguration(resource(5678)); assert.strictEqual(firstConfiguration.processId, 4321); assert.strictEqual(secondConfiguration.processId, 4322); @@ -1095,17 +946,11 @@ suite('Dotnet Debugger Extension Tests', () => { { timeoutMs: 20, retryDelayMs: 10 }); return createProjectResourceAttachProvider(() => dotNetService, resolver); }; - const resource = { - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }; + const resource = createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }); const noChild = createProvider([ createLaunchedProcess(1234, 1, '/usr/local/share/dotnet/dotnet', 'dotnet run --project /repo/api/Api.csproj'), createLaunchedProcess(4321, 1234, '/usr/local/share/dotnet/dotnet', 'dotnet exec /repo/bin/Debug/net10.0/Other.dll'), @@ -1133,37 +978,25 @@ suite('Dotnet Debugger Extension Tests', () => { ]), immediateProcessClock, { timeoutMs: 20, retryDelayMs: 10 }); - const attachProvider = createProjectResourceAttachProvider(() => dotNetService, resolver); - const createResource = (pid: number): Parameters[0] => ({ - name: `api-${pid}`, - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': pid, - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + const attachProvider = createAttachProvider(dotNetService, resolver); + const resource = (pid: number) => createProjectResource({ + 'executable.pid': pid, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }, `api-${pid}`); - assert.strictEqual((await attachProvider.createDebugConfiguration(createResource(1234))).processId, 4321); - assert.strictEqual((await attachProvider.createDebugConfiguration(createResource(5678))).processId, 8765); + assert.strictEqual((await attachProvider.createDebugConfiguration(resource(1234))).processId, 4321); + assert.strictEqual((await attachProvider.createDebugConfiguration(resource(5678))).processId, 8765); }); test('attach configuration uses the resolved project TargetPath child process ID', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }); + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); assert.strictEqual(configuration.type, 'coreclr'); assert.strictEqual(configuration.request, 'attach'); @@ -1177,21 +1010,15 @@ suite('Dotnet Debugger Extension Tests', () => { test('attach configuration uses safe properties when executable arguments are redacted', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - 'project.configuration': 'Release', - 'project.targetFramework': 'net10.0', - 'project.launchCommand': 'run', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', + })); assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( '/repo/api/Api.csproj', 'Release', undefined, 'net10.0')); @@ -1200,35 +1027,23 @@ suite('Dotnet Debugger Extension Tests', () => { test('attach configuration prefers safe properties and does not parse executable arguments', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Release/net10.0/ReleaseApi.dll', null, true, true); - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], - 'project.path': '/repo/api/Api.csproj', - 'project.configuration': 'Release', - 'project.targetFramework': 'net10.0', - 'project.launchCommand': 'run', - }, - }); - - await attachProvider.createDebugConfiguration({ - name: 'args-only', - displayName: 'Args only', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '5678', - 'executable.path': 'dotnet', - 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], - 'project.path': '/repo/api/Api.csproj', - 'project.launchCommand': 'run', - }, - }); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], + 'project.path': '/repo/api/Api.csproj', + 'project.configuration': 'Release', + 'project.targetFramework': 'net10.0', + 'project.launchCommand': 'run', + })); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '5678', + 'executable.path': 'dotnet', + 'executable.args': ['run', '--configuration', 'Debug', '--framework', 'net9.0'], + 'project.path': '/repo/api/Api.csproj', + 'project.launchCommand': 'run', + }, 'args-only', 'Args only')); assert.deepStrictEqual(dotNetService.getDotNetAttachTargetInfoStub.firstCall.args, [ '/repo/api/Api.csproj', 'Release', undefined, 'net10.0', @@ -1239,51 +1054,34 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); }); - test('attach configuration rejects an explicitly malformed launch command', async () => { + test('attach configuration rejects present invalid launch command metadata', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + const cases = [ + { label: 'unsupported publish marker', launchCommand: 'publish', checksLegacyTargetPath: false }, + { label: 'present null marker fails before older fallback', launchCommand: null, checksLegacyTargetPath: true }, + ] as const; - await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', - 'project.launchCommand': 'publish', - }, - }), - (error: unknown) => error instanceof Error - && error.message === 'Invalid launch configuration for api.'); - - assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); - }); - - test('attach configuration rejects a present null launch command before older fallback', async () => { - const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); + for (const { label, launchCommand, checksLegacyTargetPath } of cases) { + dotNetService.getDotNetAttachTargetInfoStub.resetHistory(); + dotNetService.getDotNetTargetPathStub.resetHistory(); - await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { + await assert.rejects( + attachProvider.createDebugConfiguration(createProjectResource({ 'executable.pid': '1234', 'executable.path': 'dotnet', 'executable.args': null, 'project.path': '/repo/api/Api.csproj', - 'project.launchCommand': null, - }, - }), - (error: unknown) => error instanceof Error - && error.message === 'Invalid launch configuration for api.'); + 'project.launchCommand': launchCommand, + })), + (error: unknown) => error instanceof Error + && error.message === 'Invalid launch configuration for api.', + label); - assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false); - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false); + assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false, label); + if (checksLegacyTargetPath) { + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false, label); + } + } }); test('attach configuration passes cancellation to target discovery', async () => { @@ -1291,17 +1089,11 @@ suite('Dotnet Debugger Extension Tests', () => { const cancellation = new vscode.CancellationTokenSource(); try { - await attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - }, - }, cancellation.token); + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + }), cancellation.token); assert.ok(dotNetService.getDotNetAttachTargetInfoStub.calledOnceWithExactly( '/repo/api/Api.csproj', @@ -1459,18 +1251,12 @@ suite('Dotnet Debugger Extension Tests', () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'api', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.cs', - 'secret.snapshot.property': 'top-secret', - }, - }), + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.cs', + 'secret.snapshot.property': 'top-secret', + })), (error: unknown) => { assert.ok(error instanceof Error); assert.strictEqual(error.message, 'Invalid launch configuration for api.'); @@ -1487,19 +1273,13 @@ suite('Dotnet Debugger Extension Tests', () => { test('attach configuration keeps parented project resources attachable', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); - const configuration = await attachProvider.createDebugConfiguration({ - name: 'api-grouped', - displayName: 'API', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - 'resource.launchConfigurationType': 'project', - 'resource.parentName': 'group', - }, - }); + const configuration = await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.launchConfigurationType': 'project', + 'resource.parentName': 'group', + }, 'api-grouped')); assert.strictEqual(configuration.processId, 4321); assert.strictEqual(configuration.processName, undefined); @@ -1511,19 +1291,13 @@ suite('Dotnet Debugger Extension Tests', () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'mauiapp-android-emulator', - displayName: 'MAUI', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/maui/MauiApp.csproj', - 'resource.launchConfigurationType': 'maui', - 'resource.parentName': 'mauiapp', - }, - }), + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/maui/MauiApp.csproj', + 'resource.launchConfigurationType': 'maui', + 'resource.parentName': 'mauiapp', + }, 'mauiapp-android-emulator', 'MAUI')), (error: unknown) => error instanceof Error && error.name === 'ResourceAttachConfigurationError' && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); @@ -1535,18 +1309,12 @@ suite('Dotnet Debugger Extension Tests', () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/FromTargetPath.dll', null, true, true); await assert.rejects( - attachProvider.createDebugConfiguration({ - name: 'legacy-parented', - displayName: 'Legacy parented project', - resourceType: 'Project', - state: 'Running', - properties: { - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'project.path': '/repo/api/Api.csproj', - 'resource.parentName': 'group', - }, - }), + attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + 'resource.parentName': 'group', + }, 'legacy-parented', 'Legacy parented project')), (error: unknown) => error instanceof Error && error.name === 'ResourceAttachConfigurationError' && (error as Error & { errorKind?: string }).errorKind === 'resourceNotAttachable'); diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 7708d101d52..9741e3dda27 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -94,6 +94,14 @@ function createLinuxProcessQuery( return new SystemLaunchedChildProcessQuery('linux', commandRunner, fileSystem); } +function createResolver( + query: LaunchedChildProcessQuery, + timeoutMs: number, + clock: LaunchedChildProcessClock = new TestClock(), +): LaunchedChildProcessResolver { + return new LaunchedChildProcessResolver(query, clock, { timeoutMs, retryDelayMs: 10 }); +} + suite('Launched child process discovery', () => { teardown(() => sinon.restore()); @@ -108,42 +116,54 @@ suite('Launched child process discovery', () => { ]); }); - test('parses Windows CIM process listings', () => { - const processes = parseWindowsProcessList(JSON.stringify({ - ProcessId: 42, - ParentProcessId: 10, - Name: 'api.exe', - ExecutablePath: 'C:\\target\\api.exe', - CommandLine: 'C:\\target\\api.exe', - })); - - assert.deepStrictEqual(processes, [ - process(42, 10, 'C:\\target\\api.exe', 'C:\\target\\api.exe'), - ]); - assert.deepStrictEqual(Object.keys(processes[0]), [ - 'pid', - 'parentPid', - 'executable', - 'command', - ]); - }); - - test('preserves an empty command for incomplete Windows CIM process listings', () => { - assert.deepStrictEqual(parseWindowsProcessList(JSON.stringify({ - ProcessId: 42, - ParentProcessId: 10, - Name: 'api.exe', - ExecutablePath: 'C:\\target\\api.exe', - CommandLine: null, - })), [ - process(42, 10, 'C:\\target\\api.exe', ''), - ]); + test('parses complete and incomplete Windows CIM process listings', () => { + const cases = [ + { + label: 'complete identity', + commandLine: 'C:\\target\\api.exe', + expectedCommand: 'C:\\target\\api.exe', + }, + { + label: 'missing command preserves empty command', + commandLine: null, + expectedCommand: '', + }, + ] as const; + + for (const { label, commandLine, expectedCommand } of cases) { + const processes = parseWindowsProcessList(JSON.stringify({ + ProcessId: 42, + ParentProcessId: 10, + Name: 'api.exe', + ExecutablePath: 'C:\\target\\api.exe', + CommandLine: commandLine, + })); + + assert.deepStrictEqual(processes, [ + process(42, 10, 'C:\\target\\api.exe', expectedCommand), + ], label); + assert.deepStrictEqual(Object.keys(processes[0]), [ + 'pid', + 'parentPid', + 'executable', + 'command', + ], label); + } }); test('trusts listed process identity only on Windows', () => { - assert.strictEqual(new SystemLaunchedChildProcessQuery('win32').canTrustListedProcessIdentity, true); - assert.strictEqual(new SystemLaunchedChildProcessQuery('darwin').canTrustListedProcessIdentity, false); - assert.strictEqual(new SystemLaunchedChildProcessQuery('linux').canTrustListedProcessIdentity, false); + const cases = [ + { platform: 'win32', expected: true }, + { platform: 'darwin', expected: false }, + { platform: 'linux', expected: false }, + ] as const; + + for (const { platform, expected } of cases) { + assert.strictEqual( + new SystemLaunchedChildProcessQuery(platform).canTrustListedProcessIdentity, + expected, + platform); + } }); test('trusts the Windows Name fallback when ExecutablePath is unavailable', async () => { @@ -190,10 +210,9 @@ suite('Launched child process discovery', () => { ]); }, }; - const resolver = new LaunchedChildProcessResolver( + const resolver = createResolver( new SystemLaunchedChildProcessQuery('win32', commandRunner), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); const windowsIdentity: LaunchedChildProcessIdentity = { requiresDirectChild: true, isLauncher: candidate => @@ -410,10 +429,9 @@ suite('Launched child process discovery', () => { } }, }; - const resolver = new LaunchedChildProcessResolver( + const resolver = createResolver( new SystemLaunchedChildProcessQuery('darwin', commandRunner), - new TestClock(), - { timeoutMs: 100, retryDelayMs: 10 }); + 100); const spacedTargetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; const exactPathIdentity: LaunchedChildProcessIdentity = { requiresDirectChild: true, @@ -464,10 +482,7 @@ suite('Launched child process discovery', () => { } }, }; - const resolver = new LaunchedChildProcessResolver( - createLinuxProcessQuery(commandRunner, fileSystem), - new TestClock(), - { timeoutMs: 100, retryDelayMs: 10 }); + const resolver = createResolver(createLinuxProcessQuery(commandRunner, fileSystem), 100); const frameworkDependentIdentity: LaunchedChildProcessIdentity = { requiresDirectChild: true, isLauncher: candidate => candidate.executable === '/tool/launcher', @@ -513,10 +528,7 @@ suite('Launched child process discovery', () => { } }, }; - const resolver = new LaunchedChildProcessResolver( - createLinuxProcessQuery(commandRunner, fileSystem), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(createLinuxProcessQuery(commandRunner, fileSystem), 20); await assert.rejects(resolver.resolveProcessId(10, identity)); assert.ok(candidateReadAttempts >= 2); @@ -566,7 +578,7 @@ suite('Launched child process discovery', () => { }); test('resolves a stable nested child only beneath its launcher', async () => { - const resolver = new LaunchedChildProcessResolver( + const resolver = createResolver( new SequenceProcessQuery([ [ process(10, 1, '/tool/launcher'), @@ -581,38 +593,34 @@ suite('Launched child process discovery', () => { process(43, 1, '/target/unrelated'), ], ]), - new TestClock(), - { timeoutMs: 100, retryDelayMs: 10 }); + 100); assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); }); test('waits for the same matching child twice', async () => { - const resolver = new LaunchedChildProcessResolver( + const resolver = createResolver( new SequenceProcessQuery([ [process(10, 1, '/tool/launcher'), process(42, 10, '/target/old')], [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], [process(10, 1, '/tool/launcher'), process(43, 10, '/target/new')], ]), - new TestClock(), - { timeoutMs: 100, retryDelayMs: 10 }); + 100); assert.strictEqual(await resolver.resolveProcessId(10, identity), 43); }); test('fails closed for a missing or ambiguous matching child', async () => { - const noCandidate = new LaunchedChildProcessResolver( + const noCandidate = createResolver( new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); - const ambiguous = new LaunchedChildProcessResolver( + 20); + const ambiguous = createResolver( new SequenceProcessQuery([[ process(10, 1, '/tool/launcher'), process(42, 10, '/target/api'), process(43, 10, '/target/worker'), ]]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); await assert.rejects(noCandidate.resolveProcessId(10, identity)); await assert.rejects(ambiguous.resolveProcessId(10, identity)); @@ -624,26 +632,24 @@ suite('Launched child process discovery', () => { isLauncher: candidate => candidate.executable === '/tool/launcher', isCandidate: candidate => candidate.executable === '/usr/local/share/dotnet/dotnet', }; - const resolver = new LaunchedChildProcessResolver( + const resolver = createResolver( new SequenceProcessQuery([[ process(10, 1, '/tool/launcher'), process(42, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec malformed-posix-command'), process(43, 10, '/usr/local/share/dotnet/dotnet', 'dotnet exec another-malformed-posix-command'), ]]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); await assert.rejects(resolver.resolveProcessId(10, directDotnetIdentity)); }); test('fails closed for a cyclic process listing', async () => { - const cyclic = new LaunchedChildProcessResolver( + const cyclic = createResolver( new SequenceProcessQuery([[ process(10, 42, '/tool/launcher'), process(42, 10, '/target/api'), ]]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); await assert.rejects(cyclic.resolveProcessId(10, identity)); }); @@ -663,10 +669,7 @@ suite('Launched child process discovery', () => { requiresDirectChild: true, ...identity, }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42); assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42]); @@ -679,6 +682,7 @@ suite('Launched child process discovery', () => { }; const cases = [ { + label: 'launcher command is missing', processes: [ process(10, 1, '/tool/launcher', ''), process(42, 10, '/target/api', '/target/api'), @@ -686,6 +690,7 @@ suite('Launched child process discovery', () => { expectedProcessReads: [10, 10, 42], }, { + label: 'candidate executable is missing', processes: [ process(10, 1, '/tool/launcher', '/tool/launcher'), process(42, 10, '', '/target/api'), @@ -694,25 +699,23 @@ suite('Launched child process discovery', () => { }, ]; - for (const testCase of cases) { + for (const { label, processes, expectedProcessReads } of cases) { const getProcess = sinon.stub().callsFake(async (processId: number) => processId === 10 ? process(10, 1, '/tool/launcher') : process(42, 10, '/target/api')); const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, - listProcesses: async () => testCase.processes, + listProcesses: async () => processes, getProcess, }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); - assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42); + assert.strictEqual(await resolver.resolveProcessId(10, directIdentity), 42, label); assert.deepStrictEqual( getProcess.getCalls().map(call => call.args[0]), - testCase.expectedProcessReads); + expectedProcessReads, + label); } }); @@ -721,13 +724,13 @@ suite('Launched child process discovery', () => { requiresDirectChild: true, ...identity, }; - const finalCandidates = [ - undefined, - process(42, 10, '/other/api'), - process(42, 99, '/target/api'), + const cases = [ + { label: 'candidate exited', finalCandidate: undefined }, + { label: 'PID reused by another executable', finalCandidate: process(42, 10, '/other/api') }, + { label: 'candidate reparented', finalCandidate: process(42, 99, '/target/api') }, ]; - for (const finalCandidate of finalCandidates) { + for (const { label, finalCandidate } of cases) { const query: LaunchedChildProcessQuery = { canTrustListedProcessIdentity: true, listProcesses: async () => [ @@ -736,12 +739,9 @@ suite('Launched child process discovery', () => { ], getProcess: async () => finalCandidate, }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); - await assert.rejects(resolver.resolveProcessId(10, directIdentity)); + await assert.rejects(resolver.resolveProcessId(10, directIdentity), label); } }); @@ -768,10 +768,7 @@ suite('Launched child process discovery', () => { requiresDirectChild: true, ...identity, }; - const resolver = new LaunchedChildProcessResolver( - query, - clock, - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20, clock); await assert.rejects(resolver.resolveProcessId(10, directIdentity)); }); @@ -791,57 +788,43 @@ suite('Launched child process discovery', () => { ], getProcess, }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); assert.strictEqual(await resolver.resolveProcessId(10, identity), 42); assert.deepStrictEqual(getProcess.getCalls().map(call => call.args[0]), [42, 22, 10]); }); - test('rejects a transitive candidate when the freshly queried launcher identity changes', async () => { - const query: LaunchedChildProcessQuery = { - canTrustListedProcessIdentity: true, - listProcesses: async () => [ - process(10, 1, '/tool/launcher', '/tool/launcher'), - process(22, 10, '/tool/intermediate', '/tool/intermediate'), - process(42, 22, '/target/api', '/target/api'), - ], - getProcess: async processId => new Map([ - [10, process(10, 1, '/tool/other')], - [22, process(22, 10, '/tool/intermediate')], - [42, process(42, 22, '/target/api')], - ]).get(processId), - }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); - - await assert.rejects(resolver.resolveProcessId(10, identity)); - }); + test('rejects transitive candidates when freshly queried ancestry changes', async () => { + const cases = [ + { + label: 'launcher identity changes', + freshLauncher: process(10, 1, '/tool/other'), + freshIntermediate: process(22, 10, '/tool/intermediate'), + }, + { + label: 'intermediate is reparented', + freshLauncher: process(10, 1, '/tool/launcher'), + freshIntermediate: process(22, 99, '/tool/intermediate'), + }, + ]; - test('rejects a transitive candidate when a freshly queried intermediate is reparented', async () => { - const query: LaunchedChildProcessQuery = { - canTrustListedProcessIdentity: true, - listProcesses: async () => [ - process(10, 1, '/tool/launcher', '/tool/launcher'), - process(22, 10, '/tool/intermediate', '/tool/intermediate'), - process(42, 22, '/target/api', '/target/api'), - ], - getProcess: async processId => new Map([ - [10, process(10, 1, '/tool/launcher')], - [22, process(22, 99, '/tool/intermediate')], - [42, process(42, 22, '/target/api')], - ]).get(processId), - }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + for (const { label, freshLauncher, freshIntermediate } of cases) { + const query: LaunchedChildProcessQuery = { + canTrustListedProcessIdentity: true, + listProcesses: async () => [ + process(10, 1, '/tool/launcher', '/tool/launcher'), + process(22, 10, '/tool/intermediate', '/tool/intermediate'), + process(42, 22, '/target/api', '/target/api'), + ], + getProcess: async processId => new Map([ + [10, freshLauncher], + [22, freshIntermediate], + [42, process(42, 22, '/target/api')], + ]).get(processId), + }; - await assert.rejects(resolver.resolveProcessId(10, identity)); + await assert.rejects(createResolver(query, 20).resolveProcessId(10, identity), label); + } }); test('re-verifies selected PID ancestry before accepting a process-list candidate', async () => { @@ -857,24 +840,19 @@ suite('Launched child process discovery', () => { ? process(42, 99, '/target/api', '/target/api') : process(10, 1, '/tool/launcher'), }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); await assert.rejects(resolver.resolveProcessId(10, identity)); }); test('normalizes query failures and supports cancellation', async () => { - const failedResolver = new LaunchedChildProcessResolver( + const failedResolver = createResolver( new SequenceProcessQuery([new Error('/private/target/api 4242')]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); const cancellation = new vscode.CancellationTokenSource(); - const cancelledResolver = new LaunchedChildProcessResolver( + const cancelledResolver = createResolver( new SequenceProcessQuery([[process(10, 1, '/tool/launcher')]]), - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + 20); try { await assert.rejects( @@ -900,10 +878,7 @@ suite('Launched child process discovery', () => { throw new vscode.CancellationError(); }, }; - const resolver = new LaunchedChildProcessResolver( - query, - new TestClock(), - { timeoutMs: 20, retryDelayMs: 10 }); + const resolver = createResolver(query, 20); await assert.rejects(resolver.resolveProcessId(10, identity), vscode.CancellationError); }); From 0d80082a9089c868932d2cd4384f0bd774f9dce7 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 17:15:23 -0400 Subject: [PATCH 77/90] Tighten consolidated dotnet tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- extension/src/test/dotnetDebugger.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 66199a455b7..b1468228793 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -566,10 +566,10 @@ suite('Dotnet Debugger Extension Tests', () => { await assert.rejects( attachProvider.createDebugConfiguration(createProjectResource({ - 'executable.pid': '1234', - 'executable.path': 'dotnet', - 'executable.args': null, - 'project.path': '/repo/api/Api.csproj', + 'executable.pid': '1234', + 'executable.path': 'dotnet', + 'executable.args': null, + 'project.path': '/repo/api/Api.csproj', })), (error: unknown) => error instanceof Error && error.message === 'This resource cannot be attached to a debugger.'); @@ -1057,11 +1057,11 @@ suite('Dotnet Debugger Extension Tests', () => { test('attach configuration rejects present invalid launch command metadata', async () => { const { attachProvider, dotNetService } = createDebuggerExtension('/repo/bin/Debug/net10.0/Api.dll', null, true, true); const cases = [ - { label: 'unsupported publish marker', launchCommand: 'publish', checksLegacyTargetPath: false }, - { label: 'present null marker fails before older fallback', launchCommand: null, checksLegacyTargetPath: true }, + { label: 'unsupported publish marker', launchCommand: 'publish' }, + { label: 'present null marker fails before older fallback', launchCommand: null }, ] as const; - for (const { label, launchCommand, checksLegacyTargetPath } of cases) { + for (const { label, launchCommand } of cases) { dotNetService.getDotNetAttachTargetInfoStub.resetHistory(); dotNetService.getDotNetTargetPathStub.resetHistory(); @@ -1078,9 +1078,7 @@ suite('Dotnet Debugger Extension Tests', () => { label); assert.strictEqual(dotNetService.getDotNetAttachTargetInfoStub.called, false, label); - if (checksLegacyTargetPath) { - assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false, label); - } + assert.strictEqual(dotNetService.getDotNetTargetPathStub.called, false, label); } }); From 517879b16c1aa8c5ca6d567704d68bc46245a156 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 15 Aug 2026 19:46:34 -0400 Subject: [PATCH 78/90] Fix resource debug cancellation E2E race Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: daf7d04c-3dfd-4f43-927d-830d288b9bf7 --- .../test-e2e/helpers/languageModelTools.ts | 42 +++++++++++++++++-- .../test-e2e/resourceDebugTools.e2e.test.ts | 7 ++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/extension/src/test-e2e/helpers/languageModelTools.ts b/extension/src/test-e2e/helpers/languageModelTools.ts index 700afe8197e..bf96c982498 100644 --- a/extension/src/test-e2e/helpers/languageModelTools.ts +++ b/extension/src/test-e2e/helpers/languageModelTools.ts @@ -56,11 +56,45 @@ export async function invokeLanguageModelTool( invocation.catch(() => undefined); const dialogs: AcceptedModalDialog[] = []; + let invocationSettled = false; + void invocation.finally(() => invocationSettled = true).catch(() => undefined); for (let index = 0; index < expectedConfirmations; index++) { - dialogs.push(await acceptModalDialog( - options.confirmationButtonTitle ?? 'Yes', - 180000, - index === 0 ? options.screenshotName : undefined)); + const buttonTitle = options.confirmationButtonTitle ?? 'Yes'; + const screenshotName = index === 0 ? options.screenshotName : undefined; + if (options.cancelAfterMs === undefined) { + dialogs.push(await acceptModalDialog(buttonTitle, 180000, screenshotName)); + continue; + } + + // Cancellation can win before VS Code creates the confirmation dialog, or it can leave + // an already-open dialog waiting for acknowledgement. Probe while the invocation is + // pending so either ordering completes without leaving a modal for the next test. + const deadline = Date.now() + 180000; + let confirmationAccepted = false; + while (!invocationSettled) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error(`Timed out waiting for the cancelled language-model invocation or a '${buttonTitle}' confirmation.`); + } + + try { + dialogs.push(await acceptModalDialog(buttonTitle, Math.min(1000, remainingMs), screenshotName)); + confirmationAccepted = true; + break; + } + catch { + // A confirmation is optional once the invocation has observed cancellation. + } + } + + if (!confirmationAccepted && invocationSettled) { + try { + dialogs.push(await acceptModalDialog(buttonTitle, 1000, screenshotName)); + } + catch { + // The invocation completed before VS Code created a confirmation. + } + } } const result = await invocation; diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index 5c9e7838be3..3ef5c0beb8d 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -203,6 +203,13 @@ suite('Aspire resource debug language model tool E2E', function () { { cancelAfterMs: 0, expectedConfirmations: 1 }); assert.strictEqual(cancelled.cancelled, true); assert.deepStrictEqual(cancelled.results, []); + assert.ok(cancelled.dialogs.length <= 1); + if (cancelled.dialogs.length === 1) { + assert.deepStrictEqual(cancelled.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + } await executeE2eControlCommand({ name: 'stopResource', appHostPath, resourceName: worker.name }); await waitForResourceState(worker.name, ['Exited', 'Finished', 'Stopped'], 90000); From c761ec31d4a63018dc5552503fa65ced2468397b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 19 Aug 2026 03:15:30 -0400 Subject: [PATCH 79/90] Forward already-redacted runAspireCli diagnostics through the E2E bridge The merge with main produced a semantic conflict that git resolved without a textual one. This branch tightened getE2eErrorMessage so control-command failures only surface verbatim when their message carries the "Aspire extension E2E " marker, which keeps user-supplied values (such as the resource URL in the edgeCases spec) out of the E2E state file. Main independently added runAspireCliForE2E, which redacts at the source: diagnosticCommand is built from redactCliArgsForLogging and neither the timeout nor the nonzero-exit message embeds captured stdout/stderr. Those messages were nonetheless collapsed to the generic string, so main's two new linked-worktree specs saw "E2E control command failed." instead of the timeout and exit-code diagnostics they assert on. Tag both self-redacted diagnostics with the marker the bridge already uses for its own messages. The spawn errorCallback path is deliberately left unmarked because it forwards a raw error that can embed a path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- extension/src/testing/e2eStateFileBridge.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 2bb4e4351bf..5c1adf96536 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -1485,9 +1485,13 @@ async function runAspireCliForE2E( } completed = true; + // `getE2eErrorMessage` only forwards messages that carry the "Aspire extension E2E " marker; + // everything else is collapsed to a generic string so failures cannot leak user-supplied + // values. These two diagnostics are safe to forward because `diagnosticCommand` has already + // been redacted by `redactCliArgsForLogging` and neither embeds captured stdout/stderr. void terminateCliProcess(child, 'Aspire extension E2E CLI command', { force: true, suppressTimeoutWarning: true }) .then( - () => reject(new Error(`${diagnosticCommand} timed out after ${timeoutMs}ms.`)), + () => reject(new Error(`Aspire extension E2E ${diagnosticCommand} timed out after ${timeoutMs}ms.`)), reject); }, timeoutMs); @@ -1506,7 +1510,7 @@ async function runAspireCliForE2E( if (code === 0) { resolve(result); } else { - reject(new Error(`${diagnosticCommand} exited with code ${code}.`)); + reject(new Error(`Aspire extension E2E ${diagnosticCommand} exited with code ${code}.`)); } }, errorCallback: error => { From ba603c1009d4e12e80759c323595d366dd2ccc35 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 31 Aug 2026 22:34:09 -0500 Subject: [PATCH 80/90] Address resource debugger review findings Bind snapshots to the selected AppHost process, use kernel-backed macOS process identity, and add packaged .NET and Go attach coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/extension-e2e-tests.yml | 39 +++ extension/CHANGELOG.md | 5 +- extension/loc/xlf/aspire-vscode.xlf | 66 ++++ extension/scripts/run-e2e.js | 116 ++++++- extension/src/data/AppHostDataRepository.ts | 27 +- extension/src/debugger/AspireDebugSession.ts | 23 +- extension/src/debugger/adapterTracker.ts | 18 +- extension/src/debugger/languages/dotnet.ts | 4 +- extension/src/debugger/languages/go.ts | 12 +- .../debugger/launchedChildProcessDiscovery.ts | 36 ++ .../src/debugger/resourceDebugService.ts | 13 +- .../src/lm/appHostLifecycleToolAdapters.ts | 15 +- .../src/lm/appHostTargetResolverService.ts | 5 +- extension/src/lm/resourceDebugToolAdapters.ts | 7 + .../test-e2e/resourceDebugTools.e2e.test.ts | 152 +++++++-- extension/src/test/adapterTracker.test.ts | 40 +++ .../src/test/appHostDataRepository.test.ts | 26 +- .../src/test/appHostLifecycleTools.test.ts | 18 +- extension/src/test/appHostTreeView.test.ts | 18 +- extension/src/test/aspireDebugSession.test.ts | 6 + extension/src/test/dotnetDebugger.test.ts | 38 +++ .../test/e2eAddWorkspaceFolderGuard.test.ts | 7 + extension/src/test/e2eStateFileBridge.test.ts | 14 +- extension/src/test/goProcessDiscovery.test.ts | 14 +- .../launchedChildProcessDiscovery.test.ts | 25 +- .../src/test/resourceDebugService.test.ts | 40 ++- extension/src/test/resourceDebugTools.test.ts | 3 + .../src/test/testRunSessionManager.test.ts | 16 +- extension/src/testing/e2eStateFileBridge.ts | 307 +++++++++++++++++- extension/src/types/configInfo.ts | 7 + extension/src/types/extensionApi.ts | 11 + .../src/views/AspireAppHostTreeProvider.ts | 2 +- .../Backchannel/AppHostConnectionResolver.cs | 15 +- src/Aspire.Cli/Commands/DescribeCommand.cs | 23 +- src/Aspire.Cli/Utils/AppHostHelper.cs | 11 +- src/Aspire.Cli/Utils/ExtensionHelper.cs | 5 +- .../Dcp/ResourceSnapshotBuilder.cs | 43 ++- src/Shared/Model/KnownProperties.cs | 1 - .../Commands/ConfigCommandTests.cs | 6 + .../Commands/DescribeCommandTests.cs | 18 + .../Utils/AppHostHelperTests.cs | 33 ++ .../Dcp/ResourceSnapshotBuilderTests.cs | 28 ++ 42 files changed, 1185 insertions(+), 128 deletions(-) diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 180c7c51e43..c9aae35a743 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -274,6 +274,7 @@ jobs: archivePattern: aspire-cli-linux-x64*.tar.gz cliBinary: aspire useXvfb: true + installResourceDebug: true - name: Linux shardName: java-apphost spec: out/test-e2e/test-e2e/javaAppHost.e2e.test.js @@ -543,6 +544,44 @@ jobs: echo 'FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1' } >> "$GITHUB_ENV" + - name: Install resource debug E2E prerequisites + if: ${{ matrix.installResourceDebug }} + shell: bash + run: | + set -euo pipefail + + debugger_bin="$RUNNER_TEMP/resource-debug-bin" + mkdir -p "$debugger_bin" + GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.25.2 + echo "$debugger_bin" >> "$GITHUB_PATH" + export PATH="$debugger_bin:$PATH" + dlv version + + dotnet_runtime_vsix="$RUNNER_TEMP/vscode-dotnet-runtime-3.1.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$dotnet_runtime_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dotnettools/vsextensions/vscode-dotnet-runtime/3.1.0/vspackage' + echo '8e675ffe5f3674430d63e28d2dc05ab40f36c8494e9549e79d3995d721b13f5a '"$dotnet_runtime_vsix" | sha256sum --check - + + csharp_vsix="$RUNNER_TEMP/vscode-csharp-2.148.23-linux-x64.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$csharp_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dotnettools/vsextensions/csharp/2.148.23/vspackage?targetPlatform=linux-x64' + echo '18b503e614a979212762683b35a4fa1806688ba773d5fe93bf62c9f9346db23f '"$csharp_vsix" | sha256sum --check - + + go_vsix="$RUNNER_TEMP/vscode-go-0.56.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$go_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/golang/vsextensions/Go/0.56.0/vspackage' + echo '9f5959fb17ba0a8dbd804387ddda50975fcaa9dd5267aa33eaaa89912072aacb '"$go_vsix" | sha256sum --check - + + { + echo 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG=true' + echo "ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX=$dotnet_runtime_vsix" + echo "ASPIRE_EXTENSION_E2E_CSHARP_VSIX=$csharp_vsix" + echo "ASPIRE_EXTENSION_E2E_GO_VSIX=$go_vsix" + } >> "$GITHUB_ENV" + - name: Set up the JDK for the Java E2E specs if: ${{ matrix.installJava }} uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 90181a18d0a..1753c5e2605 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -4,6 +4,10 @@ +### Features + +- Add an **Attach debugger** action for running .NET and Go resources in the Aspire pane, including installation guidance when the required C# or Go debugger extension is missing ([#18602](https://github.com/microsoft/aspire/pull/18602)). + ### Fixes - Prevent the Aspire view from stealing sidebar focus and reappearing in the Activity Bar when the window is reloaded ([#19746](https://github.com/microsoft/aspire/issues/19746), [#19754](https://github.com/microsoft/aspire/pull/19754)). @@ -84,7 +88,6 @@ ### Features -- Add an Attach debugger action for running .NET project resources in the Aspire pane when the C# extension is installed ([#18602](https://github.com/microsoft/aspire/pull/18602)). - Flatten single-AppHost group nodes in the AppHosts tree view so a lone running or idle AppHost is surfaced directly at the top level instead of under a redundant `(1)` wrapper ([#18420](https://github.com/microsoft/aspire/issues/18420), [#18523](https://github.com/microsoft/aspire/pull/18523)). - Update the Marketplace page with focused AppHost-view, debug-session, and dashboard screenshots, and add AppHost telemetry signals for discovery, launch, and running-state metrics; all events respect `telemetry.telemetryLevel` ([#17898](https://github.com/microsoft/aspire/pull/17898)). diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index eb20267163b..3d3396096be 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ A debug session is already active for id {0}. + + A debugger is already attached to {0}. + Add Aspire to this workspace @@ -85,18 +88,54 @@ Aspire terminal + + Aspire terminal closed before its process started. + Aspire terminal command arguments cannot contain control characters. Aspire terminal command syntax can only contain command names and flags. + + Aspire terminal process failed to start. + Aspire: Launch default AppHost Aspire: Launch default AppHost ({0}: {1}) + + Attach debugger + + + Attach debugger to Aspire resource + + + Attach debugger: {0} + + + Attach the VS Code debugger to a running Aspire resource that the extension has already discovered. Requires a workspace-relative AppHost path and the resource name. The default auto strategy currently attaches to the resource; start and restart under debug are not supported. + + + Attach the debugger to a running Aspire resource. + + + Attach the debugger to resource {0} from Aspire AppHost {1}? + + + Attach the debugger to the requested Aspire resource? + + + Attaching debugger to Aspire resource {0}... + + + Attaching debugger to the requested Aspire resource... + + + Attaching debugger to {0}... + Attempted to start unsupported resource type: {0}. @@ -250,12 +289,18 @@ Debug Aspire pipeline step + + Debug Aspire resource + Debug pipeline step Debug pipeline step + + Debug strategy. auto selects the available safe action, currently attach. attach only attaches a debugger; starting and restarting resources are not supported. + Debug with Chrome @@ -442,6 +487,12 @@ Install the Aspire CLI + + Install the C# extension to attach the debugger to .NET project resources. + + + Install {0} to attach the debugger to this resource. + Invalid launch configuration for {0}. @@ -532,6 +583,9 @@ Multiple AppHosts were found. Select the one to launch + + Name of a running resource from the selected AppHost. Resource names are limited to 256 characters. + New Aspire project @@ -970,6 +1024,9 @@ The selected Aspire CLI launch profile capability could not be verified. + + The selected resource is no longer available. Refresh the Aspire pane and try again. + This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs. @@ -982,6 +1039,9 @@ This field is required. + + This resource cannot be attached to a debugger. + This setting has been renamed to aspire.appHostsPollingInterval. @@ -1033,6 +1093,9 @@ VS Code did not start the Aspire {0} session for {1}. + + VS Code did not start the debugger attach session for {0}. + Value missing @@ -1087,6 +1150,9 @@ Workspace-relative path of an AppHost that Aspire has already discovered in this workspace, for example 'AppHost/AppHost.csproj' or 'apphost.cs'. The value must match one of the discovered AppHosts exactly; arbitrary paths, absolute paths, and files Aspire did not discover are rejected. In a multi-root workspace, always prefix the path with the workspace folder name (for example 'backend/AppHost/AppHost.csproj'). + + Workspace-relative path of an AppHost that Aspire has already discovered. Absolute paths and paths Aspire did not discover are rejected. In a multi-root workspace, prefix the path with the workspace folder name. + Yes diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 20195ac1c45..087706f005a 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -53,6 +53,11 @@ const enableJavaE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_JAVA // dependency of it. capabilities.ts only advertises the `java` capability - which is what makes the // CLI hand the AppHost launch back to the extension - when the first two are both installed. const REQUIRED_JAVA_EXTENSION_IDS = ['redhat.java', 'vscjava.vscode-java-debug', 'vscjava.vscode-java-dependency']; +const REQUIRED_RESOURCE_DEBUG_EXTENSION_IDS = [ + 'ms-dotnettools.vscode-dotnet-runtime', + 'ms-dotnettools.csharp', + 'golang.go', +]; const extesterVersion = extensionPackageJson.devDependencies?.['vscode-extension-tester']; if (!extesterVersion) { throw new Error('vscode-extension-tester must be pinned in extension/package.json devDependencies.'); @@ -127,6 +132,7 @@ const primaryAppHostProject = path.join(workspaceRoot, 'AspireE2E.AppHost', 'Asp const runRootNuGetConfigPath = path.join(shortRunRoot, 'NuGet.config'); const workspaceNuGetConfigPath = path.join(workspaceRoot, 'NuGet.config'); const enableAzureFunctionsE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS === 'true'; +const enableResourceDebugE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG === 'true'; const advisoryIssue = process.env.ASPIRE_EXTENSION_E2E_ADVISORY_ISSUE || ''; let cliPathForCleanup; const csharpFileHeader = `// Licensed to the .NET Foundation under one or more agreements. @@ -625,9 +631,13 @@ async function main() { } validateVsix(vsixPath); const azureFunctionsVsixPaths = resolveAzureFunctionsVsixPaths(); + const resourceDebugVsixPaths = resolveResourceDebugVsixPaths(); if (enableAzureFunctionsE2E) { validateAzureFunctionsCoreTools(); } + if (enableResourceDebugE2E) { + validateResourceDebugTools(); + } ensureExtester(); patchExtesterLaunchLocale(); @@ -653,6 +663,7 @@ async function main() { ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION: appHostSdkVersion, ASPIRE_EXTENSION_E2E_EXTESTER_MODULE: extesterModule, ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS: enableAzureFunctionsE2E ? 'true' : 'false', + ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG: enableResourceDebugE2E ? 'true' : 'false', VSCODE_NLS_CONFIG: JSON.stringify({ locale: 'en', availableLanguages: {} }), LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', @@ -697,11 +708,12 @@ async function main() { logStep('Installing VSIX'); run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', vsixPath], extestEnv, { timeout: 300000 }); - for (const azureFunctionsVsix of azureFunctionsVsixPaths) { - logStep(`Installing ${azureFunctionsVsix.displayName} VSIX`); - run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', azureFunctionsVsix.path], extestEnv, { timeout: 300000 }); + for (const dependencyVsix of [...azureFunctionsVsixPaths, ...resourceDebugVsixPaths]) { + logStep(`Installing ${dependencyVsix.displayName} VSIX`); + run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', dependencyVsix.path], extestEnv, { timeout: 300000 }); } assertJavaExtensionsRegistered(); + assertResourceDebugExtensionsRegistered(); recording = startRecording(); try { @@ -876,6 +888,42 @@ function resolveAzureFunctionsVsixPaths() { ]; } +function resolveResourceDebugVsixPaths() { + if (!enableResourceDebugE2E) { + return []; + } + + // The Extension Host runs offline. Install both debugger adapters and C#'s runtime dependency + // explicitly so this shard proves packaged attach behavior rather than a developer profile. + return [ + { + displayName: '.NET Install Tool', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX'), + }, + { + displayName: 'C#', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX'), + }, + { + displayName: 'Go', + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_GO_VSIX'), + }, + ]; +} + +function validateResourceDebugTools() { + const result = spawnSync('dlv', ['version'], { + cwd: extensionRoot, + env: getAspireCliEnvironment(), + shell: false, + encoding: 'utf8', + timeout: 60000, + }); + if (result.error || result.status !== 0) { + throw new Error(`The resource debug E2E shard requires dlv on PATH. ${result.error?.message ?? result.stderr ?? `exit code ${result.status}`}`); + } +} + /** * Copies the Java Spring Boot playground into the run's workspace. * @@ -1132,6 +1180,18 @@ function assertJavaExtensionsRegistered() { return; } + assertExtensionsRegistered('Java', REQUIRED_JAVA_EXTENSION_IDS); +} + +function assertResourceDebugExtensionsRegistered() { + if (!enableResourceDebugE2E) { + return; + } + + assertExtensionsRegistered('Resource debug', REQUIRED_RESOURCE_DEBUG_EXTENSION_IDS); +} + +function assertExtensionsRegistered(label, requiredExtensionIds) { const manifestPath = path.join(extensionsDir, 'extensions.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`VS Code did not write ${manifestPath}, so no extension is registered for the run.`); @@ -1149,7 +1209,7 @@ function assertJavaExtensionsRegistered() { .map(entry => entry?.identifier?.id) .filter(Boolean); - for (const identifier of REQUIRED_JAVA_EXTENSION_IDS) { + for (const identifier of requiredExtensionIds) { const entry = registered.find(candidate => candidate?.identifier?.id?.toLowerCase() === identifier.toLowerCase()); if (!entry) { throw new Error(`${identifier} is not registered in ${manifestPath}, so VS Code will not load it. Registered: ${registeredIds.join(', ') || '(none)'}`); @@ -1158,7 +1218,7 @@ function assertJavaExtensionsRegistered() { assertExtensionSupportsVsCodeVersion(path.join(extensionsDir, entry.relativeLocation), entry.relativeLocation); } - console.log(`Java extensions registered for the run: ${REQUIRED_JAVA_EXTENSION_IDS.join(', ')}.`); + console.log(`${label} extensions registered for the run: ${requiredExtensionIds.join(', ')}.`); } /** @@ -1314,10 +1374,13 @@ function prepareWorkspaceFixture(resolvedCliPath, resolvedAppHostSdkVersion) { fs.mkdirSync(workspaceRoot, { recursive: true }); fs.writeFileSync(workspaceMarkerFile, `${runId}\n`); writeWorkerProject('AspireE2E.Worker'); + if (enableResourceDebugE2E) { + writeGoWorker('AspireE2E.Go'); + } if (enableAzureFunctionsE2E) { writeAzureFunctionsProject('AspireE2E.Functions'); } - writeAppHostProject('AspireE2E.AppHost', resolvedAppHostSdkVersion, enableAzureFunctionsE2E); + writeAppHostProject('AspireE2E.AppHost', resolvedAppHostSdkVersion, enableAzureFunctionsE2E, enableResourceDebugE2E); writeNuGetConfigIfLocalPackageSourcesExist(); const vscodeDirectory = path.join(workspaceRoot, '.vscode'); @@ -1370,12 +1433,15 @@ function restoreWorkspaceFixture() { } } -function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzureFunctions) { +function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzureFunctions, includeResourceDebug) { const projectDirectory = path.join(workspaceRoot, projectName); fs.mkdirSync(projectDirectory, { recursive: true }); const azureFunctionsPackageReference = includeAzureFunctions ? ` \n` : ''; + const goPackageReference = includeResourceDebug + ? ` \n` + : ''; fs.writeFileSync(path.join(projectDirectory, `${projectName}.csproj`), ` @@ -1387,7 +1453,7 @@ function writeAppHostProject(projectName, resolvedAppHostSdkVersion, includeAzur -${azureFunctionsPackageReference} +${azureFunctionsPackageReference}${goPackageReference} `); @@ -1395,6 +1461,12 @@ ${azureFunctionsPackageReference} const azureFunctionsResource = includeAzureFunctions ? `builder.AddAzureFunctionsProject("e2e-functions", "../AspireE2E.Functions/AspireE2E.Functions.csproj");\n\n` : ''; + const goResource = includeResourceDebug + ? `builder.AddGoApp("e2e-go", "../AspireE2E.Go") + .WithHttpEndpoint(name: "http", env: "PORT"); + +` + : ''; fs.writeFileSync(path.join(projectDirectory, 'AppHost.cs'), `${csharpFileHeader}#pragma warning disable ASPIREINTERACTION001 #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIRETERMINAL001 @@ -1477,7 +1549,7 @@ builder.AddProject("e2e-terminal") .WithHttpEndpoint(name: "http") .WithTerminal(); -${azureFunctionsResource}builder.Pipeline.AddStep("e2e-run-action-step", async context => +${azureFunctionsResource}${goResource}builder.Pipeline.AddStep("e2e-run-action-step", async context => { var task = await context.ReportingStep .CreateTaskAsync("Running E2E run action pipeline step", context.CancellationToken) @@ -1610,6 +1682,32 @@ app.Run(); `); } +function writeGoWorker(projectName) { + const projectDirectory = path.join(workspaceRoot, projectName); + fs.mkdirSync(projectDirectory, { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'go.mod'), `module example.com/aspire-e2e-go + +go 1.24 +`); + fs.writeFileSync(path.join(projectDirectory, 'main.go'), `package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + http.HandleFunc("/", func(writer http.ResponseWriter, _ *http.Request) { + message := "go-ok" + _, _ = fmt.Fprint(writer, message) + }) + log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil)) +} +`); +} + function resolveAppHostSdkVersion(resolvedCliPath) { if (process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION) { return process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION; diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 8428c046738..20842a7120b 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -7,7 +7,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { appHostDescribeMayNotBeSupported, appHostDiscoveryProgress, appHostPathMustBeNonEmptyAbsolute, aspireCliDescribeNotSupported, aspireDescribeMinimumVersion, errorFetchingAppHosts, workspaceViewSelectedMultipleAppHosts, workspaceViewSelectedSingleAppHost } from '../loc/strings'; import { AppHostCandidate, AppHostDiscoveryService, CandidateAppHostDisplayInfo, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult, isBuildableAppHostCandidate } from '../utils/appHostDiscovery'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; -import { describeIncludeDisabledCommandsCapability } from '../types/configInfo'; +import { describeAppHostPidCapability, describeIncludeDisabledCommandsCapability } from '../types/configInfo'; import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getComparisonKey, isAppHostPathUnderFolder, isSameAppHostPath } from '../utils/paths/comparison'; import { FileSystemEntryDescriptor, FileSystemEntryDescriptorIndex, getFileSystemEntryDescriptor } from '../utils/paths/fileSystemIdentity'; @@ -1325,11 +1325,30 @@ export class AppHostDataRepository { } } - async fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken): Promise { + async fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken, appHostPid?: number): Promise { + const args = ['describe', '--format', 'json', '--apphost', appHostPath]; + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); + if (appHostPid !== undefined) { + if (!Number.isInteger(appHostPid) || appHostPid <= 0) { + throw new Error('The AppHost process ID must be a positive integer.'); + } + + const capability = await this._configInfoProvider.getCapabilityStatus(describeAppHostPidCapability, { + target, + cancellationToken, + suppressErrors: true, + }); + if (capability !== 'supported') { + throw new Error('The selected Aspire CLI cannot bind resource snapshots to an AppHost process.'); + } + + args.push('--apphost-pid', String(appHostPid)); + } + const snapshot = await this._runCliJson( 'aspire describe', - this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath]), - { cancellationToken, target: getCliPathTargetForUri(vscode.Uri.file(appHostPath)) }); + this._cliRunner.withNoLogo(args), + { cancellationToken, target }); return snapshot.resources ?? []; } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 35c53521d22..02b9303a376 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -165,6 +165,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche private _appHostDebugSession?: AspireResourceDebugSession = undefined; private _resourceDebugSessions: AspireResourceDebugSession[] = []; + private readonly _resourceDebugSessionProcessIds = new Map(); private _trackedDebugAdapters: string[] = []; private _rpcClient?: ICliRpcClient; private readonly _dashboardLauncher = new DashboardLauncher(this); @@ -291,8 +292,9 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // Already-started debugger integrations report the actual debuggee PID back to DCP. // Resource attach must recognize that PID as editor-owned rather than treating it as a launcher. hasResourceDebugSessionProcess(processId: number): boolean { - return this._resourceDebugSessions.some( - session => (session as Partial).processId === processId); + return [...this._resourceDebugSessionProcessIds.values()].includes(processId) || + this._resourceDebugSessions.some( + session => (session as Partial).processId === processId); } constructor(session: vscode.DebugSession, rpcServer: AspireRpcServer, dcpServer: AspireDcpServer, terminalProvider: AspireTerminalProvider, removeAspireDebugSession: (session: AspireDebugSession) => void, trackAppHostDebugSession: AppHostDebugSessionTracker = () => { }, debugSessionId: string = generateDcpIdPrefix(), operationKind?: AspireOperationKind) { @@ -306,6 +308,8 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this.operationKind = operationKind ?? getOperationKind(this.configuration.command); this.debugSessionId = debugSessionId; + this._disposables.push(vscode.debug.onDidTerminateDebugSession( + terminatedSession => this._resourceDebugSessionProcessIds.delete(terminatedSession.id))); } /** @@ -1233,7 +1237,17 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } this._trackedDebugAdapters.push(debugAdapter); - this._disposables.push(createDebugAdapterTracker(this._dcpServer, debugAdapter, appHostTracker)); + this._disposables.push(createDebugAdapterTracker( + this._dcpServer, + debugAdapter, + appHostTracker, + (session, processId) => { + if (processId === undefined) { + this._resourceDebugSessionProcessIds.delete(session.id); + } else { + this._resourceDebugSessionProcessIds.set(session.id, processId); + } + })); } private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']; @@ -1510,6 +1524,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } void resourceDebugSession.termination.then(exitCode => { + this._resourceDebugSessions = this._resourceDebugSessions.filter(session => session !== resourceDebugSession); if (debugConfig.debugSessionId === null) { extensionLogOutputChannel.warn(`Unable to report termination for run ${debugConfig.runId} because the DCP session ID is missing.`); return; @@ -1574,6 +1589,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // stop that is still waiting for VS Code to confirm the same termination. terminated = true; this._resourceDebugSessions = this._resourceDebugSessions.filter(resourceSession => resourceSession.id !== session.id); + this._resourceDebugSessionProcessIds.delete(session.id); cleanupResource(); resolveTermination(); terminationDisposable.dispose(); @@ -1813,6 +1829,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this.flushAppHostLogOutput(); this._appHostLogOutput.reset(); this._trackedDebugAdapters = []; + this._resourceDebugSessionProcessIds.clear(); this._onDidSendDebugConsoleOutput.dispose(); // Keep this disposed session tracked while its delayed CLI termination is pending, so // extension deactivation can still force-drain the process tree before VS Code exits. diff --git a/extension/src/debugger/adapterTracker.ts b/extension/src/debugger/adapterTracker.ts index 19f25f894e8..4e6f716636d 100644 --- a/extension/src/debugger/adapterTracker.ts +++ b/extension/src/debugger/adapterTracker.ts @@ -19,6 +19,7 @@ export type AppHostRestartHandler = (debugSessionId: string) => boolean; */ export type DapOutputCategory = 'console' | 'important' | 'stdout' | 'stderr' | 'debug' | 'telemetry' | (string & {}) | undefined; export type AppHostOutputHandler = (output: string, category: DapOutputCategory) => void; +export type DebuggeeProcessHandler = (session: vscode.DebugSession, processId: number | undefined) => void; export interface AppHostTrackerOptions { // VS Code invokes every factory registered for an adapter type for every matching @@ -29,7 +30,12 @@ export interface AppHostTrackerOptions { onOutput?: AppHostOutputHandler; } -export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapter: string, appHostTracker?: AppHostTrackerOptions): vscode.Disposable { +export function createDebugAdapterTracker( + dcpServer: AspireDcpServer, + debugAdapter: string, + appHostTracker?: AppHostTrackerOptions, + onDebuggeeProcess?: DebuggeeProcessHandler, +): vscode.Disposable { return vscode.debug.registerDebugAdapterTrackerFactory(debugAdapter, { createDebugAdapterTracker(session: vscode.DebugSession) { const configuration = session.configuration; @@ -96,6 +102,13 @@ export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapt // Reset before the PID guard: `systemProcessId` is optional in DAP, so a // restart reported without it must still clear the stale exit code. debuggeeExitCode = undefined; + if (!configuration.isApphost) { + onDebuggeeProcess?.( + session, + typeof message.body?.systemProcessId === 'number' + ? message.body.systemProcessId + : undefined); + } if (typeof message.body?.systemProcessId !== 'number') { extensionLogOutputChannel.warn(`Debug session ${session.id} does not have a valid system process ID.`); @@ -117,6 +130,9 @@ export function createDebugAdapterTracker(dcpServer: AspireDcpServer, debugAdapt } if (message.type === 'event' && message.event === 'exited' && typeof message.body?.exitCode === 'number') { + if (!configuration.isApphost) { + onDebuggeeProcess?.(session, undefined); + } debuggeeExitCode = message.body.exitCode; } }, diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index 2217846ee2f..961cb0d4213 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -1081,8 +1081,8 @@ function isDotNetProcess(process: LaunchedChildProcess): boolean { return executableName === 'dotnet' || executableName === 'dotnet.exe'; } -function isAppHostProcessForTarget(process: LaunchedChildProcess, appHostPaths: readonly string[]): boolean { - return appHostPaths.some(appHostPath => areProcessPathsEqual(process.executable, appHostPath)); +function isAppHostProcessForTarget(candidate: LaunchedChildProcess, appHostPaths: readonly string[]): boolean { + return appHostPaths.some(appHostPath => areProcessPathsEqual(candidate.executable, appHostPath)); } function isAppHostProcessForTargetName(process: LaunchedChildProcess, targetName: string): boolean { diff --git a/extension/src/debugger/languages/go.ts b/extension/src/debugger/languages/go.ts index 01913f93276..b02ad614e34 100644 --- a/extension/src/debugger/languages/go.ts +++ b/extension/src/debugger/languages/go.ts @@ -5,7 +5,6 @@ import { extensionLogOutputChannel } from "../../utils/logging"; import { ResourceDebuggerExtension } from "../debuggerExtensions"; import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugResourceSnapshot } from '../resourceDebugContracts'; import { - getProcessCommandProgram, launchedChildProcessResolver, type LaunchedChildProcess, type LaunchedChildProcessIdentity, @@ -193,17 +192,12 @@ function getProcessId(resource: ResourceDebugResourceSnapshot): number | undefin } function isGoBuildApplication(process: LaunchedChildProcess): boolean { - return isGoRunApplicationPath(process.executable) || - isGoRunApplicationPath(getProcessCommandProgram(process.command)); + return isGoRunApplicationPath(process.executable); } function isGoToolProcess(process: LaunchedChildProcess): boolean { - const programs = [getProcessCommandProgram(process.command), process.executable]; - return programs.some(program => { - const executableName = program?.split(/[\\/]/).pop()?.toLowerCase(); - return executableName === 'go' || executableName === 'go.exe'; - }) || - /(?:^|[\\/\s])go(?:\.exe)?\s+run(?:\s|$)/i.test(process.command); + const executableName = process.executable.split(/[\\/]/).pop()?.toLowerCase(); + return executableName === 'go' || executableName === 'go.exe'; } function isGoRunApplicationPath(path: string | undefined): boolean { diff --git a/extension/src/debugger/launchedChildProcessDiscovery.ts b/extension/src/debugger/launchedChildProcessDiscovery.ts index 20b1db5550d..fd4700add48 100644 --- a/extension/src/debugger/launchedChildProcessDiscovery.ts +++ b/extension/src/debugger/launchedChildProcessDiscovery.ts @@ -393,6 +393,24 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer return this._getLinuxProcess(processId, cancellationToken, timeoutMs); } + if (this._platform === 'darwin') { + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), + this._commandRunner.run('lsof', ['-a', '-p', String(processId), '-d', 'txt', '-Fn'], cancellationToken, timeoutMs), + this._commandRunner.run('ps', ['-p', String(processId), '-o', 'args='], cancellationToken, timeoutMs), + ]); + const executablePath = parseMacOsTextExecutablePath(executableOutput, processId); + if (executablePath === undefined) { + return undefined; + } + + return createProcessInfo( + processId, + parentPidOutput.trim(), + executablePath, + commandOutput.trim()); + } + const [parentPidOutput, executableOutput, commandOutput] = await Promise.all([ this._commandRunner.run('ps', ['-p', String(processId), '-o', 'ppid='], cancellationToken, timeoutMs), this._commandRunner.run('ps', ['-p', String(processId), '-o', 'comm='], cancellationToken, timeoutMs), @@ -436,6 +454,24 @@ export class SystemLaunchedChildProcessQuery implements LaunchedChildProcessQuer } } +export function parseMacOsTextExecutablePath(output: string, processId: number): string | undefined { + // `lsof -a -p 123 -d txt -Fn` reports the kernel-backed text executable as: + // p123 + // ftxt + // n/Applications/My Long App.app/Contents/MacOS/My Long App + // Require the requested PID record and the `txt` file descriptor before accepting its name. + const lines = output.split(/\r?\n/); + if (!lines.includes(`p${processId}`)) { + return undefined; + } + + const textDescriptorIndex = lines.indexOf('ftxt'); + const executableLine = textDescriptorIndex >= 0 ? lines[textDescriptorIndex + 1] : undefined; + return executableLine?.startsWith('n') && executableLine.length > 1 + ? executableLine.slice(1) + : undefined; +} + export class SystemLaunchedChildProcessCommandRunner implements LaunchedChildProcessCommandRunner { constructor( private readonly _spawn: LaunchedChildProcessSpawner = diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 9dd2d1929dd..575173d8aca 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -30,7 +30,7 @@ import { export interface ResourceDebugAppHostRepository { fetchRunningAppHostsOnce(cancellationToken?: vscode.CancellationToken): Promise; - fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken): Promise; + fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken, appHostPid?: number): Promise; } export type ResourceDebugAppHostIdentityComparer = @@ -192,7 +192,8 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger try { resources = await this._dependencies.appHostRepository.fetchAppHostResourcesOnce( resolvedTarget.absolutePath, - request.cancellationToken); + request.cancellationToken, + resolvedTarget.appHostPid); } catch (error) { if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { @@ -306,6 +307,14 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger return { outcome: 'error', errorKind: 'configurationFailed' }; } + const attachProcessId = configuration.processId; + if (typeof attachProcessId === 'number' && + Number.isInteger(attachProcessId) && + attachProcessId > 0 && + this._dependencies.isProcessAlreadyDebugged?.(attachProcessId)) { + return { outcome: 'alreadyDebugging' }; + } + if (request.cancellationToken?.isCancellationRequested) { return { outcome: 'cancelled' }; } diff --git a/extension/src/lm/appHostLifecycleToolAdapters.ts b/extension/src/lm/appHostLifecycleToolAdapters.ts index ab92f700249..f7f762f7f69 100644 --- a/extension/src/lm/appHostLifecycleToolAdapters.ts +++ b/extension/src/lm/appHostLifecycleToolAdapters.ts @@ -144,18 +144,5 @@ function describeLaunchProfile(value: unknown): string | undefined { return undefined; } - return isValidLaunchProfile(value) ? escapeMarkdown(value) : appHostLifecycleInvalidLaunchProfile; + return isValidLaunchProfile(value) ? escapeMarkdownForConfirmation(value) : appHostLifecycleInvalidLaunchProfile; } - -/** - * Escapes the Markdown constructs that change how a path renders inline. - * - * The confirmation body renders as Markdown, so an unescaped `*`, `_`, `` ` ``, `[`, or - * `<` in a real file name would show the user something other than the file the tool is - * about to launch. Escaping keeps the rendered text one-to-one with the path instead of - * deleting characters, which would break that relationship in the other direction. - * Characters that are only meaningful at the start of a line (`.`, `-`, `{`, `}`) are - * left alone: the path is always interpolated mid-sentence and they are extremely common - * in real project paths. - * See https://spec.commonmark.org/0.31.2/#backslash-escapes - */ diff --git a/extension/src/lm/appHostTargetResolverService.ts b/extension/src/lm/appHostTargetResolverService.ts index 4e4678a6877..b15b467ede2 100644 --- a/extension/src/lm/appHostTargetResolverService.ts +++ b/extension/src/lm/appHostTargetResolverService.ts @@ -237,7 +237,10 @@ function describeKnownAppHosts(targets: readonly AppHostTarget[]): readonly stri */ function toContainedPosixRelativePath(folderPath: string, candidate: string): string | undefined { const relative = path.relative(folderPath, candidate); - if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) { + if (relative.length === 0 || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative)) { return undefined; } diff --git a/extension/src/lm/resourceDebugToolAdapters.ts b/extension/src/lm/resourceDebugToolAdapters.ts index f0bd48c73f5..ea8cc7f293b 100644 --- a/extension/src/lm/resourceDebugToolAdapters.ts +++ b/extension/src/lm/resourceDebugToolAdapters.ts @@ -65,6 +65,13 @@ export function registerAspireResourceDebugTool(service: AspireResourceDebugTool [aspireResourceDebugToolName, { prepareInvocation: (options: { readonly input: Record }, token: vscode.CancellationToken) => tool.prepareInvocation({ input: options.input as unknown as AspireResourceDebugToolInput }, token), + invoke: ( + options: { readonly input: Record; readonly toolInvocationToken: undefined }, + token: vscode.CancellationToken, + ) => tool.invoke({ + input: options.input as unknown as AspireResourceDebugToolInput, + toolInvocationToken: options.toolInvocationToken, + }, token), }], ]); diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index 3ef5c0beb8d..d9a82fc6543 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -1,6 +1,7 @@ import * as assert from 'assert'; +import * as fs from 'fs'; import * as path from 'path'; -import { findResource, waitForCommandOutcome, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResourceState, waitForWorkspaceAppHost } from './helpers/assertions'; +import { findResource, waitForCommandOutcome, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResourceState, waitForWorkspaceAppHost } from './helpers/assertions'; import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { invokeLanguageModelTool, prepareLanguageModelToolInvocation } from './helpers/languageModelTools'; import { getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; @@ -19,7 +20,29 @@ interface ResourceDebugToolResult { debuggerExtensions?: Array<{ id: string; label: string }>; } +interface AttachedResourceDebugProof { + proof: 'aspire-resource-attach-breakpoint-detach'; + toolPayload: ResourceDebugToolResult; + resourceName: string; + debugType: 'coreclr' | 'go'; + breakpoint: { + sourcePath: string; + line: number; + text: string; + matchingStackFrame: { + source?: { path?: string }; + line?: number; + }; + }; + attachRequests: unknown[]; + breakpointResponses: Array<{ success?: boolean }>; + debugAdapterResponses: unknown[]; + resourceResponseAfterDetach: string; + sessionTerminated: boolean; +} + const resourceDebugToolName = 'aspire_resource_debug'; +const resourceDebugPrerequisitesInstalled = process.env.ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG === 'true'; // VS Code does not expose its telemetry transport to an Extension Host test, and the E2E bridge // intentionally persists only bounded tool results. resourceDebugService.test.ts asserts the exact @@ -117,30 +140,32 @@ suite('Aspire resource debug language model tool E2E', function () { confirmationMessage: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, }); - const invocation = await invokeLanguageModelTool( - resourceDebugToolName, - { - appHostPath: relativeAppHostPath, - resourceName: worker.name, - }, - { expectedConfirmations: 1, screenshotName: 'resource-debug-confirmation' }); + if (!resourceDebugPrerequisitesInstalled) { + const invocation = await invokeLanguageModelTool( + resourceDebugToolName, + { + appHostPath: relativeAppHostPath, + resourceName: worker.name, + }, + { expectedConfirmations: 1, screenshotName: 'resource-debug-confirmation' }); - assert.deepStrictEqual(invocation.dialogs[0], { - message: 'Attach debugger to Aspire resource', - details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, - }); - assert.deepStrictEqual(invocation.results, [{ - tool: resourceDebugToolName, - success: false, - outcome: 'debuggerExtensionMissing', - appHost: relativeAppHostPath, - resourceName: worker.name, - requestedStrategy: 'auto', - effectiveStrategy: 'none', - controller: 'none', - debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], - }]); - assertSafeResourceDebugResult(invocation.results[0]); + assert.deepStrictEqual(invocation.dialogs[0], { + message: 'Attach debugger to Aspire resource', + details: `Attach the debugger to resource ${worker.name} from Aspire AppHost ${relativeAppHostPath}?`, + }); + assert.deepStrictEqual(invocation.results, [{ + tool: resourceDebugToolName, + success: false, + outcome: 'debuggerExtensionMissing', + appHost: relativeAppHostPath, + resourceName: worker.name, + requestedStrategy: 'auto', + effectiveStrategy: 'none', + controller: 'none', + debuggerExtensions: [{ id: 'ms-dotnettools.csharp', label: 'C#' }], + }]); + assertSafeResourceDebugResult(invocation.results[0]); + } const missingResource = await invokeLanguageModelTool( resourceDebugToolName, @@ -226,6 +251,71 @@ suite('Aspire resource debug language model tool E2E', function () { assert.strictEqual(stopped.results[0].outcome, 'resourceNotRunning'); assertSafeResourceDebugResult(stopped.results[0]); }); + + test('attaches packaged .NET and Go debuggers, hits breakpoints, detaches, and tears down', async function () { + this.timeout(900000); + if (!resourceDebugPrerequisitesInstalled) { + this.skip(); + } + + await openAspireView(); + await waitForRepositoryIdle(); + const discovered = await waitForWorkspaceAppHost(); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + + await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); + await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + await waitForResourceState('e2e-worker', ['Running'], 180000); + await waitForResourceState('e2e-go', ['Running'], 180000); + + const scenarios = [ + { + resourceName: 'e2e-worker', + debugType: 'coreclr' as const, + sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Worker', 'Program.cs'), + marker: 'app.MapGet("/", () => "ok");', + expectedResponse: 'ok', + }, + { + resourceName: 'e2e-go', + debugType: 'go' as const, + sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Go', 'main.go'), + marker: 'message := "go-ok"', + expectedResponse: 'go-ok', + }, + ]; + + for (const scenario of scenarios) { + const proof = (await executeE2eControlCommand({ + name: 'proveAttachedResourceDebugging', + appHostPath, + resourceName: scenario.resourceName, + sourcePath: scenario.sourcePath, + breakpointLine: findBreakpointLine(scenario.sourcePath, scenario.marker), + expectedDebugType: scenario.debugType, + expectedResponse: scenario.expectedResponse, + timeoutMs: 300000, + }, { timeoutMs: 330000 })).result as AttachedResourceDebugProof; + + assert.strictEqual(proof.proof, 'aspire-resource-attach-breakpoint-detach'); + assert.strictEqual(proof.toolPayload.outcome, 'started'); + assert.strictEqual(proof.toolPayload.provider, scenario.debugType === 'coreclr' ? 'dotnet' : 'go'); + assertSafeResourceDebugResult(proof.toolPayload); + assert.strictEqual(proof.resourceName, scenario.resourceName); + assert.strictEqual(proof.debugType, scenario.debugType); + assert.strictEqual(proof.breakpoint.matchingStackFrame.line, proof.breakpoint.line); + assert.ok(isSamePath(proof.breakpoint.matchingStackFrame.source?.path, scenario.sourcePath)); + assert.ok(proof.attachRequests.length > 0); + assert.ok(proof.breakpointResponses.some(response => response.success === true)); + assert.deepStrictEqual(proof.debugAdapterResponses, []); + assert.strictEqual(proof.resourceResponseAfterDetach, scenario.expectedResponse); + assert.strictEqual(proof.sessionTerminated, true); + } + + await stopPrimaryAppHostIfRunning(); + await waitForNoDebugSessions(120000); + await waitForNoRunningAppHost(120000, appHostPath); + }); }); function toWorkspaceRelativePath(filePath: string): string { @@ -240,3 +330,17 @@ function assertSafeResourceDebugResult(result: ResourceDebugToolResult): void { assert.ok(!path.isAbsolute(result.appHost)); assert.doesNotMatch(serialized, /(?:pid|process|configuration|arguments?|args|environment|env|secret|token|executable)|https?:\/\/|\/(?:Users|private|var|tmp)\b/i); } + +function findBreakpointLine(sourcePath: string, marker: string): number { + const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/); + const index = lines.findIndex(line => line.includes(marker)); + if (index < 0) { + throw new Error(`Could not find '${marker}' in ${sourcePath} to place a breakpoint on.`); + } + + return index; +} + +function isSamePath(left: string | undefined, right: string): boolean { + return left !== undefined && path.resolve(left) === path.resolve(right); +} diff --git a/extension/src/test/adapterTracker.test.ts b/extension/src/test/adapterTracker.test.ts index f2b929ca8a7..9c43ecbfd11 100644 --- a/extension/src/test/adapterTracker.test.ts +++ b/extension/src/test/adapterTracker.test.ts @@ -291,6 +291,46 @@ suite('Debug Adapter Tracker Tests', () => { disposable.dispose(); }); + test('reports the debuggee process for non-AppHost sessions', () => { + const processHandler = sinon.spy(); + const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr', undefined, processHandler); + const factory = registerFactoryStub.lastCall.args[1]; + const tracker = factory.createDebugAdapterTracker(debugSession); + + tracker.onDidSendMessage({ + type: 'event', + event: 'process', + body: { systemProcessId: 4242 } + }); + + assert.strictEqual(processHandler.calledOnceWithExactly(debugSession, 4242), true); + disposable.dispose(); + }); + + test('clears the tracked debuggee process on missing restart PIDs and exit', () => { + const processHandler = sinon.spy(); + const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr', undefined, processHandler); + const factory = registerFactoryStub.lastCall.args[1]; + const tracker = factory.createDebugAdapterTracker(debugSession); + + tracker.onDidSendMessage({ + type: 'event', + event: 'process', + body: {} + }); + tracker.onDidSendMessage({ + type: 'event', + event: 'exited', + body: { exitCode: 0 } + }); + + assert.deepStrictEqual(processHandler.getCalls().map(call => call.args), [ + [debugSession, undefined], + [debugSession, undefined], + ]); + disposable.dispose(); + }); + test('process event without a system process ID still resets a captured exit code', async () => { const disposable = createDebugAdapterTracker(dcpServer as any, 'coreclr'); const factory = registerFactoryStub.lastCall.args[1]; diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 01d10a54e71..924d6494e65 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -12,7 +12,7 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { AppHostDiscoveryService, type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import * as cliModule from '../utils/process/cliProcess'; import * as configInfoProvider from '../utils/configInfoProvider'; -import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; +import { describeAppHostPidCapability, describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; import { errorFetchingAppHosts } from '../loc/strings'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; @@ -96,7 +96,7 @@ suite('AppHostDataRepository', () => { // Default to a current CLI so common-path tests use streamed discovery and include disabled // commands in describe output. Compatibility tests override this response explicitly. getConfigInfoStub = sinon.stub(configInfoProvider.ConfigInfoProvider.prototype, 'getConfigInfo').resolves({ - capabilities: [describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], + capabilities: [describeAppHostPidCapability, describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], } as any); defaultWorkspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); findFilesStub = sinon.stub(vscode.workspace, 'findFiles').resolves([]); @@ -1426,17 +1426,17 @@ suite('AppHostDataRepository', () => { } }); - test('fetchAppHostResourcesOnce describes one AppHost with the caller cancellation token', async () => { + test('fetchAppHostResourcesOnce describes one AppHost process with the caller cancellation token', async () => { const describeProcess = new TestChildProcess(); spawnStub.onFirstCall().returns(describeProcess); const repository = new AppHostDataRepository(terminalProvider); const cancellation = new vscode.CancellationTokenSource(); try { - const fetchPromise = repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', cancellation.token); + const fetchPromise = repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', cancellation.token, 1234); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['describe', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['describe', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj', '--apphost-pid', '1234']); cancellation.cancel(); await assert.rejects(fetchPromise, vscode.CancellationError); @@ -1447,6 +1447,22 @@ suite('AppHostDataRepository', () => { } }); + test('fetchAppHostResourcesOnce fails before describe when the CLI cannot bind an AppHost process', async () => { + getConfigInfoStub.resolves({ + capabilities: [describeIncludeDisabledCommandsCapability, lsJsonStreamCapability], + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + await assert.rejects( + repository.fetchAppHostResourcesOnce('/workspace/AppHost.csproj', undefined, 1234), + /cannot bind resource snapshots to an AppHost process/); + assert.strictEqual(spawnStub.called, false); + } finally { + repository.dispose(); + } + }); + test('fetchAppHostsOnce retries without nologo when an older CLI rejects it', async () => { const rejectedPsProcess = new TestChildProcess(); const psProcess = new TestChildProcess(); diff --git a/extension/src/test/appHostLifecycleTools.test.ts b/extension/src/test/appHostLifecycleTools.test.ts index 354e42ad1f0..5dd36608577 100644 --- a/extension/src/test/appHostLifecycleTools.test.ts +++ b/extension/src/test/appHostLifecycleTools.test.ts @@ -289,7 +289,10 @@ class FakeDiscoveryService implements AppHostLifecycleDiscoveryService { return this.registeredPaths .filter(candidatePath => { const relative = path.relative(folderPath, candidatePath); - return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); + return relative.length > 0 && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative); }) .map(candidatePath => ({ path: candidatePath, language: 'csharp', status: 'buildable' })); } @@ -854,6 +857,19 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.appHostPath, 'SingleFile/apphost.cs'); }); + test('accepts an in-workspace directory whose name begins with two dots', async () => { + const directory = path.join(workspaceRoot, '..services'); + fs.mkdirSync(directory, { recursive: true }); + const project = path.join(directory, 'AppHost.csproj'); + fs.writeFileSync(project, appHostProjectContents); + discoveryService.registeredPaths.push(project); + + const result = await service.start({ appHostPath: '..services/AppHost.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.appHostPath, '..services/AppHost.csproj'); + }); + test('treats a symlinked AppHost as the AppHost it points at', async function () { const directory = path.join(workspaceRoot, 'Symlinked'); fs.mkdirSync(directory, { recursive: true }); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 59b4c6b77ed..b1738d3e99c 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -513,7 +513,7 @@ suite('AspireAppHostTreeProvider', () => { onDidChangeVisibility: visibilityEmitter.event, reveal, } as unknown as Parameters[0]; - const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService(), makeResourceDebugger()); provider.setTreeView(treeView); dataEmitter.fire(); @@ -2799,7 +2799,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { globalSettingsSchema: { properties: [] }, capabilities: [pipelineInteractionCapability], }); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [workspaceAppHostsGroup] = provider.getChildren(); await waitForCondition( @@ -2879,7 +2879,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const globalProvider = new AspireAppHostTreeProvider(globalRepository, terminalProvider, launchService); + const globalProvider = new AspireAppHostTreeProvider(globalRepository, terminalProvider, launchService, makeResourceDebugger()); const [appHostItem] = globalProvider.getChildren(); assert.ok(appHostItem instanceof AppHostItem); const workspaceResourcesRepository = { @@ -2893,7 +2893,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const workspaceResourcesProvider = new AspireAppHostTreeProvider(workspaceResourcesRepository, terminalProvider, launchService); + const workspaceResourcesProvider = new AspireAppHostTreeProvider(workspaceResourcesRepository, terminalProvider, launchService, makeResourceDebugger()); const [workspaceResourcesItem] = workspaceResourcesProvider.getChildren(); assert.ok(workspaceResourcesItem instanceof WorkspaceResourcesItem); const workspaceAppHostRepository = { @@ -2906,7 +2906,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const workspaceAppHostProvider = new AspireAppHostTreeProvider(workspaceAppHostRepository, terminalProvider, launchService); + const workspaceAppHostProvider = new AspireAppHostTreeProvider(workspaceAppHostRepository, terminalProvider, launchService, makeResourceDebugger()); const [workspaceAppHostItem] = workspaceAppHostProvider.getChildren(); assert.ok(workspaceAppHostItem instanceof WorkspaceAppHostItem); @@ -2969,7 +2969,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }); sandbox.stub(vscode.window, 'showInputBox').resolves(undefined); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -3017,7 +3017,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const launchStub = sandbox.stub(launchService, 'launch').rejects(launchError); sandbox.stub(configInfoProvider.ConfigInfoProvider.prototype, 'getCapabilityStatus').resolves('supported'); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -3057,7 +3057,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { workspaceAppHostDescription: undefined, onDidChangeData, } as unknown as AppHostDataRepository; - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, makeResourceDebugger()); const callbacks = registerTreeCommandCallbacks(sandbox, provider, repository); const [appHostItem] = provider.getChildren(); @@ -3172,6 +3172,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { assert.ok(request); assert.strictEqual(request.appHost.absolutePath, appHostPath); assert.strictEqual(request.appHost.displayPath, vscode.workspace.asRelativePath(appHostPath)); + assert.strictEqual(request.appHost.appHostPid, 1234); provider.dispose(); }); @@ -4494,6 +4495,7 @@ suite('AppHost tree actions', () => { repository, makeTerminalProvider(), launchService, + makeResourceDebugger(), undefined, makeClipboard(), configInfoProviderInstance, diff --git a/extension/src/test/aspireDebugSession.test.ts b/extension/src/test/aspireDebugSession.test.ts index 43633b8a1e1..05f1663a77e 100644 --- a/extension/src/test/aspireDebugSession.test.ts +++ b/extension/src/test/aspireDebugSession.test.ts @@ -5297,6 +5297,7 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); exit_code: 17, }, ]); + assert.strictEqual(aspireDebugSession.hasResourceDebugSessionProcess(4242), false); aspireDebugSession.dispose(); }); @@ -5377,6 +5378,11 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) .hasResourceDebugSessionProcess(5252), false); + (aspireDebugSession as any)._resourceDebugSessionProcessIds.set('run-2', 5252); + assert.strictEqual( + (aspireDebugSession as unknown as { hasResourceDebugSessionProcess(processId: number): boolean }) + .hasResourceDebugSessionProcess(5252), + true); aspireDebugSession.dispose(); }); diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 366a43b4ca0..d47d3cd4bc5 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -618,6 +618,7 @@ suite('Dotnet Debugger Extension Tests', () => { }); test('attach configuration resolves an apphost child by its evaluated executable identity', async () => { + sinon.stub(process, 'platform').value('linux'); const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service'; const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); @@ -667,6 +668,43 @@ suite('Dotnet Debugger Extension Tests', () => { }), false); }); + test('matches a long macOS apphost path only through the exact executable identity', async () => { + sinon.stub(process, 'platform').value('darwin'); + const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service With A Long Name'; + const { dotNetService } = createDebuggerExtension(targetPath, null, true, true); + dotNetService.getDotNetAttachTargetInfoStub.resolves({ targetPath, useAppHost: true }); + const resolver = { + resolveProcessId: sinon.stub().resolves(4321), + }; + const attachProvider = createAttachProvider(dotNetService, resolver); + + await attachProvider.createDebugConfiguration(createProjectResource({ + 'executable.pid': 1234, + 'executable.path': 'dotnet', + 'project.path': '/repo/api/Api.csproj', + })); + + const processIdentity = resolver.resolveProcessId.firstCall.args[1] as TestLaunchedChildProcessIdentity; + assert.strictEqual(processIdentity.isCandidate({ + pid: 4321, + parentPid: 1234, + executable: targetPath, + command: `${targetPath} --urls http://localhost:5000`, + }), true); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4322, + parentPid: 1234, + executable: '/repo/OneDrive -', + command: targetPath, + }), false); + assert.strictEqual(processIdentity.isCandidate({ + pid: 4323, + parentPid: 1234, + executable: `${targetPath} Worker`, + command: targetPath, + }), false); + }); + test('preserves raw and full-realpath apphost candidates', async () => { const targetPath = '/workspace/link/bin/Debug/net10.0/Api.dll'; const appHostPath = '/workspace/link/bin/Debug/net10.0/Api'; diff --git a/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts index 33feb7d6a09..34af13e65ba 100644 --- a/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts +++ b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts @@ -44,6 +44,13 @@ suite('E2E addWorkspaceFolder guard', () => { assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); }); + test('accepts a contained folder whose name begins with two dots', () => { + const folderPath = path.join(workspaceRoot, '..services'); + fs.mkdirSync(folderPath, { recursive: true }); + + assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); + }); + test('rejects a folder outside every configured root', () => { const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-addfolder-outside-')); diff --git a/extension/src/test/e2eStateFileBridge.test.ts b/extension/src/test/e2eStateFileBridge.test.ts index 807389667d3..6e2b7cd01b4 100644 --- a/extension/src/test/e2eStateFileBridge.test.ts +++ b/extension/src/test/e2eStateFileBridge.test.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { AspireExtensionContext } from '../AspireExtensionContext'; import { registerTreeViewCommands } from '../activation/registerTreeViewCommands'; import { AppHostDataRepository, ViewMode } from '../data/AppHostDataRepository'; +import type { ResourceDebugger } from '../debugger/resourceDebugContracts'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; import { executeE2eControlCommand } from '../testing/e2eStateFileBridge'; import { pipelineInteractionCapability } from '../types/configInfo'; @@ -24,6 +25,13 @@ function createLaunchService(): AppHostLaunchService { }); } +function createResourceDebugger(): ResourceDebugger { + return { + canAttachToResource: () => false, + debug: async () => ({ outcome: 'unsupportedResource' }), + }; +} + suite('E2E state file bridge', () => { let sandbox: sinon.SinonSandbox; @@ -72,7 +80,7 @@ suite('E2E state file bridge', () => { pipelineInteractionCapability, ], }); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const registeredCommands = captureRegisteredTreeCommands(sandbox, provider, repository); sandbox.stub(vscode.commands, 'executeCommand').callsFake(async (commandId: string, ...args: unknown[]) => { const command = registeredCommands.get(commandId); @@ -109,7 +117,7 @@ suite('E2E state file bridge', () => { const repository = createRepository(['/repo/primary/AppHost/AppHost.csproj']); const terminalProvider = {} as AspireTerminalProvider; const launchService = createLaunchService(); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves(undefined); await assert.rejects( @@ -133,7 +141,7 @@ suite('E2E state file bridge', () => { const terminalProvider = {} as AspireTerminalProvider; const launchService = createLaunchService(); const launchStub = sandbox.stub(launchService, 'launch').resolves(); - const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService); + const provider = new AspireAppHostTreeProvider(repository, terminalProvider, launchService, createResourceDebugger()); const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves('refreshed'); const markStarted = sandbox.spy(); diff --git a/extension/src/test/goProcessDiscovery.test.ts b/extension/src/test/goProcessDiscovery.test.ts index f2439373e4c..c3fb77b61de 100644 --- a/extension/src/test/goProcessDiscovery.test.ts +++ b/extension/src/test/goProcessDiscovery.test.ts @@ -179,14 +179,24 @@ suite('Go process discovery', () => { assert.strictEqual(await resolver.resolveApplicationPid(10), 42); }); - test('recognizes a Go launcher from its command when macOS truncates comm', () => { + test('does not trust a Go launcher command when executable identity differs', () => { const identity = createGoRunProcessIdentity(); assert.strictEqual(identity.isLauncher(process( 10, 1, '/Users/me/Very', - '/Users/me/Very Long Go Installation/bin/go run ./cmd/api')), true); + '/Users/me/Very Long Go Installation/bin/go run ./cmd/api')), false); + assert.strictEqual(identity.isLauncher(process( + 11, + 1, + '/bin/bash', + 'bash -c "go run ./cmd/api"')), false); + assert.strictEqual(identity.isCandidate(process( + 42, + 10, + '/usr/bin/other', + '/private/var/folders/x/go-build123/b001/exe/api')), false); }); test('waits for the same Go build application candidate twice', async () => { diff --git a/extension/src/test/launchedChildProcessDiscovery.test.ts b/extension/src/test/launchedChildProcessDiscovery.test.ts index 9741e3dda27..57d7ee9b2c8 100644 --- a/extension/src/test/launchedChildProcessDiscovery.test.ts +++ b/extension/src/test/launchedChildProcessDiscovery.test.ts @@ -5,6 +5,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { LaunchedChildProcessResolver, + parseMacOsTextExecutablePath, parsePosixProcessList, parseWindowsProcessList, SystemLaunchedChildProcessQuery, @@ -393,7 +394,7 @@ suite('Launched child process discovery', () => { } }); - test('resolves a macOS child with a spaced non-ASCII executable path from per-candidate ps details', async () => { + test('resolves a macOS child with a spaced non-ASCII executable path from lsof identity', async () => { const calls: Array<{ command: string; args: readonly string[] }> = []; const processDetails = new Map([ [10, { parentPid: 1, executable: '/tool/launcher', command: '/tool/launcher --run' }], @@ -406,6 +407,16 @@ suite('Launched child process discovery', () => { const commandRunner: LaunchedChildProcessCommandRunner = { async run(command, args): Promise { calls.push({ command, args }); + if (command === 'lsof') { + const processId = Number(args[2]); + const details = processDetails.get(processId); + if (!details) { + throw new Error(`Unexpected process ID: ${processId}`); + } + + return `p${processId}\nftxt\nn${details.executable}\n`; + } + assert.strictEqual(command, 'ps'); if (args.join(' ') === '-axo pid=,ppid=') { return '10 1\n42 10'; @@ -420,8 +431,6 @@ suite('Launched child process discovery', () => { switch (args[args.length - 1]) { case 'ppid=': return String(details.parentPid); - case 'comm=': - return details.executable; case 'args=': return details.command; default: @@ -443,6 +452,16 @@ suite('Launched child process discovery', () => { assert.ok(calls.every(call => call.args.join(' ') !== '-axo pid=,ppid=,comm=,args=')); }); + test('parses only the requested macOS lsof text executable record', () => { + const output = 'p42\nftxt\nn/Applications/My Long App.app/Contents/MacOS/My Long App\n'; + + assert.strictEqual( + parseMacOsTextExecutablePath(output, 42), + '/Applications/My Long App.app/Contents/MacOS/My Long App'); + assert.strictEqual(parseMacOsTextExecutablePath(output, 43), undefined); + assert.strictEqual(parseMacOsTextExecutablePath('p42\nfcwd\nn/repo\n', 42), undefined); + }); + test('retries when a Linux candidate exits between topology and procfs reads', async () => { const targetPath = '/repo/OneDrive - Microsoft/über-long-path/My Attach Service.dll'; let candidateReadAttempts = 0; diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 758b4dd84b5..f8e3f490627 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -194,8 +194,10 @@ function createService(options: { } { const repository: ResourceDebugAppHostRepository = { fetchRunningAppHostsOnce: async () => options.appHosts ?? [createAppHost()], - fetchAppHostResourcesOnce: async appHostPath => - (options.appHosts ?? [createAppHost()]).find(appHost => appHost.appHostPath === appHostPath)?.resources ?? [], + fetchAppHostResourcesOnce: async (appHostPath, _cancellationToken, appHostPid) => + (options.appHosts ?? [createAppHost()]).find(appHost => + appHost.appHostPath === appHostPath && + (appHostPid === undefined || appHost.appHostPid === appHostPid))?.resources ?? [], }; const events = new TestDebugSessionEvents(); const telemetry = options.telemetry ?? new TestResourceDebugTelemetry(); @@ -468,7 +470,8 @@ suite('Resource debug service', () => { createAppHost({ appHostPid: 1111 }), createAppHost({ appHostPid: 2222 }), ]; - const { service, sessions } = createService({ appHosts }); + const { service, repository, sessions } = createService({ appHosts }); + const fetchResources = sinon.spy(repository, 'fetchAppHostResourcesOnce'); assert.deepStrictEqual(await service.debug(createRequest({ appHost: { @@ -476,6 +479,7 @@ suite('Resource debug service', () => { appHostPid: 2222, }, })), { outcome: 'started', providerId: 'dotnet' }); + assert.deepStrictEqual(fetchResources.firstCall.args, [target.absolutePath, undefined, 2222]); sessions.dispose(); }); @@ -540,6 +544,36 @@ suite('Resource debug service', () => { sessions.dispose(); }); + test('returns alreadyDebugging when Aspire owns the resolved attach process', async () => { + const startDebugging = sinon.stub().resolves(true); + const provider = createProvider({ + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 5252, + }), + }); + const { service, sessions } = createService({ + appHosts: [createAppHost({ + resources: [createResource({ + properties: { + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': 4242, + } as unknown as ResourceJson['properties'], + })], + })], + provider, + isProcessAlreadyDebugged: processId => processId === 5252, + startDebugging, + }); + + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'alreadyDebugging' }); + assert.strictEqual(startDebugging.called, false); + sessions.dispose(); + }); + test('reports the missing Go debugger extension using only its requirement metadata', async () => { const resolver = { resolveApplicationPid: sinon.stub().rejects(new Error('/private/go-build123/b001/exe/api 4567')), diff --git a/extension/src/test/resourceDebugTools.test.ts b/extension/src/test/resourceDebugTools.test.ts index 221028a91bf..1454eb9ec9f 100644 --- a/extension/src/test/resourceDebugTools.test.ts +++ b/extension/src/test/resourceDebugTools.test.ts @@ -228,6 +228,9 @@ suite('Aspire resource debug language model tool', () => { assert.strictEqual(registration.registered, true); assert.deepStrictEqual(registerToolStub.getCalls().map(call => call.args[0]), [aspireResourceDebugToolName]); assert.deepStrictEqual([...registration.tools.keys()], [aspireResourceDebugToolName]); + assert.strictEqual( + typeof (registration.tools.get(aspireResourceDebugToolName) as { invoke?: unknown }).invoke, + 'function'); registration.dispose(); assert.deepStrictEqual(disposed, [aspireResourceDebugToolName]); }); diff --git a/extension/src/test/testRunSessionManager.test.ts b/extension/src/test/testRunSessionManager.test.ts index e36db4360f7..6306fd61d43 100644 --- a/extension/src/test/testRunSessionManager.test.ts +++ b/extension/src/test/testRunSessionManager.test.ts @@ -148,25 +148,25 @@ function stubDebugSessionEvents(): { start: (session: vscode.DebugSession) => void; terminate: (session: vscode.DebugSession) => void; } { - let startDebugSession: ((session: vscode.DebugSession) => void) | undefined; - let terminateDebugSession: ((session: vscode.DebugSession) => void) | undefined; + const startDebugSessions: Array<(session: vscode.DebugSession) => void> = []; + const terminateDebugSessions: Array<(session: vscode.DebugSession) => void> = []; sinon.stub(vscode.debug, 'onDidStartDebugSession').callsFake(listener => { - startDebugSession = listener; + startDebugSessions.push(listener); return { dispose: () => { } }; }); sinon.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake(listener => { - terminateDebugSession = listener; + terminateDebugSessions.push(listener); return { dispose: () => { } }; }); return { start: session => { - assert.ok(startDebugSession); - startDebugSession(session); + assert.ok(startDebugSessions.length > 0); + startDebugSessions.forEach(listener => listener(session)); }, terminate: session => { - assert.ok(terminateDebugSession); - terminateDebugSession(session); + assert.ok(terminateDebugSessions.length > 0); + terminateDebugSessions.forEach(listener => listener(session)); }, }; } diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index e37b2d95281..32255b82714 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -796,6 +796,10 @@ export async function executeE2eControlCommand( cleanupRun(runId); } } + case 'proveAttachedResourceDebugging': { + markStarted(); + return await proveAttachedResourceDebugging(command, appHostTreeProvider, preparableLanguageModelTools); + } case 'proveAppHostAndResourceDebugging': { markStarted(); return await proveAppHostAndResourceDebugging(command, aspireContext, appHostTreeProvider); @@ -1028,8 +1032,16 @@ function getE2eEnvVars(value: unknown): EnvVar[] { } type AppHostAndResourceDebugProofCommand = Extract; +type AttachedResourceDebugProofCommand = Extract; type MauiResourceDebugProofCommand = Extract; +interface E2eInvocableLanguageModelTool extends PreparableLanguageModelTool { + invoke( + options: { readonly input: Record; readonly toolInvocationToken: undefined }, + token: vscode.CancellationToken, + ): Promise; +} + interface DebugSessionSnapshot { id: string; type: string; @@ -1069,6 +1081,257 @@ interface DebugAdapterMessageSummary { body?: unknown; } +async function proveAttachedResourceDebugging( + command: AttachedResourceDebugProofCommand, + appHostTreeProvider: AspireAppHostTreeProvider, + languageModelTools: ReadonlyMap, +): Promise { + const appHostPath = getE2eWorkspacePath(command.appHostPath); + const sourcePath = getE2eWorkspacePath(command.sourcePath); + const resourceName = getE2eRequiredString(command.resourceName, 'Aspire extension E2E attach proof requires resourceName.'); + const expectedResponse = getE2eRequiredString(command.expectedResponse, 'Aspire extension E2E attach proof requires expectedResponse.'); + const breakpointLine = getE2eBreakpointLine(command.breakpointLine); + const resourceRequestPath = command.resourceRequestPath ?? '/'; + const timeoutMs = getE2ePositiveInteger(command.timeoutMs, 300000, 'timeoutMs'); + const expectedDebugType = command.expectedDebugType; + if (expectedDebugType !== 'coreclr' && expectedDebugType !== 'go') { + throw new Error(`Aspire extension E2E attach proof expected coreclr or go, got '${String(expectedDebugType)}'.`); + } + + const debugSessions: DebugSessionSnapshot[] = []; + const sessionById = new Map(); + const terminatedSessionIds = new Set(); + const attachRequests: DebugAdapterMessageSummary[] = []; + const debugAdapterResponses: DebugAdapterMessageSummary[] = []; + const breakpointResponses: DebugAdapterMessageSummary[] = []; + const stoppedEvents: DebugAdapterStoppedEvent[] = []; + + const sessionSubscription = vscode.debug.onDidStartDebugSession(session => { + sessionById.set(session.id, session); + debugSessions.push(toDebugSessionSnapshot(session)); + }); + const terminationSubscription = vscode.debug.onDidTerminateDebugSession(session => { + terminatedSessionIds.add(session.id); + }); + const trackerRegistration = vscode.debug.registerDebugAdapterTrackerFactory('*', { + createDebugAdapterTracker(session) { + return { + onWillReceiveMessage(message) { + if (message?.type === 'request' && message.command === 'attach') { + attachRequests.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + body: redactDebugAdapterArguments(message.arguments), + }); + } + }, + onDidSendMessage(message) { + if (message?.type === 'response' && message.success === false) { + debugAdapterResponses.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + success: false, + body: redactDebugAdapterArguments(message), + }); + } + if (message?.type === 'response' && message.command === 'setBreakpoints') { + breakpointResponses.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + command: message.command, + success: message.success, + body: redactDebugAdapterArguments(message.body), + }); + } + if (message?.type === 'event' && message.event === 'stopped') { + stoppedEvents.push({ + sessionId: session.id, + sessionType: session.type, + sessionName: session.name, + reason: message.body?.reason, + threadId: message.body?.threadId, + }); + } + }, + }; + }, + }); + + const breakpoint = new vscode.SourceBreakpoint( + new vscode.Location(vscode.Uri.file(sourcePath), new vscode.Position(breakpointLine, 0)), + true); + vscode.debug.addBreakpoints([breakpoint]); + let attachedSession: vscode.DebugSession | undefined; + let toolPayload: Record | undefined; + + try { + const resourceDebugTool = languageModelTools.get('aspire_resource_debug') as E2eInvocableLanguageModelTool | undefined; + if (!resourceDebugTool?.invoke) { + throw new Error('Aspire extension E2E attach proof could not find the registered aspire_resource_debug tool.'); + } + + const workspaceRoot = getE2eWorkspacePath(process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT); + const relativeAppHostPath = path.relative(workspaceRoot, appHostPath).split(path.sep).join('/'); + const toolCancellation = new vscode.CancellationTokenSource(); + let languageModelResult: vscode.LanguageModelToolResult; + try { + languageModelResult = await resourceDebugTool.invoke({ + input: { + appHostPath: relativeAppHostPath, + resourceName, + strategy: 'attach', + }, + toolInvocationToken: undefined, + }, toolCancellation.token); + } + finally { + toolCancellation.dispose(); + } + const resultPart = languageModelResult.content[0]; + if (!(resultPart instanceof vscode.LanguageModelTextPart)) { + throw new Error('aspire_resource_debug returned a non-text result.'); + } + + toolPayload = JSON.parse(resultPart.value) as Record; + const expectedProvider = expectedDebugType === 'coreclr' ? 'dotnet' : 'go'; + if (toolPayload.outcome !== 'started' || toolPayload.provider !== expectedProvider) { + throw new Error(`aspire_resource_debug returned ${JSON.stringify(toolPayload)} instead of starting ${expectedProvider}.`); + } + + attachedSession = await waitForE2eValue( + `${expectedDebugType} attach session for resource '${resourceName}'`, + timeoutMs, + () => [...sessionById.values()].find(session => + session.type === expectedDebugType && + session.configuration.request === 'attach')); + + const breakpointHit = await withResourceTraffic( + appHostTreeProvider, + appHostPath, + resourceName, + resourceRequestPath, + timeoutMs, + async () => await waitForE2eValue( + `breakpoint in ${sourcePath}:${breakpointLine + 1}`, + timeoutMs, + async () => { + for (const stoppedEvent of stoppedEvents) { + if (stoppedEvent.sessionId !== attachedSession?.id || stoppedEvent.threadId === undefined) { + continue; + } + + let stackTrace: { stackFrames?: Array<{ source?: { path?: string }; line?: number }> } | undefined; + try { + stackTrace = await attachedSession.customRequest('stackTrace', { + threadId: stoppedEvent.threadId, + startFrame: 0, + levels: 20, + }); + } + catch { + continue; + } + + const matchingFrame = stackTrace?.stackFrames?.find(frame => + typeof frame.source?.path === 'string' && + isSamePath(frame.source.path, sourcePath) && + frame.line === breakpointLine + 1); + if (matchingFrame) { + return { stoppedEvent, matchingFrame }; + } + } + + return undefined; + })); + + // Remove the breakpoint before continuing so requests queued by the traffic driver cannot + // immediately stop the process again and race debugger detach. + vscode.debug.removeBreakpoints([breakpoint]); + await attachedSession.customRequest('continue', { threadId: breakpointHit.stoppedEvent.threadId }); + await vscode.debug.stopDebugging(attachedSession); + await waitForE2eValue( + `${expectedDebugType} attach session termination`, + timeoutMs, + () => terminatedSessionIds.has(attachedSession!.id) ? true : undefined); + + const responseBody = await waitForE2eValue( + `resource '${resourceName}' response after debugger detach`, + timeoutMs, + async () => { + const resourceAfterDetach = appHostTreeProvider.findResourceElement(resourceName, appHostPath); + if (!(resourceAfterDetach instanceof ResourceItem) || resourceAfterDetach.resource.state !== 'Running') { + return undefined; + } + + const requestUrl = await resolveResourceRequestUrl( + appHostTreeProvider, + appHostPath, + resourceName, + resourceRequestPath, + timeoutMs); + try { + const response = await fetch(requestUrl, { signal: AbortSignal.timeout(5000) }); + const body = await response.text(); + return response.ok && body === expectedResponse ? body : undefined; + } + catch { + return undefined; + } + }); + + if (attachRequests.length === 0 || breakpointResponses.every(response => response.success !== true)) { + throw new Error(`The ${expectedDebugType} adapter did not report both attach and bound-breakpoint protocol traffic.`); + } + + return { + proof: 'aspire-resource-attach-breakpoint-detach', + toolPayload, + resourceName, + debugType: expectedDebugType, + debugSessionId: attachedSession.id, + breakpoint: { + sourcePath, + line: breakpointLine + 1, + text: fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/)[breakpointLine]?.trim(), + stoppedEvent: breakpointHit.stoppedEvent, + matchingStackFrame: breakpointHit.matchingFrame, + }, + attachRequests, + breakpointResponses, + debugAdapterResponses, + resourceResponseAfterDetach: responseBody, + sessionTerminated: true, + }; + } + catch (error) { + throw new Error(`${error instanceof Error ? error.message : String(error)} +Diagnostics: +${JSON.stringify({ + debugSessions, + attachRequests, + breakpointResponses, + debugAdapterResponses, + stoppedEvents, + terminatedSessionIds: [...terminatedSessionIds], + toolPayload, + }, undefined, 2)}`); + } + finally { + vscode.debug.removeBreakpoints([breakpoint]); + sessionSubscription.dispose(); + terminationSubscription.dispose(); + trackerRegistration.dispose(); + if (attachedSession && !terminatedSessionIds.has(attachedSession.id)) { + await vscode.debug.stopDebugging(attachedSession); + } + } +} + async function proveAppHostAndResourceDebugging(command: AppHostAndResourceDebugProofCommand, aspireContext: AspireExtensionContext, appHostTreeProvider: AspireAppHostTreeProvider): Promise { const appHostPath = getE2eWorkspacePath(command.appHostPath); const appHostSourcePath = getE2eWorkspacePath(command.appHostSourcePath); @@ -1609,18 +1872,12 @@ async function withResourceTraffic( endpointTimeoutMs: number, waitForHit: () => Promise ): Promise { - const baseUrl = await waitForE2eValue( - `an HTTP endpoint for resource '${resourceName}'`, - endpointTimeoutMs, - () => { - const element = appHostTreeProvider.findEndpointElement({ appHostPath, resourceName }); - return element && hasEndpointUrl(element) ? element.url : undefined; - }, - () => describeResourcesForE2E(appHostTreeProvider, appHostPath, resourceName)); - - // A relative path resolves against the endpoint only when the base ends in '/'; without it the - // last segment of the endpoint would be replaced instead. - const requestUrl = new URL(requestPath.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); + const requestUrl = await resolveResourceRequestUrl( + appHostTreeProvider, + appHostPath, + resourceName, + requestPath, + endpointTimeoutMs); let driving = true; const driver = (async () => { @@ -1647,6 +1904,27 @@ async function withResourceTraffic( } } +async function resolveResourceRequestUrl( + appHostTreeProvider: AspireAppHostTreeProvider, + appHostPath: string, + resourceName: string, + requestPath: string, + endpointTimeoutMs: number, +): Promise { + const baseUrl = await waitForE2eValue( + `an HTTP endpoint for resource '${resourceName}'`, + endpointTimeoutMs, + () => { + const element = appHostTreeProvider.findEndpointElement({ appHostPath, resourceName }); + return element && hasEndpointUrl(element) ? element.url : undefined; + }, + () => describeResourcesForE2E(appHostTreeProvider, appHostPath, resourceName)); + + // A relative path resolves against the endpoint only when the base ends in '/'; without it the + // last segment of the endpoint would be replaced instead. + return new URL(requestPath.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); +} + async function waitForE2eValue(description: string, timeoutMs: number, getValue: () => T | undefined | Promise, describeState?: () => string): Promise { const started = Date.now(); let lastError: string | undefined; while (Date.now() - started < timeoutMs) { @@ -1982,7 +2260,10 @@ function isPathWithinDirectory(candidatePath: string, directoryPath: string): bo const resolvedCandidate = path.resolve(candidatePath); const resolvedDirectory = path.resolve(directoryPath); const relativePath = path.relative(resolvedDirectory, resolvedCandidate); - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + return relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)); } function getE2eBreakpoints(): Array<{ filePath: string; line: number; enabled: boolean }> { diff --git a/extension/src/types/configInfo.ts b/extension/src/types/configInfo.ts index 3ad8746eacd..eb740c18624 100644 --- a/extension/src/types/configInfo.ts +++ b/extension/src/types/configInfo.ts @@ -59,6 +59,13 @@ export const pipelineStepListJsonCapability = 'pipeline-step-list-json.v1'; */ export const describeIncludeDisabledCommandsCapability = 'describe-include-disabled-commands.v1'; +/** + * Capability advertised by the CLI when `aspire describe` accepts `--apphost-pid` to bind an + * explicit AppHost path to one running process. Keep in sync with + * `KnownCapabilities.DescribeAppHostPid` in src/Aspire.Cli/Utils/ExtensionHelper.cs. + */ +export const describeAppHostPidCapability = 'describe-apphost-pid.v1'; + /** * Capability advertised by the CLI when `aspire ls --format json --stream` emits AppHost * candidates as newline-delimited JSON. Tooling uses this to avoid probing localized CLI errors diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index 91dbc06fb60..ee6d6896427 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -270,5 +270,16 @@ export type AspireExtensionE2EControlCommand = debuggers?: Readonly>; environmentKeys?: readonly string[]; } + | { + name: 'proveAttachedResourceDebugging'; + appHostPath: string; + resourceName: string; + sourcePath: string; + breakpointLine: number; + expectedDebugType: 'coreclr' | 'go'; + expectedResponse: string; + resourceRequestPath?: string; + timeoutMs?: number; + } | { name: 'proveAppHostAndResourceDebugging'; appHostPath: string; resourceName: string; appHostSourcePath: string; appHostBreakpointLine: number; resourceSourcePath: string; resourceBreakpointLine: number; resourceRequestPath?: string; timeoutMs?: number } | { name: 'proveMauiResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number }; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 3a08bf2bcba..2548b0d3c9e 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -933,7 +933,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider getParentResourceName(r) === resource.name); items.push(new ResourceItem( resource, - null, + element.appHost?.appHostPid ?? null, hasChildren, element.resources, element.appHostPath, diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 00463cad69e..3715ce53262 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -88,6 +88,9 @@ public async Task ResolveAllConnectionsAsync( /// Whether AppHosts running in a different git worktree are hidden from interactive selection. /// AppHosts elsewhere in the same worktree remain selectable. /// + /// + /// Optional process ID that restricts an explicit project lookup to one AppHost instance. + /// /// The resolved connection, or null with an error message. public async Task ResolveConnectionAsync( FileInfo? projectFile, @@ -95,7 +98,8 @@ public async Task ResolveConnectionAsync( string selectPrompt, string notFoundMessage, CancellationToken cancellationToken, - bool restrictToCurrentWorktree = false) + bool restrictToCurrentWorktree = false, + int? appHostPid = null) { // Fast path: If --apphost was specified, check directly for its socket if (projectFile is not null) @@ -146,7 +150,8 @@ public async Task ResolveConnectionAsync( projectFile.FullName, executionContext.HomeDirectory.FullName, Environment.ProcessId, - logger); + logger, + appHostPid); // Try each matching socket until we get a connection foreach (var socketPath in matchingSockets) @@ -155,6 +160,12 @@ public async Task ResolveConnectionAsync( { var connection = await AppHostAuxiliaryBackchannel.ConnectAsync( socketPath, logger, profilingTelemetry, cancellationToken).ConfigureAwait(false); + if (appHostPid is not null && connection.AppHostInfo?.ProcessId != appHostPid) + { + connection.Dispose(); + continue; + } + if (connection is not null) { var result = new AppHostConnectionResult { Connection = connection }; diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 23437a425bb..adcb7ba3375 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -100,6 +100,10 @@ internal sealed class DescribeCommand : BaseCommand { Hidden = true }; + private static readonly Option s_appHostPidOption = new("--apphost-pid") + { + Hidden = true + }; public DescribeCommand( AppHostConnectionResolver connectionResolver, @@ -119,6 +123,21 @@ public DescribeCommand( Options.Add(s_formatOption); Options.Add(s_includeHiddenOption); Options.Add(s_includeDisabledCommandsOption); + Options.Add(s_appHostPidOption); + Validators.Add(result => + { + var appHostPid = result.GetValue(s_appHostPidOption); + if (appHostPid is <= 0) + { + result.AddError("--apphost-pid must be a positive process ID."); + } + else if (appHostPid is not null && + result.GetValue(s_appHostOption.InnerOption) is null && + result.GetValue(s_appHostOption.LegacyOption) is null) + { + result.AddError("--apphost-pid requires --apphost."); + } + }); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -131,13 +150,15 @@ protected override async Task ExecuteAsync(ParseResult parseResul var format = parseResult.GetValue(s_formatOption); var includeHidden = parseResult.GetValue(s_includeHiddenOption); var includeDisabledCommands = parseResult.GetValue(s_includeDisabledCommandsOption); + var appHostPid = parseResult.GetValue(s_appHostPidOption); var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, SharedCommandStrings.ScanningForRunningAppHosts, string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, DescribeCommandStrings.SelectAppHostAction), SharedCommandStrings.AppHostNotRunning, - cancellationToken); + cancellationToken, + appHostPid: appHostPid); if (!result.Success) { diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 08250b7df5a..bebc307dbd6 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -138,7 +138,8 @@ internal static string[] FindMatchingNonOrphanedSockets( string appHostPath, string homeDirectory, int currentPid, - ILogger logger) + ILogger logger, + int? appHostPid = null) { // Resolve symlinks so callers that provide "/tmp/..." can still match sockets keyed // off the physical path (for example "/private/tmp/..." on macOS). @@ -150,7 +151,13 @@ internal static string[] FindMatchingNonOrphanedSockets( logger.LogDebug("Cleaned up {Count} orphaned AppHost socket(s).", deletedCount); } - return remainingSockets; + // PID-less legacy sockets cannot be filtered before connecting. Keep them so an older + // AppHost remains reachable, then let the caller validate AppHostInfo.ProcessId. + return appHostPid is null + ? remainingSockets + : remainingSockets.Where(socketPath => + ExtractPidFromSocketPath(socketPath) is not { } socketPid || + socketPid == appHostPid).ToArray(); } /// diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index 338d9fb1ef6..8836d64aad0 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -44,6 +44,9 @@ internal static class KnownCapabilities // pass it and parse (localized) error output when an older CLI rejects it. public const string DescribeIncludeDisabledCommands = "describe-include-disabled-commands.v1"; + // Advertised so tooling can bind `aspire describe` to one same-path AppHost process. + public const string DescribeAppHostPid = "describe-apphost-pid.v1"; + // Advertised so tooling can detect that `aspire ls --format json --stream` is supported // before opting into newline-delimited JSON candidate discovery. public const string LsJsonStream = "ls-json-stream.v1"; @@ -59,5 +62,5 @@ internal static class KnownCapabilities /// /// Gets the set of capabilities this CLI advertises to extensions. /// - public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, PipelineStepListJson, DescribeIncludeDisabledCommands, LsJsonStream, IsolatedLaunch, LaunchProfile]; + public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, PipelineStepListJson, DescribeIncludeDisabledCommands, DescribeAppHostPid, LsJsonStream, IsolatedLaunch, LaunchProfile]; } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index d859f0a456d..be3035639f8 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -253,21 +253,25 @@ private static ImmutableArray GetDotNetLaunchPropertie return []; } - if (effectiveArgs is not [var command, ..] || - (!string.Equals(command, "run", StringComparison.OrdinalIgnoreCase) && - !string.Equals(command, "watch", StringComparison.OrdinalIgnoreCase))) + if (effectiveArgs is null || + FindDotNetProjectCommand(effectiveArgs) is not { } commandInfo) { return [new(KnownProperties.Project.LaunchCommand, null)]; } + var (command, commandIndex) = commandInfo; string? configuration = null; string? targetFramework = null; // DCP reports dotnet launch arguments as: // ["watch", "--project", "/repo/api.csproj", "--configuration", "Release", "--framework=net10.0", "--", ...appArgs] + // ["[env:NAME=value]", "--diagnostics", "run", "--project", "/repo/api.csproj"] + // ["-d", "watch", "--project", "/repo/api.csproj"] // Only launcher arguments before "--" are safe to publish as launch metadata. Application // arguments after the separator can contain unrelated values and remain sensitive in executable.args. - for (var index = 1; index < effectiveArgs.Count; index++) + // See https://learn.microsoft.com/dotnet/core/tools/dotnet and + // https://github.com/dotnet/command-line-api/blob/main/src/System.CommandLine/EnvironmentVariablesDirective.cs. + for (var index = commandIndex + 1; index < effectiveArgs.Count; index++) { var argument = effectiveArgs[index]; if (argument == "--") @@ -314,6 +318,37 @@ private static ImmutableArray GetDotNetLaunchPropertie return properties.ToImmutable(); + static (string Command, int Index)? FindDotNetProjectCommand(IReadOnlyList arguments) + { + var index = 0; + var hasEnvironmentVariableDirective = false; + while (index < arguments.Count && + (string.Equals(arguments[index], "[env]", StringComparison.OrdinalIgnoreCase) || + arguments[index].StartsWith("[env:", StringComparison.OrdinalIgnoreCase) && arguments[index].EndsWith(']'))) + { + hasEnvironmentVariableDirective = true; + index++; + } + + while (index < arguments.Count && arguments[index] is "-d" or "--diagnostics") + { + index++; + } + + if (index >= arguments.Count) + { + return null; + } + + return arguments[index].ToLowerInvariant() switch + { + "run" => ("run", index), + // .NET 10 cannot resolve the external watch command through an environment directive. + "watch" when !hasEnvironmentVariableDirective => ("watch", index), + _ => null, + }; + } + static bool TryReadOptionValue(string argument, string longOption, string shortOption, out string? value) { foreach (var option in new[] { longOption, shortOption }) diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index 97aae20e0c2..5623554fc43 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -33,7 +33,6 @@ public static class Resource public const string AppArgsSensitivity = "resource.appArgsSensitivity"; public const string ExcludeFromMcp = "resource.excludeFromMcp"; public const string WaitingFor = "resource.waitingFor"; - public const string LaunchConfigurationType = "resource.launchConfigurationType"; } public static class Container diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 7c8f968c14a..8e4725ab03b 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -25,6 +25,12 @@ public void ConfigInfo_AdvertisesLsJsonStream() Assert.Contains(KnownCapabilities.LsJsonStream, KnownCapabilities.GetAdvertisedCapabilities()); } + [Fact] + public void ConfigInfo_AdvertisesDescribeAppHostPid() + { + Assert.Contains(KnownCapabilities.DescribeAppHostPid, KnownCapabilities.GetAdvertisedCapabilities()); + } + [Fact] public void ConfigInfo_AdvertisesIsolatedLaunch() { diff --git a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index f2ba3e413f5..60da1ea687a 100644 --- a/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -48,6 +48,24 @@ public async Task DescribeCommand_WhenNoAppHostRunning_ReturnsSuccess() Assert.Equal(CliExitCodes.Success, exitCode); } + [Theory] + [InlineData("describe --apphost-pid 42")] + [InlineData("describe --apphost missing.csproj --apphost-pid 0")] + [InlineData("describe --apphost missing.csproj --apphost-pid -1")] + public async Task DescribeCommand_AppHostPid_RejectsUnboundOrInvalidValues(string commandLine) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse(commandLine); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.NotEqual(CliExitCodes.Success, exitCode); + } + [Theory] [InlineData("json")] [InlineData("Json")] diff --git a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs index bb67bf8870c..1cd025a4cad 100644 --- a/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/AppHostHelperTests.cs @@ -409,6 +409,39 @@ public void FindMatchingNonOrphanedSockets_RemovesDeadPidSocketsAndKeepsLiveAndP Assert.True(File.Exists(pidlessSocket)); } + [Fact] + public void FindMatchingNonOrphanedSockets_WithAppHostPid_ReturnsOnlyThatPidQualifiedSocket() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var backchannelsDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch"); + Directory.CreateDirectory(backchannelsDir); + + var appHostPath = "/path/to/MyApp.AppHost.csproj"; + var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath); + var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, workspace.WorkspaceRoot.FullName); + var appHostId = Path.GetFileName(prefix); + var selectedPid = Environment.ProcessId; + var otherPid = int.MaxValue - 1; + var selectedSocket = Path.Combine(backchannelsDir, $"{appHostId}a1b2C3d4.{selectedPid}"); + var otherSocket = Path.Combine(backchannelsDir, $"{appHostId}Z9y8X7w6.{otherPid}"); + var pidlessSocket = Path.Combine(backchannelsDir, appHostId); + File.WriteAllText(selectedSocket, ""); + File.WriteAllText(otherSocket, ""); + File.WriteAllText(pidlessSocket, ""); + + var matchingSockets = AppHostHelper.FindMatchingNonOrphanedSockets( + appHostPath, + workspace.WorkspaceRoot.FullName, + otherPid, + NullLogger.Instance, + selectedPid); + + Assert.Collection( + matchingSockets.Order(StringComparer.Ordinal), + socket => Assert.Equal(pidlessSocket, socket), + socket => Assert.Equal(selectedSocket, socket)); + } + [Fact] public void FindMatchingNonOrphanedSockets_WithSymlinkedPath_MatchesCanonicalSocket() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 13a81042b99..20786666e8b 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -170,6 +170,34 @@ public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); } + [Theory] + [InlineData("run", "[env:ASPNETCORE_ENVIRONMENT=Development]", "--diagnostics")] + [InlineData("watch", "-d")] + public void ProjectSnapshotIncludesLaunchMetadataAfterSupportedDotNetPrefixes( + string launchCommand, + params string[] prefixes) + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = [.. prefixes, launchCommand, "--configuration", "Release", "--framework", "net10.0"], + ProcessId = 1234 + }; + + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, CreatePreviousSnapshot()); + + Assert.Equal(launchCommand, GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value); + Assert.Equal("Release", GetProperty(snapshot, KnownProperties.Project.Configuration).Value); + Assert.Equal("net10.0", GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value); + } + [Fact] public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetArgumentsAreMissing() { From a300ea208de9496ad0a8e6a717471fe437623ac0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 31 Aug 2026 23:13:21 -0500 Subject: [PATCH 81/90] Isolate packaged resource attach coverage Start the AppHost outside the extension debug lifecycle so the packaged C# and Go attach proof can own the only resource debugger sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-e2e/resourceDebugTools.e2e.test.ts | 28 +++++++++++++++---- extension/src/testing/e2eStateFileBridge.ts | 2 +- extension/src/types/extensionApi.ts | 2 +- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index d9a82fc6543..0f6f77b706b 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -58,7 +58,11 @@ suite('Aspire resource debug language model tool E2E', function () { ], 'Resource debug language model tool E2E teardown failed.'); }); - test('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async () => { + test('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async function () { + if (resourceDebugPrerequisitesInstalled) { + this.skip(); + } + await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); @@ -116,7 +120,11 @@ suite('Aspire resource debug language model tool E2E', function () { } }); - test('requires explicit confirmation and returns safe running-resource outcomes', async () => { + test('requires explicit confirmation and returns safe running-resource outcomes', async function () { + if (resourceDebugPrerequisitesInstalled) { + this.skip(); + } + await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); @@ -206,7 +214,11 @@ suite('Aspire resource debug language model tool E2E', function () { assertSafeResourceDebugResult(unsupportedResource.results[0]); }); - test('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async () => { + test('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async function () { + if (resourceDebugPrerequisitesInstalled) { + this.skip(); + } + await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); @@ -263,8 +275,14 @@ suite('Aspire resource debug language model tool E2E', function () { const discovered = await waitForWorkspaceAppHost(); const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); - await executeE2eControlCommand({ name: 'runAppHost', appHostPath }, { waitFor: 'started' }); - await waitForCommandOutcome('aspire-vscode.runAppHost', 'success', 120000); + const start = (await executeE2eControlCommand({ + name: 'runAspireCli', + args: ['start', '--apphost', appHostPath, '--format', 'json', '--non-interactive', '--nologo'], + workingDirectory: '.', + timeoutMs: 180000, + noExtensionVariables: true, + }, { timeoutMs: 210000 })).result as { exitCode: number | null; stdout: string; stderr: string }; + assert.strictEqual(start.exitCode, 0, `aspire start failed.\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}`); await waitForResourceState('e2e-worker', ['Running'], 180000); await waitForResourceState('e2e-go', ['Running'], 180000); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 32255b82714..582cada6c5a 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -939,7 +939,7 @@ export async function executeE2eControlCommand( [...command.args], workingDirectory, timeoutMs, - terminalProvider.createEnvironment()); + terminalProvider.createEnvironment(undefined, undefined, command.noExtensionVariables)); markStarted(); return await commandPromise; } diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index ee6d6896427..728f6ab6817 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -255,7 +255,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'getWorkspaceFolders' } | { name: 'addWorkspaceFolder'; folderPath: string } | { name: 'getActiveEditor' } - | { name: 'runAspireCli'; args: readonly string[]; workingDirectory: string; timeoutMs?: number } + | { name: 'runAspireCli'; args: readonly string[]; workingDirectory: string; timeoutMs?: number; noExtensionVariables?: boolean } | { name: 'getResourceDebuggerExtensions' } | { name: 'getSupportedCapabilities' } | { name: 'getVisibleExtensionIds' } From c445020193e1dcd13ef429fa398af841404767dd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 31 Aug 2026 23:35:13 -0500 Subject: [PATCH 82/90] Skip negative debugger cases before setup Avoid running teardown hooks for statically skipped negative cases in the packaged debugger shard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-e2e/resourceDebugTools.e2e.test.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index 0f6f77b706b..29bbb412d27 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -43,6 +43,7 @@ interface AttachedResourceDebugProof { const resourceDebugToolName = 'aspire_resource_debug'; const resourceDebugPrerequisitesInstalled = process.env.ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG === 'true'; +const negativePathTest = resourceDebugPrerequisitesInstalled ? test.skip : test; // VS Code does not expose its telemetry transport to an Extension Host test, and the E2E bridge // intentionally persists only bounded tool results. resourceDebugService.test.ts asserts the exact @@ -58,11 +59,7 @@ suite('Aspire resource debug language model tool E2E', function () { ], 'Resource debug language model tool E2E teardown failed.'); }); - test('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async function () { - if (resourceDebugPrerequisitesInstalled) { - this.skip(); - } - + negativePathTest('returns bounded results for invalid, additional, and unknown selectors after generic confirmation', async () => { await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); @@ -120,11 +117,7 @@ suite('Aspire resource debug language model tool E2E', function () { } }); - test('requires explicit confirmation and returns safe running-resource outcomes', async function () { - if (resourceDebugPrerequisitesInstalled) { - this.skip(); - } - + negativePathTest('requires explicit confirmation and returns safe running-resource outcomes', async () => { await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); @@ -214,11 +207,7 @@ suite('Aspire resource debug language model tool E2E', function () { assertSafeResourceDebugResult(unsupportedResource.results[0]); }); - test('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async function () { - if (resourceDebugPrerequisitesInstalled) { - this.skip(); - } - + negativePathTest('cancels through the VS Code invocation token and reports a stopped resource without invoking a debugger', async () => { await openAspireView(); await waitForRepositoryIdle(); const discovered = await waitForWorkspaceAppHost(); From 413c554524e19a022eb9cf470e300aefcaf5450f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 18:25:20 -0500 Subject: [PATCH 83/90] Harden resource debugger metadata and E2E coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- extension/scripts/run-e2e.js | 21 ++--- extension/src/debugger/debuggerExtensions.ts | 31 ++++--- .../src/debugger/resourceDebugContracts.ts | 4 +- .../src/debugger/resourceDebugService.ts | 20 +++++ extension/src/extension.ts | 14 ++++ .../test-e2e/resourceDebugTools.e2e.test.ts | 12 ++- extension/src/test/e2eLaunchProfile.test.ts | 26 +++++- .../src/test/resourceDebugService.test.ts | 59 +++++++++++++ .../ExecutableLaunchRecipe.cs | 35 ++++---- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 7 ++ src/Aspire.Hosting/Dcp/Model/Executable.cs | 1 + .../Dcp/ResourceSnapshotBuilder.cs | 54 ++++++++++-- .../Dcp/ExecutableLaunchPlanTests.cs | 35 ++++++++ .../Dcp/ResourceSnapshotBuilderTests.cs | 84 +++++++++++++++++++ 14 files changed, 349 insertions(+), 54 deletions(-) diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 464c80d3adc..89d44d1682e 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -890,19 +890,19 @@ function resolveAzureFunctionsVsixPaths() { return [ { displayName: '.NET Install Tool', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'C#', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'Azure Resource Groups', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, { displayName: 'Azure Functions', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS'), }, ]; } @@ -917,15 +917,15 @@ function resolveResourceDebugVsixPaths() { return [ { displayName: '.NET Install Tool', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), }, { displayName: 'C#', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), }, { displayName: 'Go', - path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_GO_VSIX'), + path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_GO_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG'), }, ]; } @@ -1333,14 +1333,15 @@ function assertExtensionSupportsVsCodeVersion(extensionDirectory, directoryName) } } -function resolveRequiredVsixPath(environmentVariable) { const configuredPath = process.env[environmentVariable]; +function resolveRequiredVsixPath(environmentVariable, selectingFeatureFlag) { + const configuredPath = process.env[environmentVariable]; if (!configuredPath) { - throw new Error(`${environmentVariable} is required when ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS=true.`); + throw new Error(`${environmentVariable} is required when ${selectingFeatureFlag}=true.`); } const resolvedPath = path.resolve(configuredPath); if (!fs.existsSync(resolvedPath)) { - throw new Error(`${environmentVariable} points to a missing file: ${resolvedPath}`); + throw new Error(`${environmentVariable} points to a missing file: ${resolvedPath}. It is required when ${selectingFeatureFlag}=true.`); } validateVsix(resolvedPath); diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 2aab0b6dd6d..cb13143f5e1 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -1,4 +1,5 @@ import path from "path"; +import * as vscode from 'vscode'; import { ExecutableLaunchConfiguration, EnvVar, LaunchOptions, AspireResourceExtendedDebugConfiguration, AspireExtendedDebugConfiguration, AspireResourceDebugSession } from "../dcp/types"; import { debugProject, runProject } from "../loc/strings"; import { getEnvironmentForChildProcess, mergeEnvs } from "../utils/environment"; @@ -42,6 +43,24 @@ export async function createDebugSessionConfiguration(debugSessionConfig: Aspire return (await prepareDebugSession(debugSessionConfig, launchConfig, args, env, launchOptions, debuggerExtension)).debugConfiguration; } +export function applyDebuggerConfigurationOverrides( + configuration: vscode.DebugConfiguration, + debugSessionConfig: AspireExtendedDebugConfiguration | undefined, + launchConfigurationType: string, + isApphost: boolean): void { + if (!debugSessionConfig?.debuggers) { + return; + } + + if (isApphost && debugSessionConfig.debuggers['apphost']) { + Object.assign(configuration, debugSessionConfig.debuggers['apphost']); + } + + if (debugSessionConfig.debuggers[launchConfigurationType]) { + Object.assign(configuration, debugSessionConfig.debuggers[launchConfigurationType]); + } +} + export async function prepareDebugSession(debugSessionConfig: AspireExtendedDebugConfiguration, launchConfig: ExecutableLaunchConfiguration, args: string[] | undefined, env: EnvVar[], launchOptions: LaunchOptions, debuggerExtension: ResourceDebuggerExtension): Promise { if (debuggerExtension === null) { extensionLogOutputChannel.warn(`Unknown type: ${launchConfig.type}.`); @@ -67,17 +86,7 @@ export async function prepareDebugSession(debugSessionConfig: AspireExtendedDebu isApphost: launchOptions.isApphost }; - if (debugSessionConfig.debuggers) { - // 1. Check if this is the apphost - if (launchOptions.isApphost && debugSessionConfig.debuggers['apphost']) { - Object.assign(configuration, debugSessionConfig.debuggers['apphost']); - } - - // 2. Check for resource type specific debugger settings - if (debugSessionConfig.debuggers[launchConfig.type]) { - Object.assign(configuration, debugSessionConfig.debuggers[launchConfig.type]); - } - } + applyDebuggerConfigurationOverrides(configuration, debugSessionConfig, launchConfig.type, launchOptions.isApphost); let alreadyStartedSession: AlreadyStartedResourceDebugSession | undefined; diff --git a/extension/src/debugger/resourceDebugContracts.ts b/extension/src/debugger/resourceDebugContracts.ts index fcbbc9d0a24..208bd9c4f96 100644 --- a/extension/src/debugger/resourceDebugContracts.ts +++ b/extension/src/debugger/resourceDebugContracts.ts @@ -14,12 +14,14 @@ export type ResourceDebugStrategy = 'auto' | 'attach'; /** * An AppHost selected by a caller. The absolute path remains internal to the editor * control plane; only the safe display path may be used by presentation layers. The - * optional process ID preserves exact tree-item identity when one path has overlapping runs. + * optional process IDs preserve exact tree-item identity and select the owning editor + * session when one path has overlapping runs. */ export interface ResourceDebugAppHostTarget { readonly absolutePath: string; readonly displayPath: string; readonly appHostPid?: number; + readonly cliPid?: number; } export interface ResourceDebugRequest { diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index 575173d8aca..af0c4df67ea 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode'; import type { AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; +import type { AspireExtendedDebugConfiguration } from '../dcp/types'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; +import { applyDebuggerConfigurationOverrides } from './debuggerExtensions'; import { ResourceAttachConfigurationError, type ResourceAttachProvider, @@ -46,6 +48,7 @@ export interface ResourceDebugServiceDependencies { readonly startDebugging: ResourceDebugStartDebugging; readonly compareAppHostIdentity?: ResourceDebugAppHostIdentityComparer; readonly isProcessAlreadyDebugged?: (processId: number) => boolean; + readonly getDebugSessionConfiguration?: (appHost: ResourceDebugAppHostTarget) => AspireExtendedDebugConfiguration | undefined; readonly telemetry?: ResourceDebugTelemetry; readonly clock?: ResourceDebugClock; } @@ -115,6 +118,7 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger absolutePath: resolvedAppHost.appHostPath, displayPath: request.appHost.displayPath, appHostPid: resolvedAppHost.appHostPid, + cliPid: resolvedAppHost.cliPid ?? undefined, }; result = await this._dependencies.sessionRegistry.runSerialized( resolvedTarget, @@ -293,6 +297,22 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger let configuration: vscode.DebugConfiguration; try { configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); + const attachIdentityProperties = ['type', 'request', 'processId', 'mode', 'debugAdapter'] + .filter(property => Object.prototype.hasOwnProperty.call(configuration, property)) + .map(property => [property, configuration[property]]); + const launchConfigurationType = resource.properties?.['resource.launchConfigurationType']; + if (typeof launchConfigurationType === 'string') { + applyDebuggerConfigurationOverrides( + configuration, + this._dependencies.getDebugSessionConfiguration?.(appHost), + launchConfigurationType, + false); + + // The provider owns the attach target and adapter contract. User settings can add + // debugger-specific options, but cannot retarget this operation to another process. + Object.assign(configuration, Object.fromEntries(attachIdentityProperties)); + configuration.noDebug = false; + } } catch (error) { if (isCommandCancellation(error) || request.cancellationToken?.isCancellationRequested) { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 58e076943fd..fb4cdcc5157 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -44,6 +44,7 @@ import { ResourceDebugService } from './debugger/resourceDebugService'; import { ResourceDebugSessionRegistry } from './debugger/resourceDebugSessionRegistry'; import { ExtensionResourceDebugTelemetry, monotonicResourceDebugClock } from './debugger/resourceDebugTelemetry'; import { initializeHotReloadAdvisory } from './debugger/hotReload'; +import { compareAppHostIdentity } from './utils/appHostIdentity'; let aspireExtensionContext = new AspireExtensionContext(); @@ -140,6 +141,19 @@ export async function activate(context: vscode.ExtensionContext) { vscode.debug.startDebugging(workspaceFolder, configuration), isProcessAlreadyDebugged: processId => aspireExtensionContext.aspireDebugSessions.some(session => session.hasResourceDebugSessionProcess(processId)), + getDebugSessionConfiguration: appHost => { + const matchingSessions = aspireExtensionContext.aspireDebugSessions.filter(session => { + if (compareAppHostIdentity(session.resolvedAppHostPath ?? session.appHostPath, appHost.absolutePath) !== 'same') { + return false; + } + + return appHost.cliPid !== undefined + ? session.cliProcessId === appHost.cliPid + : session.operationKind === 'run'; + }); + + return matchingSessions.length === 1 ? matchingSessions[0].configuration : undefined; + }, telemetry: resourceDebugTelemetry, clock: resourceDebugClock, }); diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index 29bbb412d27..d40dbfb668c 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -272,19 +272,23 @@ suite('Aspire resource debug language model tool E2E', function () { noExtensionVariables: true, }, { timeoutMs: 210000 })).result as { exitCode: number | null; stdout: string; stderr: string }; assert.strictEqual(start.exitCode, 0, `aspire start failed.\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}`); - await waitForResourceState('e2e-worker', ['Running'], 180000); - await waitForResourceState('e2e-go', ['Running'], 180000); + const workerRunning = await waitForResourceState('e2e-worker', ['Running'], 180000); + const worker = findResource(workerRunning.state, 'e2e-worker'); + assert.ok(worker); + const goRunning = await waitForResourceState('e2e-go', ['Running'], 180000); + const go = findResource(goRunning.state, 'e2e-go'); + assert.ok(go); const scenarios = [ { - resourceName: 'e2e-worker', + resourceName: worker.name, debugType: 'coreclr' as const, sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Worker', 'Program.cs'), marker: 'app.MapGet("/", () => "ok");', expectedResponse: 'ok', }, { - resourceName: 'e2e-go', + resourceName: go.name, debugType: 'go' as const, sourcePath: path.join(getWorkspaceRoot(), 'AspireE2E.Go', 'main.go'), marker: 'message := "go-ok"', diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 0227281b19e..f3c4299accd 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -633,10 +633,10 @@ suite('E2E launch profile', () => { assert.ok(csharpInstallIndex > dotnetRuntimeInstallIndex); assert.ok(resourceGroupsInstallIndex > csharpInstallIndex); assert.ok(functionsInstallIndex > resourceGroupsInstallIndex); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX')")); - assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_CSHARP_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_RESOURCE_GROUPS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(runner.includes("path: resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_AZURE_FUNCTIONS_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); assert.ok(runner.includes("const executable = isWindows ? (process.env.ComSpec || 'cmd.exe') : displayName;")); assert.ok(runner.includes("const args = isWindows ? ['/d', '/s', '/c', 'func.cmd --version'] : ['--version'];")); assert.ok(runner.includes("const certificatePassword = String.raw`Aspire E2E p@ss'\\word`;")); @@ -645,6 +645,24 @@ suite('E2E launch profile', () => { assert.strictEqual(runStep.includes('continue-on-error:'), false); }); + test('attributes missing VSIX dependencies to the selecting E2E shard', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const azureFunctionsResolver = runner.slice( + runner.indexOf('function resolveAzureFunctionsVsixPaths()'), + runner.indexOf('function resolveResourceDebugVsixPaths()')); + const resourceDebugResolver = runner.slice( + runner.indexOf('function resolveResourceDebugVsixPaths()'), + runner.indexOf('function validateResourceDebugTools()')); + + assert.ok(azureFunctionsResolver.includes( + "resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS')")); + assert.ok(resourceDebugResolver.includes( + "resolveRequiredVsixPath('ASPIRE_EXTENSION_E2E_DOTNET_RUNTIME_VSIX', 'ASPIRE_EXTENSION_E2E_ENABLE_RESOURCE_DEBUG')")); + assert.ok(runner.includes('`${environmentVariable} is required when ${selectingFeatureFlag}=true.`')); + assert.ok(runner.includes('`${environmentVariable} points to a missing file: ${resolvedPath}. It is required when ${selectingFeatureFlag}=true.`')); + }); + test('wires structured E2E harness failures into advisory handling', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index f8e3f490627..4fc7f5eefa9 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -8,6 +8,7 @@ import { ResourceAttachProviderRegistry } from '../debugger/resourceAttachProvid import { ResourceDebugAppHostIdentityComparer, ResourceDebugAppHostRepository, ResourceDebugService, ResourceDebugServiceDependencies } from '../debugger/resourceDebugService'; import { ResourceDebugSessionEvents, ResourceDebugSessionRegistry, ResourceDebugSessionRegistryOptions } from '../debugger/resourceDebugSessionRegistry'; import { ResourceAttachConfigurationError, type ResourceAttachProvider, type ResourceDebugAppHostTarget, type ResourceDebugRequest, type ResourceDebugResourceSnapshot, type ResourceDebugResult } from '../debugger/resourceDebugContracts'; +import type { AspireExtendedDebugConfiguration } from '../dcp/types'; import { extensionLogOutputChannel } from '../utils/logging'; const target: ResourceDebugAppHostTarget = { @@ -185,6 +186,7 @@ function createService(options: { clock?: { now(): number }; pendingStartTimeoutMs?: number; isProcessAlreadyDebugged?: (processId: number) => boolean; + getDebugSessionConfiguration?: (appHost: ResourceDebugAppHostTarget) => AspireExtendedDebugConfiguration | undefined; } = {}): { service: ResourceDebugService; repository: ResourceDebugAppHostRepository; @@ -219,6 +221,7 @@ function createService(options: { telemetry, clock, isProcessAlreadyDebugged: options.isProcessAlreadyDebugged, + getDebugSessionConfiguration: options.getDebugSessionConfiguration, } as unknown as ResourceDebugServiceDependencies); return { service, repository, sessions, events, telemetry }; @@ -258,6 +261,7 @@ suite('Resource debug service', () => { canAttachToResource: sinon.stub().returns(false), createDebugConfiguration: sinon.stub().rejects(new Error('first provider should not configure')), }); + const secondProvider = createProvider({ canAttachToResource: sinon.stub().returns(true), createDebugConfiguration: sinon.stub().resolves({ @@ -286,6 +290,61 @@ suite('Resource debug service', () => { } }); + test('merges Go debugger overrides while preserving the resolved attach identity', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const appHosts = [createAppHost({ cliPid: 84, resources: [createGoResource()] })]; + const provider = createGoResourceAttachProvider({ + resolveApplicationPid: async () => 4567, + }); + const { service, sessions } = createService({ + appHosts, + provider, + getDebugSessionConfiguration: appHost => { + assert.deepStrictEqual(appHost, { ...resolvedTarget, cliPid: 84 }); + return { + type: 'aspire', + name: 'AppHost', + request: 'launch', + program: target.absolutePath, + debuggers: { + go: { + name: 'Custom Go attach', + substitutePath: [{ from: '/workspace', to: '/repo' }], + trace: 'verbose', + type: 'node', + request: 'launch', + mode: 'remote', + debugAdapter: 'legacy', + processId: 9999, + noDebug: true, + }, + }, + }; + }, + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'go' }); + assert.ok(startedConfiguration); + assert.strictEqual(startedConfiguration.type, 'go'); + assert.strictEqual(startedConfiguration.request, 'attach'); + assert.strictEqual(startedConfiguration.mode, 'local'); + assert.strictEqual(startedConfiguration.debugAdapter, 'dlv-dap'); + assert.strictEqual(startedConfiguration.name, 'Custom Go attach'); + assert.strictEqual(startedConfiguration.processId, 4567); + assert.strictEqual(startedConfiguration.noDebug, false); + assert.deepStrictEqual(startedConfiguration.substitutePath, [{ from: '/workspace', to: '/repo' }]); + assert.strictEqual(startedConfiguration.trace, 'verbose'); + } + finally { + sessions.dispose(); + } + }); + test('uses a fresh AppHost snapshot instead of a tree resource', async () => { let fetchCount = 0; let configuredResource: ResourceDebugResourceSnapshot | undefined; diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs index 5f1c904118c..1c00b6b1069 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchRecipe.cs @@ -193,7 +193,7 @@ internal sealed class ExecutableLaunchContext( /// /// The resolved environment variables for the executable. /// The serialized launch configurations supplied to an IDE. -/// The arguments projected into the dashboard command line. +/// The resolved arguments and their execution and dashboard projections. internal sealed class ExecutableLaunchPlan( string command, string workingDirectory, @@ -201,7 +201,7 @@ internal sealed class ExecutableLaunchPlan( IReadOnlyList? arguments, IEnumerable> environmentVariables, IEnumerable launchConfigurations, - IEnumerable displayArguments) + IEnumerable launchArguments) { /// /// Gets the executable path or command name. @@ -235,10 +235,16 @@ internal sealed class ExecutableLaunchPlan( /// public IReadOnlyList LaunchConfigurations { get; } = launchConfigurations.ToArray(); + /// + /// Gets the resolved arguments and their execution and dashboard projections. + /// + public IReadOnlyList LaunchArguments { get; } = launchArguments.ToArray(); + /// /// Gets the arguments projected into the dashboard command line. /// - public IReadOnlyList DisplayArguments { get; } = displayArguments.ToArray(); + public IReadOnlyList DisplayArguments { get; } = + launchArguments.Where(static argument => argument.Display).ToArray(); } /// @@ -327,7 +333,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && var omittedLaunchToolArgumentCount = omitLaunchToolArguments ? launchToolArgumentCount : 0; var executableArguments = new List(arguments.Count - omittedLaunchToolArgumentCount); - var displayArguments = new List(arguments.Count); + var launchArguments = new List(arguments.Count); var nextExecutableArgumentIndex = 0; for (var i = 0; i < arguments.Count; i++) @@ -343,16 +349,13 @@ context.Decision.DebugSupport is { } activeDebugSupport && executableArguments.Add(argument.Value); } - if (display) - { - displayArguments.Add(new( - argument.Value, - argument.IsSensitive, - executable, - display, - effectiveArgumentIndex, - isLaunchToolArgument ? ExecutableLaunchArgumentRole.LaunchTool : ExecutableLaunchArgumentRole.Application)); - } + launchArguments.Add(new( + argument.Value, + argument.IsSensitive, + executable, + display, + effectiveArgumentIndex, + isLaunchToolArgument ? ExecutableLaunchArgumentRole.LaunchTool : ExecutableLaunchArgumentRole.Application)); } var launchConfigurations = await CreateLaunchConfigurationsAsync(context).ConfigureAwait(false); @@ -364,7 +367,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && executableArguments.Count > 0 ? executableArguments : null, context.ExecutionConfiguration.EnvironmentVariables, launchConfigurations, - displayArguments); + launchArguments); } private static async Task> CreateLaunchConfigurationsAsync(ExecutableLaunchContext context) @@ -524,7 +527,7 @@ context.Decision.DebugSupport is { } activeDebugSupport && projectArguments.Count > 0 ? projectArguments : null, context.ExecutionConfiguration.EnvironmentVariables, launchConfigurations, - launchArguments.Where(static argument => argument.Display)); + launchArguments); } private static async Task> CreateLaunchConfigurationsAsync( diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 6c040d86d88..ea3aa8cd5aa 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -194,6 +194,13 @@ internal static void Render( argument.Value, argument.IsSensitive, argument.EffectiveArgumentIndex))); + // Hidden launch-tool arguments are intentionally absent from resource.appArgs, but launch metadata + // still needs their sensitivity without copying their resolved values into another DCP annotation. + executable.SetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + plan.LaunchArguments + .Where(static argument => argument.IsSensitive && argument.EffectiveArgumentIndex is not null) + .Select(static argument => argument.EffectiveArgumentIndex!.Value)); ApplyLifetime(renderedResource.ModelResource, spec); ApplyTerminal(renderedResource.ModelResource, executable, logger); diff --git a/src/Aspire.Hosting/Dcp/Model/Executable.cs b/src/Aspire.Hosting/Dcp/Model/Executable.cs index 14f76a7d09e..3d2c34f124d 100644 --- a/src/Aspire.Hosting/Dcp/Model/Executable.cs +++ b/src/Aspire.Hosting/Dcp/Model/Executable.cs @@ -284,6 +284,7 @@ internal static class ExecutableState internal sealed class Executable : CustomResource, IKubernetesStaticMetadata { public const string LaunchConfigurationsAnnotation = "executable.usvc-dev.developer.microsoft.com/launch-configurations"; + public const string SensitiveEffectiveArgumentIndexesAnnotation = "executable.usvc-dev.developer.microsoft.com/sensitive-effective-argument-indexes"; [JsonConstructor] public Executable(ExecutableSpec spec) : base(spec) { } diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs index be3035639f8..3dbe24d0bf3 100644 --- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs +++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs @@ -173,7 +173,11 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn var properties = GetLaunchConfigurationType(appModelResource) is { } launchConfigurationType ? previous.Properties.SetResourceProperty(KnownProperties.Resource.LaunchConfigurationType, launchConfigurationType) : previous.Properties.RemoveResourceProperty(KnownProperties.Resource.LaunchConfigurationType); - var dotNetLaunchProperties = GetDotNetLaunchProperties(executable.Spec.ExecutablePath, effectiveArgs); + properties = properties + .RemoveResourceProperty(KnownProperties.Project.LaunchCommand) + .RemoveResourceProperty(KnownProperties.Project.Configuration) + .RemoveResourceProperty(KnownProperties.Project.TargetFramework); + var dotNetLaunchProperties = GetDotNetLaunchProperties(executable, executable.Spec.ExecutablePath, effectiveArgs); if (projectPath is not null) { @@ -244,7 +248,10 @@ private static bool IsNotStartedExecutableState(string? state) return string.IsNullOrEmpty(state) || state == ExecutableState.Unknown; } - private static ImmutableArray GetDotNetLaunchProperties(string? executablePath, IReadOnlyList? effectiveArgs) + private static ImmutableArray GetDotNetLaunchProperties( + CustomResource resource, + string? executablePath, + IReadOnlyList? effectiveArgs) { var executableName = Path.GetFileName(executablePath); if (!string.Equals(executableName, "dotnet", StringComparison.OrdinalIgnoreCase) && @@ -260,6 +267,12 @@ private static ImmutableArray GetDotNetLaunchPropertie } var (command, commandIndex) = commandInfo; + var sensitiveArgumentIndexes = GetSensitiveEffectiveArgumentIndexes(resource); + if (sensitiveArgumentIndexes.Contains(commandIndex)) + { + return [new(KnownProperties.Project.LaunchCommand, null)]; + } + string? configuration = null; string? targetFramework = null; @@ -281,25 +294,25 @@ private static ImmutableArray GetDotNetLaunchPropertie if (TryReadOptionValue(argument, "--configuration", "-c", out var inlineConfiguration)) { - configuration = inlineConfiguration; + configuration = sensitiveArgumentIndexes.Contains(index) ? null : inlineConfiguration; continue; } if (TryReadOptionValue(argument, "--framework", "-f", out var inlineTargetFramework)) { - targetFramework = inlineTargetFramework; + targetFramework = sensitiveArgumentIndexes.Contains(index) ? null : inlineTargetFramework; continue; } if (argument is "--configuration" or "-c") { - configuration = ReadNextValue(effectiveArgs, ref index); + configuration = ReadNextValue(effectiveArgs, sensitiveArgumentIndexes, ref index); continue; } if (argument is "--framework" or "-f") { - targetFramework = ReadNextValue(effectiveArgs, ref index); + targetFramework = ReadNextValue(effectiveArgs, sensitiveArgumentIndexes, ref index); } } @@ -365,15 +378,18 @@ static bool TryReadOptionValue(string argument, string longOption, string shortO return false; } - static string? ReadNextValue(IReadOnlyList arguments, ref int index) + static string? ReadNextValue(IReadOnlyList arguments, HashSet sensitiveArgumentIndexes, ref int index) { + var optionIsSensitive = sensitiveArgumentIndexes.Contains(index); if (index + 1 >= arguments.Count || arguments[index + 1] == "--") { return null; } index++; - return NormalizeValue(arguments[index]); + return optionIsSensitive || sensitiveArgumentIndexes.Contains(index) + ? null + : NormalizeValue(arguments[index]); } static string? NormalizeValue(string value) @@ -383,6 +399,28 @@ static bool TryReadOptionValue(string argument, string longOption, string shortO } } + private static HashSet GetSensitiveEffectiveArgumentIndexes(CustomResource resource) + { + if (resource.TryGetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + out List? sensitiveEffectiveArgumentIndexes)) + { + return sensitiveEffectiveArgumentIndexes.ToHashSet(); + } + + if (!resource.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out List? launchArgumentAnnotations)) + { + return []; + } + + return launchArgumentAnnotations + .Where(static annotation => annotation.IsSensitive && annotation.EffectiveArgumentIndex is not null) + .Select(static annotation => annotation.EffectiveArgumentIndex!.Value) + .ToHashSet(); + } + private static (ImmutableArray Args, ImmutableArray? ArgsAreSensitive, bool IsSensitive)? GetLaunchArgs(CustomResource resource, IReadOnlyList? effectiveArgs) { if (!resource.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out List? launchArgumentAnnotations)) diff --git a/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs index 47475558dcb..34c05de6e8d 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ExecutableLaunchPlanTests.cs @@ -210,6 +210,41 @@ [new ExecutableLaunchArgument( }); } + [Fact] + public void RendererPreservesSensitivityForHiddenEffectiveArguments() + { + var resource = new ExecutableResource("app", "dotnet", "/tmp"); + var executable = Executable.Create("app-12345678", "stale-tool"); + var renderedResource = new RenderedModelResource(resource, executable); + var plan = new ExecutableLaunchPlan( + "dotnet", + "/tmp", + ExecutableLaunchMechanism.Process, + ["run", "--configuration", "resolved-secret"], + [], + [], + [ + new("run", isSensitive: false, executable: true, display: false, effectiveArgumentIndex: 0, role: ExecutableLaunchArgumentRole.LaunchTool), + new("--configuration", isSensitive: false, executable: true, display: false, effectiveArgumentIndex: 1, role: ExecutableLaunchArgumentRole.LaunchTool), + new("resolved-secret", isSensitive: true, executable: true, display: false, effectiveArgumentIndex: 2, role: ExecutableLaunchArgumentRole.LaunchTool), + ]); + + ExecutableCreator.Render( + renderedResource, + plan, + pemCertificates: null, + NullLogger.Instance); + + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.SensitiveEffectiveArgumentIndexesAnnotation, + out var sensitiveEffectiveArgumentIndexes)); + Assert.Equal([2], sensitiveEffectiveArgumentIndexes); + Assert.True(executable.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out var displayedArguments)); + Assert.Empty(displayedArguments); + } + [Fact] public async Task ResolverRejectsMultipleLaunchRecipes() { diff --git a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs index 20786666e8b..43b2c4dd9de 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ResourceSnapshotBuilderTests.cs @@ -170,6 +170,90 @@ public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata( Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive); } + [Fact] + public void ProjectSnapshotOmitsSensitiveHiddenLaunchToolMetadata() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + "run", + "--project", + "/app/project.csproj", + "--configuration", + "resolved-configuration-secret", + "--framework=resolved-framework-secret", + }; + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + executable.SetAnnotationAsObjectList(DcpCustomResource.ResourceAppArgsAnnotation, Array.Empty()); + executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4, 5]); + + var previousSnapshot = CreatePreviousSnapshot() with + { + Properties = + [ + new(KnownProperties.Project.Configuration, "stale-configuration"), + new(KnownProperties.Project.TargetFramework, "stale-framework"), + ] + }; + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previousSnapshot); + + Assert.Equal("run", GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value); + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration)); + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.TargetFramework)); + } + + [Fact] + public void ProjectSnapshotDoesNotPublishDotNetLaunchMetadataBeforeSensitiveOverride() + { + var project = new ProjectResource("project"); + project.Annotations.Add(new TestProjectMetadata()); + + var effectiveArgs = new List + { + "run", + "--configuration", + "Release", + "--configuration", + "resolved-configuration-secret", + }; + var executable = Executable.Create("project", "dotnet"); + executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name); + executable.Status = new ExecutableStatus + { + EffectiveArgs = effectiveArgs, + ProcessId = 1234 + }; + executable.SetAnnotationAsObjectList( + DcpCustomResource.ResourceAppArgsAnnotation, + effectiveArgs.Select((argument, index) => new AppLaunchArgumentAnnotation( + argument, + isSensitive: false, + effectiveArgumentIndex: index))); + executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4]); + + var previousSnapshot = CreatePreviousSnapshot() with + { + Properties = [new(KnownProperties.Project.Configuration, "stale-configuration")] + }; + var snapshot = CreateSnapshotBuilder(new Dictionary + { + [project.Name] = project + }).ToSnapshot(executable, previousSnapshot); + + Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration)); + } + [Theory] [InlineData("run", "[env:ASPNETCORE_ENVIRONMENT=Development]", "--diagnostics")] [InlineData("watch", "-d")] From 76a1af1ae95d411640eae7ddb19ff0fce40aa470 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 19:14:27 -0500 Subject: [PATCH 84/90] Enable packaged Go attach in Linux E2E Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- .github/workflows/extension-e2e-tests.yml | 5 +++++ extension/scripts/run-e2e.js | 10 ++++++++++ extension/src/test/e2eLaunchProfile.test.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index e97f2f63580..c2b4a721394 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -577,6 +577,11 @@ jobs: run: | set -euo pipefail + # Ubuntu's Yama default allows ptrace only when the debugger is an ancestor of the target. + # DCP launches the Go resource independently from Delve, so packaged attach needs this test-only policy. + sudo sysctl --write kernel.yama.ptrace_scope=0 + test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0" + debugger_bin="$RUNNER_TEMP/resource-debug-bin" mkdir -p "$debugger_bin" GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.25.2 diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 89d44d1682e..b4efbaf157e 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -941,6 +941,16 @@ function validateResourceDebugTools() { if (result.error || result.status !== 0) { throw new Error(`The resource debug E2E shard requires dlv on PATH. ${result.error?.message ?? result.stderr ?? `exit code ${result.status}`}`); } + + if (process.platform === 'linux') { + const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope'; + if (fs.existsSync(ptraceScopePath)) { + const ptraceScope = fs.readFileSync(ptraceScopePath, 'utf8').trim(); + if (ptraceScope !== '0') { + throw new Error(`The resource debug E2E shard requires kernel.yama.ptrace_scope=0 so Delve can attach to DCP-launched Go processes, but ${ptraceScopePath} contains '${ptraceScope}'.`); + } + } + } } /** diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index f3c4299accd..3c823e297a6 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -663,6 +663,20 @@ suite('E2E launch profile', () => { assert.ok(runner.includes('`${environmentVariable} points to a missing file: ${resolvedPath}. It is required when ${selectingFeatureFlag}=true.`')); }); + test('configures Linux ptrace access for packaged Go resource attach', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const workflow = fs.readFileSync(path.join(extensionRoot, '..', '.github', 'workflows', 'extension-e2e-tests.yml'), 'utf8'); + const resourceDebugPrerequisites = workflow.slice( + workflow.indexOf('- name: Install resource debug E2E prerequisites'), + workflow.indexOf('- name: Set up the JDK for the Java E2E specs')); + + assert.ok(resourceDebugPrerequisites.includes('sudo sysctl --write kernel.yama.ptrace_scope=0')); + assert.ok(resourceDebugPrerequisites.includes('test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0"')); + assert.ok(runner.includes("const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope';")); + assert.ok(runner.includes('The resource debug E2E shard requires kernel.yama.ptrace_scope=0')); + }); + test('wires structured E2E harness failures into advisory handling', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); From 18ee65adf5eb17aa871f2b0de91060c34d58c128 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 19:53:37 -0500 Subject: [PATCH 85/90] Build Go E2E fixture for debugger attach Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- extension/scripts/run-e2e.js | 2 +- extension/src/test/e2eLaunchProfile.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index b4efbaf157e..9d4cca0bf40 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -1554,7 +1554,7 @@ ${azureFunctionsPackageReference}${goPackageReference} ? `builder.AddAzureFunctionsProject("e2e-functions", "../AspireE2E.Functions/AspireE2E.Functions.csproj");\n\n` : ''; const goResource = includeResourceDebug - ? `builder.AddGoApp("e2e-go", "../AspireE2E.Go") + ? `builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l") .WithHttpEndpoint(name: "http", env: "PORT"); ` diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 3c823e297a6..66a56ca18a7 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -677,6 +677,16 @@ suite('E2E launch profile', () => { assert.ok(runner.includes('The resource debug E2E shard requires kernel.yama.ptrace_scope=0')); }); + test('disables Go optimizations for the packaged attach breakpoint proof', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); + const resourceDebugSpec = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'resourceDebugTools.e2e.test.ts'), 'utf8'); + + assert.ok(runner.includes('builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l")')); + assert.ok(resourceDebugSpec.includes("marker: 'message := \"go-ok\"'")); + assert.ok(resourceDebugSpec.includes("proof.proof, 'aspire-resource-attach-breakpoint-detach'")); + }); + test('wires structured E2E harness failures into advisory handling', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); From c0ba84775e1b6a1c0f4cce767a351a9ca29ca5e1 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 20:30:38 -0500 Subject: [PATCH 86/90] Update Delve for Go 1.26 E2E Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- .github/workflows/extension-e2e-tests.yml | 3 ++- extension/src/test/e2eLaunchProfile.test.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index c2b4a721394..98bd0d1ad8f 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -584,7 +584,8 @@ jobs: debugger_bin="$RUNNER_TEMP/resource-debug-bin" mkdir -p "$debugger_bin" - GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.25.2 + # The hosted runner uses Go 1.26; Delve 1.25.x supports debug targets only through Go 1.25. + GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.27.1 echo "$debugger_bin" >> "$GITHUB_PATH" export PATH="$debugger_bin:$PATH" dlv version diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 66a56ca18a7..5d73ff4f914 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -671,6 +671,7 @@ suite('E2E launch profile', () => { workflow.indexOf('- name: Install resource debug E2E prerequisites'), workflow.indexOf('- name: Set up the JDK for the Java E2E specs')); + assert.ok(resourceDebugPrerequisites.includes('go install github.com/go-delve/delve/cmd/dlv@v1.27.1')); assert.ok(resourceDebugPrerequisites.includes('sudo sysctl --write kernel.yama.ptrace_scope=0')); assert.ok(resourceDebugPrerequisites.includes('test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0"')); assert.ok(runner.includes("const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope';")); From 711ff242a20c3901bb0cb51d487894b2358a5a3f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 20:42:47 -0500 Subject: [PATCH 87/90] Trigger CI for Delve compatibility fix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df From a5567bb79d60865cecc9ed94b124ff067e268342 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 22:01:11 -0500 Subject: [PATCH 88/90] Improve packaged attach E2E diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- .github/workflows/extension-e2e-tests.yml | 3 +- .../test-e2e/resourceDebugTools.e2e.test.ts | 2 +- extension/src/test/e2eLaunchProfile.test.ts | 12 +++++- extension/src/testing/e2eStateFileBridge.ts | 38 ++++++++++++++----- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 98bd0d1ad8f..c2b4a721394 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -584,8 +584,7 @@ jobs: debugger_bin="$RUNNER_TEMP/resource-debug-bin" mkdir -p "$debugger_bin" - # The hosted runner uses Go 1.26; Delve 1.25.x supports debug targets only through Go 1.25. - GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.27.1 + GOBIN="$debugger_bin" GOTOOLCHAIN=local go install github.com/go-delve/delve/cmd/dlv@v1.25.2 echo "$debugger_bin" >> "$GITHUB_PATH" export PATH="$debugger_bin:$PATH" dlv version diff --git a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts index d40dbfb668c..813d1b4fdc4 100644 --- a/extension/src/test-e2e/resourceDebugTools.e2e.test.ts +++ b/extension/src/test-e2e/resourceDebugTools.e2e.test.ts @@ -306,7 +306,7 @@ suite('Aspire resource debug language model tool E2E', function () { expectedDebugType: scenario.debugType, expectedResponse: scenario.expectedResponse, timeoutMs: 300000, - }, { timeoutMs: 330000 })).result as AttachedResourceDebugProof; + }, { timeoutMs: 360000 })).result as AttachedResourceDebugProof; assert.strictEqual(proof.proof, 'aspire-resource-attach-breakpoint-detach'); assert.strictEqual(proof.toolPayload.outcome, 'started'); diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 5d73ff4f914..c3ab098859e 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -671,7 +671,6 @@ suite('E2E launch profile', () => { workflow.indexOf('- name: Install resource debug E2E prerequisites'), workflow.indexOf('- name: Set up the JDK for the Java E2E specs')); - assert.ok(resourceDebugPrerequisites.includes('go install github.com/go-delve/delve/cmd/dlv@v1.27.1')); assert.ok(resourceDebugPrerequisites.includes('sudo sysctl --write kernel.yama.ptrace_scope=0')); assert.ok(resourceDebugPrerequisites.includes('test "$(cat /proc/sys/kernel/yama/ptrace_scope)" = "0"')); assert.ok(runner.includes("const ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope';")); @@ -688,6 +687,17 @@ suite('E2E launch profile', () => { assert.ok(resourceDebugSpec.includes("proof.proof, 'aspire-resource-attach-breakpoint-detach'")); }); + test('keeps packaged attach failures diagnosable before the outer timeout', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const bridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.ts'), 'utf8'); + const resourceDebugSpec = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'resourceDebugTools.e2e.test.ts'), 'utf8'); + + assert.ok(bridge.includes('Resource debug E2E ${session.type} setBreakpoints response:')); + assert.ok(bridge.includes('Resource debug E2E first traffic response for resource')); + assert.ok(bridge.includes('Resource debug E2E attach proof failed:')); + assert.ok(resourceDebugSpec.includes('{ timeoutMs: 360000 }')); + }); + test('wires structured E2E harness failures into advisory handling', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const runner = fs.readFileSync(path.join(extensionRoot, 'scripts', 'run-e2e.js'), 'utf8'); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index eb26a49bdf5..3364ea2778d 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -1167,23 +1167,27 @@ async function proveAttachedResourceDebugging( }); } if (message?.type === 'response' && message.command === 'setBreakpoints') { - breakpointResponses.push({ + const breakpointResponse = { sessionId: session.id, sessionType: session.type, sessionName: session.name, command: message.command, success: message.success, body: redactDebugAdapterArguments(message.body), - }); + }; + breakpointResponses.push(breakpointResponse); + extensionLogOutputChannel.info(`Resource debug E2E ${session.type} setBreakpoints response: ${JSON.stringify(breakpointResponse)}`); } if (message?.type === 'event' && message.event === 'stopped') { - stoppedEvents.push({ + const stoppedEvent = { sessionId: session.id, sessionType: session.type, sessionName: session.name, reason: message.body?.reason, threadId: message.body?.threadId, - }); + }; + stoppedEvents.push(stoppedEvent); + extensionLogOutputChannel.info(`Resource debug E2E ${session.type} stopped event: ${JSON.stringify(stoppedEvent)}`); } }, }; @@ -1337,9 +1341,7 @@ async function proveAttachedResourceDebugging( }; } catch (error) { - throw new Error(`${error instanceof Error ? error.message : String(error)} -Diagnostics: -${JSON.stringify({ + const diagnostics = { debugSessions, attachRequests, breakpointResponses, @@ -1347,7 +1349,13 @@ ${JSON.stringify({ stoppedEvents, terminatedSessionIds: [...terminatedSessionIds], toolPayload, - }, undefined, 2)}`); + }; + extensionLogOutputChannel.error(`Resource debug E2E attach proof failed: ${error instanceof Error ? error.message : String(error)} +Diagnostics: +${JSON.stringify(diagnostics, undefined, 2)}`); + throw new Error(`${error instanceof Error ? error.message : String(error)} +Diagnostics: +${JSON.stringify(diagnostics, undefined, 2)}`); } finally { vscode.debug.removeBreakpoints([breakpoint]); @@ -1908,14 +1916,24 @@ async function withResourceTraffic( resourceName, requestPath, endpointTimeoutMs); + extensionLogOutputChannel.info(`Resource debug E2E traffic target resolved for resource '${resourceName}'.`); let driving = true; + let firstAttempt = true; const driver = (async () => { while (driving) { try { - await fetch(requestUrl, { signal: AbortSignal.timeout(2000) }); + const response = await fetch(requestUrl, { signal: AbortSignal.timeout(2000) }); + if (firstAttempt) { + firstAttempt = false; + extensionLogOutputChannel.info(`Resource debug E2E first traffic response for resource '${resourceName}': HTTP ${response.status}.`); + } } - catch { + catch (error) { + if (firstAttempt) { + firstAttempt = false; + extensionLogOutputChannel.info(`Resource debug E2E first traffic attempt for resource '${resourceName}' failed: ${error instanceof Error ? error.name : typeof error}.`); + } // Connection refused until the server is listening, and aborted once a request parks on the // breakpoint. Neither says anything about whether the breakpoint bound, so both are ignored // and the wait below is left to decide. From adf031f8f04039dc03c1cea3f8270f7a77060c66 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 1 Sep 2026 23:03:50 -0500 Subject: [PATCH 89/90] Run Go attach E2E with debug symbols Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- extension/scripts/run-e2e.js | 61 +++++++++++++++++++++ extension/src/test/e2eLaunchProfile.test.ts | 3 + 2 files changed, 64 insertions(+) diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 9d4cca0bf40..9b262b21351 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -1555,6 +1555,7 @@ ${azureFunctionsPackageReference}${goPackageReference} : ''; const goResource = includeResourceDebug ? `builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l") + .WithCommand("./test-tools/go") .WithHttpEndpoint(name: "http", env: "PORT"); ` @@ -1798,6 +1799,66 @@ func main() { log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil)) } `); + + // `go run` intentionally links its temporary executable with `-s -w`, so Delve can attach to + // the process but cannot bind source breakpoints. Keep the supported AddGoApp/go-launcher shape, + // while using an unstripped child under a go-build*/exe path that the attach provider recognizes. + const debugExecutable = path.join(projectDirectory, 'go-build-debug', 'exe', isWindows ? 'e2e-go.exe' : 'e2e-go'); + fs.mkdirSync(path.dirname(debugExecutable), { recursive: true }); + buildGoE2EExecutable(projectDirectory, ['build', '-gcflags=all=-N -l', '-o', debugExecutable, '.'], 'debug target'); + + const launcherSourceDirectory = path.join(projectDirectory, 'debug-launcher'); + fs.mkdirSync(launcherSourceDirectory, { recursive: true }); + fs.writeFileSync(path.join(launcherSourceDirectory, 'main.go'), `package main + +import ( + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" +) + +func main() { + target := filepath.Join(filepath.Dir(os.Args[0]), "..", "go-build-debug", "exe", "${isWindows ? 'e2e-go.exe' : 'e2e-go'}") + command := exec.Command(target) + command.Env = os.Environ() + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + log.Fatal(err) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + go func() { + _ = command.Process.Signal(<-signals) + }() + + if err := command.Wait(); err != nil { + log.Fatal(err) + } +} +`); + + const launcherExecutable = path.join(projectDirectory, 'test-tools', isWindows ? 'go.exe' : 'go'); + fs.mkdirSync(path.dirname(launcherExecutable), { recursive: true }); + buildGoE2EExecutable(projectDirectory, ['build', '-o', launcherExecutable, './debug-launcher'], 'debug launcher'); +} + +function buildGoE2EExecutable(projectDirectory, args, description) { + const result = spawnSync('go', args, { + cwd: projectDirectory, + env: getAspireCliEnvironment(), + shell: false, + encoding: 'utf8', + timeout: 120000, + }); + if (result.error || result.status !== 0) { + throw new Error(`Unable to build the Go E2E ${description}. ${result.error?.message ?? result.stderr ?? `exit code ${result.status}`}`); + } } function resolveAppHostSdkVersion(resolvedCliPath) { diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index c3ab098859e..c939d1ed08a 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -683,6 +683,9 @@ suite('E2E launch profile', () => { const resourceDebugSpec = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'resourceDebugTools.e2e.test.ts'), 'utf8'); assert.ok(runner.includes('builder.AddGoApp("e2e-go", "../AspireE2E.Go", gcFlags: "all=-N -l")')); + assert.ok(runner.includes('.WithCommand("./test-tools/go")')); + assert.ok(runner.includes("['build', '-gcflags=all=-N -l', '-o', debugExecutable, '.']")); + assert.ok(runner.includes("'go-build-debug', 'exe'")); assert.ok(resourceDebugSpec.includes("marker: 'message := \"go-ok\"'")); assert.ok(resourceDebugSpec.includes("proof.proof, 'aspire-resource-attach-breakpoint-detach'")); }); From 5214f379643bec641cf8607d7fd870b768e8d1e1 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 2 Sep 2026 00:00:35 -0500 Subject: [PATCH 90/90] Fix resource attach safety regressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c01c030-226e-41c5-81ca-a6483a1856df --- extension/loc/xlf/aspire-vscode.xlf | 3 + extension/package.nls.json | 1 + .../src/debugger/resourceDebugService.ts | 62 ++++++-- extension/src/loc/strings.ts | 1 + extension/src/test/appHostTreeView.test.ts | 33 ++++ .../src/test/resourceDebugService.test.ts | 86 +++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 6 + .../Backchannel/AppHostConnectionResolver.cs | 8 +- .../AppHostConnectionResolverTests.cs | 143 ++++++++++++++++++ .../TestServices/TestAppHostSocket.cs | 7 + 10 files changed, 338 insertions(+), 12 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 2a45d1ed858..160378c5a58 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -1063,6 +1063,9 @@ Timeout in milliseconds for Aspire CLI commands that discover AppHost projects. Streaming discovery resets this timeout when output is received and has a maximum runtime of five minutes. Minimum: 1000. + + Trust this workspace before attaching a debugger to an Aspire resource. + Unable to add folder to workspace: {0} diff --git a/extension/package.nls.json b/extension/package.nls.json index b7d01af9b42..c29a3b1367c 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -336,6 +336,7 @@ "aspire-vscode.strings.attachDebuggerCsharpExtensionRequired": "Install the C# extension to attach the debugger to .NET project resources.", "aspire-vscode.strings.attachDebuggerExtensionsRequired": "Install {0} to attach the debugger to this resource.", "aspire-vscode.strings.attachDebuggerDeclined": "VS Code did not start the debugger attach session for {0}.", + "aspire-vscode.strings.attachDebuggerWorkspaceNotTrusted": "Trust this workspace before attaching a debugger to an Aspire resource.", "aspire-vscode.strings.appHostDeployingDescription": "Deploying...", "aspire-vscode.strings.appHostPublishingDescription": "Publishing...", "aspire-vscode.strings.appHostRunningPipelineStepDescription": "Running pipeline step...", diff --git a/extension/src/debugger/resourceDebugService.ts b/extension/src/debugger/resourceDebugService.ts index af0c4df67ea..0efb571d795 100644 --- a/extension/src/debugger/resourceDebugService.ts +++ b/extension/src/debugger/resourceDebugService.ts @@ -4,7 +4,6 @@ import type { AspireExtendedDebugConfiguration } from '../dcp/types'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; -import { applyDebuggerConfigurationOverrides } from './debuggerExtensions'; import { ResourceAttachConfigurationError, type ResourceAttachProvider, @@ -30,6 +29,35 @@ import { monotonicResourceDebugClock, } from './resourceDebugTelemetry'; +const safeAttachDebuggerOverrideProperties = { + dotnet: [ + 'name', + 'justMyCode', + 'requireExactSource', + 'suppressJITOptimizations', + 'enableStepFiltering', + 'sourceFileMap', + 'sourceLinkOptions', + 'symbolOptions', + 'logging', + 'stopAtEntry', + ], + go: [ + 'name', + 'stopOnEntry', + 'substitutePath', + 'showRegisters', + 'showGlobalVariables', + 'showLog', + 'logOutput', + 'hideSystemGoroutines', + 'stackTraceDepth', + 'showPprofLabels', + 'trace', + 'cwd', + ], +} as const; + export interface ResourceDebugAppHostRepository { fetchRunningAppHostsOnce(cancellationToken?: vscode.CancellationToken): Promise; fetchAppHostResourcesOnce(appHostPath: string, cancellationToken?: vscode.CancellationToken, appHostPid?: number): Promise; @@ -297,20 +325,13 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger let configuration: vscode.DebugConfiguration; try { configuration = await provider.createDebugConfiguration(resource, request.cancellationToken); - const attachIdentityProperties = ['type', 'request', 'processId', 'mode', 'debugAdapter'] - .filter(property => Object.prototype.hasOwnProperty.call(configuration, property)) - .map(property => [property, configuration[property]]); const launchConfigurationType = resource.properties?.['resource.launchConfigurationType']; if (typeof launchConfigurationType === 'string') { - applyDebuggerConfigurationOverrides( + applySafeAttachDebuggerOverrides( configuration, this._dependencies.getDebugSessionConfiguration?.(appHost), launchConfigurationType, - false); - - // The provider owns the attach target and adapter contract. User settings can add - // debugger-specific options, but cannot retarget this operation to another process. - Object.assign(configuration, Object.fromEntries(attachIdentityProperties)); + provider.id); configuration.noDebug = false; } } @@ -371,6 +392,27 @@ export class ResourceDebugService implements vscode.Disposable, ResourceDebugger } } +function applySafeAttachDebuggerOverrides( + configuration: vscode.DebugConfiguration, + debugSessionConfiguration: AspireExtendedDebugConfiguration | undefined, + launchConfigurationType: string, + providerId: ResourceAttachProvider['id'], +): void { + const overrides = debugSessionConfiguration?.debuggers?.[launchConfigurationType]; + if (!overrides) { + return; + } + + // A denylist would let a newly supported transport or remote-target property silently retarget + // an operation the user confirmed as a local Aspire resource. Copy only options that affect + // presentation, source mapping, symbol loading, logging, or debugger runtime behavior. + for (const property of safeAttachDebuggerOverrideProperties[providerId]) { + if (Object.prototype.hasOwnProperty.call(overrides, property)) { + configuration[property] = overrides[property]; + } + } +} + function getResourceProcessId(resource: ResourceJson): number | undefined { const value: unknown = resource.properties?.['executable.pid']; if (typeof value === 'number') { diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 716505489bd..e9f92399957 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -168,6 +168,7 @@ export const appHostDiscoveryProgress = vscode.l10n.t('Discovering AppHosts...') export const attachDebuggerConfigurationName = (resource: string) => vscode.l10n.t('Attach debugger: {0}', resource); export const attachDebuggerUnavailable = vscode.l10n.t('This resource cannot be attached to a debugger.'); export const attachDebuggerResourceNotFound = vscode.l10n.t('The selected resource is no longer available. Refresh the Aspire pane and try again.'); +export const attachDebuggerWorkspaceNotTrusted = vscode.l10n.t('Trust this workspace before attaching a debugger to an Aspire resource.'); export const attachDebuggerCsharpExtensionRequired = vscode.l10n.t('Install the C# extension to attach the debugger to .NET project resources.'); export const attachDebuggerExtensionsRequired = (labels: string) => vscode.l10n.t('Install {0} to attach the debugger to this resource.', labels); export const attachDebuggerDeclined = (resource: string) => vscode.l10n.t('VS Code did not start the debugger attach session for {0}.', resource); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index b1738d3e99c..eea82dedc40 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -3136,6 +3136,39 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { provider.dispose(); }); + test('attachDebuggerToResource rejects Restricted Mode before shared debugger work', async () => { + sandbox.stub(vscode.workspace, 'isTrusted').value(false); + const debug = sinon.stub().rejects(new Error('restricted workspaces must not invoke the shared debugger')); + const withProgress = sandbox.stub(vscode.window, 'withProgress'); + const warning = sandbox.stub(vscode.window, 'showWarningMessage'); + const resourceDebugger: ResourceDebugger = { + debug, + canAttachToResource: () => true, + }; + const provider = makeTreeProvider([ + makeAppHost({ + resources: [ + makeResource({ + name: 'api', + displayName: 'API', + resourceType: 'Project', + state: ResourceState.Running, + properties: makeAttachableProjectProperties(), + }), + ], + }), + ], 'global', undefined, resourceDebugger); + + const result = await provider.attachDebuggerToResource(getFirstResourceItem(provider)); + + assert.deepStrictEqual(result, { success: false, errorKind: 'ResourceNotAttachable' }); + assert.ok(debug.notCalled); + assert.ok(withProgress.notCalled); + assert.ok(warning.calledOnce); + assert.strictEqual(warning.firstCall.args[0], 'Trust this workspace before attaching a debugger to an Aspire resource.'); + provider.dispose(); + }); + test('attachDebuggerToResource passes the workspace AppHost path to the debug service', async () => { let request: ResourceDebugRequest | undefined; const appHostPath = '/workspace/apps/Store/AppHost.csproj'; diff --git a/extension/src/test/resourceDebugService.test.ts b/extension/src/test/resourceDebugService.test.ts index 4fc7f5eefa9..546ff5d79c4 100644 --- a/extension/src/test/resourceDebugService.test.ts +++ b/extension/src/test/resourceDebugService.test.ts @@ -311,12 +311,19 @@ suite('Resource debug service', () => { name: 'Custom Go attach', substitutePath: [{ from: '/workspace', to: '/repo' }], trace: 'verbose', + justMyCode: false, type: 'node', request: 'launch', mode: 'remote', debugAdapter: 'legacy', processId: 9999, noDebug: true, + pipeTransport: { pipeProgram: 'ssh', pipeArgs: ['remote-host'] }, + remotePath: '/remote/source', + host: 'remote-host', + port: 2345, + dlvToolPath: '/remote/dlv', + dlvFlags: ['--backend=rr'], }, }, }; @@ -339,6 +346,85 @@ suite('Resource debug service', () => { assert.strictEqual(startedConfiguration.noDebug, false); assert.deepStrictEqual(startedConfiguration.substitutePath, [{ from: '/workspace', to: '/repo' }]); assert.strictEqual(startedConfiguration.trace, 'verbose'); + assert.strictEqual(startedConfiguration.justMyCode, undefined); + assert.strictEqual(startedConfiguration.pipeTransport, undefined); + assert.strictEqual(startedConfiguration.remotePath, undefined); + assert.strictEqual(startedConfiguration.host, undefined); + assert.strictEqual(startedConfiguration.port, undefined); + assert.strictEqual(startedConfiguration.dlvToolPath, undefined); + assert.strictEqual(startedConfiguration.dlvFlags, undefined); + } + finally { + sessions.dispose(); + } + }); + + test('merges safe CoreCLR overrides without allowing a remote attach target', async () => { + let startedConfiguration: vscode.DebugConfiguration | undefined; + const provider = createProvider({ + createDebugConfiguration: async () => ({ + type: 'coreclr', + request: 'attach', + name: 'Attach debugger: API', + processId: 4321, + }), + }); + const appHosts = [createAppHost({ + resources: [createResource({ + properties: { + 'resource.launchConfigurationType': 'project', + 'project.path': '/repo/api/Api.csproj', + 'executable.path': 'dotnet', + 'executable.pid': '1234', + }, + })], + })]; + const { service, sessions } = createService({ + appHosts, + provider, + getDebugSessionConfiguration: () => ({ + type: 'aspire', + name: 'AppHost', + request: 'launch', + program: target.absolutePath, + debuggers: { + project: { + name: 'Custom .NET attach', + justMyCode: false, + sourceFileMap: { '/build': '/repo' }, + substitutePath: [{ from: '/workspace', to: '/repo' }], + trace: 'verbose', + type: 'cppdbg', + request: 'launch', + processId: 9999, + noDebug: true, + pipeTransport: { pipeProgram: 'ssh', pipeArgs: ['remote-host'] }, + remoteMachineName: 'remote-host', + debugServer: 4711, + }, + }, + }), + startDebugging: async (_folder, configuration) => { + startedConfiguration = configuration; + return true; + }, + }); + + try { + assert.deepStrictEqual(await service.debug(createRequest()), { outcome: 'started', providerId: 'dotnet' }); + assert.ok(startedConfiguration); + assert.strictEqual(startedConfiguration.type, 'coreclr'); + assert.strictEqual(startedConfiguration.request, 'attach'); + assert.strictEqual(startedConfiguration.name, 'Custom .NET attach'); + assert.strictEqual(startedConfiguration.processId, 4321); + assert.strictEqual(startedConfiguration.noDebug, false); + assert.strictEqual(startedConfiguration.justMyCode, false); + assert.deepStrictEqual(startedConfiguration.sourceFileMap, { '/build': '/repo' }); + assert.strictEqual(startedConfiguration.substitutePath, undefined); + assert.strictEqual(startedConfiguration.trace, undefined); + assert.strictEqual(startedConfiguration.pipeTransport, undefined); + assert.strictEqual(startedConfiguration.remoteMachineName, undefined); + assert.strictEqual(startedConfiguration.debugServer, undefined); } finally { sessions.dispose(); diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 2548b0d3c9e..4d2b81a0a9f 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -24,6 +24,7 @@ import { attachDebuggerAlreadyDebugging, attachDebuggerUnavailable, attachDebuggerResourceNotFound, + attachDebuggerWorkspaceNotTrusted, attachDebuggerExtensionsRequired, attachDebuggerDeclined, dashboardUrlNotFound, @@ -1515,6 +1516,11 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { + if (!vscode.workspace.isTrusted) { + vscode.window.showWarningMessage(attachDebuggerWorkspaceNotTrusted); + return { success: false, errorKind: 'ResourceNotAttachable' }; + } + return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: attachingDebugger(element.resource.displayName ?? element.resource.name), diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index c6bc2eb308f..c7b7f504ac6 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -48,8 +48,12 @@ internal sealed class AppHostConnectionResolver( CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, ILogger logger, - ProfilingTelemetry profilingTelemetry) + ProfilingTelemetry profilingTelemetry, + Func>? findSockets = null) { + private readonly Func> _findSockets = + findSockets ?? AppHostSocketManager.FindSockets; + /// /// Resolves all running AppHost connections using socket-first discovery. /// Used when stopping all running AppHosts (e.g., via --all flag). @@ -147,7 +151,7 @@ public async Task ResolveConnectionAsync( }; } - var matchingSockets = AppHostSocketManager.FindSockets( + var matchingSockets = _findSockets( projectFile.FullName, executionContext.HomeDirectory.FullName, Environment.ProcessId, diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 662f9d48a3f..56bb5aa150e 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Net; +using System.Net.Sockets; using Aspire.Cli.Backchannel; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -12,6 +14,7 @@ using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using StreamJsonRpc; namespace Aspire.Cli.Tests.Backchannel; @@ -89,6 +92,42 @@ public async Task ResolveConnectionAsync_WithExplicitProjectFile_DeletesDeadPidS Assert.False(File.Exists(socketPath)); } + [Fact] + public async Task ResolveConnectionAsync_WithExplicitProjectFileAndPid_SelectsMatchingInstance() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var projectFile = CreateProjectFile(workspace.WorkspaceRoot, "TestAppHost", "TestAppHost.csproj"); + using var otherServer = TestResolverBackchannelServer.Start(projectFile.FullName, processId: 1111); + using var requestedServer = TestResolverBackchannelServer.Start(projectFile.FullName, processId: 2222); + var resolver = new AppHostConnectionResolver( + new TestAuxiliaryBackchannelMonitor(), + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build()), + (_, _, _, _) => [otherServer.AppHostSocket, requestedServer.AppHostSocket]); + + var result = await resolver.ResolveConnectionAsync( + projectFile, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken, + appHostPid: 2222); + + Assert.True(result.Success); + Assert.Equal(2222, result.Connection.AppHostInfo?.ProcessId); + await otherServer.WaitForClientDisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var appHostInfo = await result.Connection.GetAppHostInfoV2Async(TestContext.Current.CancellationToken); + Assert.Equal("2222", appHostInfo?.Pid); + + result.Connection.Dispose(); + await requestedServer.WaitForClientDisconnectAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + [Fact] public void IsProjectResolutionError_WithNonProjectResolutionExitCode_ReturnsFalse() { @@ -580,4 +619,108 @@ private static string CreateSocketFileForKey(string socketKeyPath, DirectoryInfo File.WriteAllText(socketPath, ""); return socketPath; } + + private sealed class TestResolverBackchannelServer : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cancellationSource = new(); + private readonly List _disposables = []; + private readonly TaskCompletionSource _clientDisconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private TestResolverBackchannelServer(string appHostPath, int processId) + { + _listener.Start(); + AppHostSocket = new TestAppHostSocket($"test-socket-{processId}") + { + ConnectAsyncCallback = ConnectAsync + }; + _ = AcceptClientAsync(appHostPath, processId); + } + + public TestAppHostSocket AppHostSocket { get; } + + public static TestResolverBackchannelServer Start(string appHostPath, int processId) + => new(appHostPath, processId); + + public Task WaitForClientDisconnectAsync() => _clientDisconnected.Task; + + public void Dispose() + { + _cancellationSource.Cancel(); + foreach (var disposable in _disposables) + { + disposable.Dispose(); + } + + _listener.Stop(); + _cancellationSource.Dispose(); + } + + private async ValueTask ConnectAsync(CancellationToken cancellationToken) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + await socket.ConnectAsync((IPEndPoint)_listener.LocalEndpoint, cancellationToken); + return socket; + } + catch + { + socket.Dispose(); + throw; + } + } + + private async Task AcceptClientAsync(string appHostPath, int processId) + { + var socket = await _listener.AcceptSocketAsync(_cancellationSource.Token); + var stream = new NetworkStream(socket, ownsSocket: true); + var messageHandler = new HeaderDelimitedMessageHandler( + stream, + stream, + BackchannelJsonSerializerContext.CreateRpcMessageFormatter()); + var rpc = new JsonRpc(messageHandler, new TestResolverRpcTarget(appHostPath, processId)); + rpc.Disconnected += (_, _) => _clientDisconnected.TrySetResult(); + rpc.StartListening(); + _disposables.Add(rpc); + _disposables.Add(messageHandler); + _disposables.Add(stream); + } + } + + private sealed class TestResolverRpcTarget(string appHostPath, int processId) + { + private readonly string[] _capabilities = + [ + AuxiliaryBackchannelCapabilities.V1, + AuxiliaryBackchannelCapabilities.V2 + ]; + + public Task GetAppHostInformationAsync() + => Task.FromResult(new AppHostInformation + { + AppHostPath = appHostPath, + ProcessId = processId + }); + + public Task GetCapabilitiesAsync(GetCapabilitiesRequest? request = null) + { + _ = request; + return Task.FromResult(new GetCapabilitiesResponse + { + Capabilities = _capabilities + }); + } + + public Task GetAppHostInfoAsync(GetAppHostInfoRequest? request = null) + { + _ = request; + return Task.FromResult(new GetAppHostInfoResponse + { + Pid = processId.ToString(CultureInfo.InvariantCulture), + AppHostPath = appHostPath, + AspireHostVersion = "test" + }); + } + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs index 697f88b4249..af0922877a5 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostSocket.cs @@ -12,8 +12,15 @@ internal sealed class TestAppHostSocket(string socketPath) : IAppHostSocket public int? ProcessId { get; init; } = BackchannelConstants.ExtractPid(socketPath); + public Func>? ConnectAsyncCallback { get; init; } + public async ValueTask ConnectAsync(CancellationToken cancellationToken) { + if (ConnectAsyncCallback is not null) + { + return await ConnectAsyncCallback(cancellationToken); + } + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try {