diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs
index 5e1d6f9d6d7..b8510bd00aa 100644
--- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs
@@ -107,23 +107,77 @@ public static bool HasLaunchToolArgsOwnedBy(this IResource resource, SupportsDeb
///
/// Launch configuration is created by invoking the producer callback passed to
///
- /// (or its asynchronous overload),
- /// which owns the complete configuration; Aspire serializes the result as-is.
- /// The configuration is produced fresh on each call; it is not a singleton.
- /// Aspire may call the producer several times for the same resource.
+ /// (or one of its asynchronous overloads), which owns the complete configuration; Aspire serializes the result as-is.
+ /// The configuration is produced fresh on each call.
///
///
- /// This describes the launch configuration itself, not whether one is going to be used.
- /// Depending on how the application is started, or how a resource is configured,
- /// Aspire may or may not run the resource under a debugger. Use to test for that.
+ /// This inspection API does not resolve the resource's environment variables. A producer that accepts a
+ /// receives an empty
+ /// collection. Aspire invokes that producer
+ /// separately with resolved values when it creates the executable.
+ ///
+ ///
+ /// This describes the launch configuration itself, not whether one is going to be used. Depending on how the
+ /// application is started or how a resource is configured, Aspire may or may not run the resource under a debugger.
+ /// Use to test for that.
///
///
[AspireExportIgnore(Reason = "Debug support inspection is a local .NET helper and is not part of the ATS surface.")]
- public static Task CreateLaunchConfigurationAsync(this IResource resource, string mode, CancellationToken cancellationToken = default)
+ public static Task CreateLaunchConfigurationAsync(
+ this IResource resource,
+ string mode,
+ CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(mode);
+ var context = new LaunchConfigurationCallbackContext(
+ mode,
+ resource,
+ new Dictionary(),
+ cancellationToken);
+
+ return resource.CreateLaunchConfigurationAsync(context);
+ }
+
+ ///
+ /// Creates the launch configuration that this resource sends to the IDE using a callback context.
+ ///
+ /// The resource to inspect. It must carry a .
+ /// The callback context containing the resolved environment and launch data.
+ /// The launch configuration, typically an .
+ /// belongs to a different resource.
+ /// The resource does not declare debug launch support.
+ ///
+ ///
+ /// Launch configuration is created by invoking the producer callback passed to
+ /// ,
+ /// which owns the complete configuration; Aspire serializes the result as-is.
+ ///
+ ///
+ /// This method never resolves environment variables. Aspire creates
+ /// when the active debug-support annotation is producing a launch configuration for an executable creation.
+ ///
+ ///
+ /// This overload is internal because only Aspire constructs callback contexts containing resolved environment
+ /// variables. Use the public overload when inspecting a launch configuration outside executable creation.
+ ///
+ ///
+ internal static Task CreateLaunchConfigurationAsync(
+ this IResource resource,
+ LaunchConfigurationCallbackContext context)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!ReferenceEquals(resource, context.Resource))
+ {
+ throw new ArgumentException(
+ $"The launch configuration callback context belongs to resource '{context.Resource.Name}', " +
+ $"but launch configuration was requested for resource '{resource.Name}'.",
+ nameof(context));
+ }
+
if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation))
{
throw new InvalidOperationException(
@@ -132,7 +186,7 @@ public static Task CreateLaunchConfigurationAsync(this IResource resourc
$"Note that it only adds the annotation in run mode.");
}
- return supportsDebuggingAnnotation.LaunchConfigurationProducer(mode, cancellationToken);
+ return supportsDebuggingAnnotation.LaunchConfigurationProducer(context);
}
private static string[]? GetSupportedLaunchConfigurations(IConfiguration configuration)
diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs
index b1cec8dba26..4d8dcd62915 100644
--- a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs
+++ b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs
@@ -60,8 +60,7 @@ public static class KnownLaunchConfigurationTypes
///
///
/// Integrations create a derived type and supply it through
-///
-/// or its asynchronous overload.
+/// one of the WithDebugSupport overloads on .
///
///
/// The launch configuration type identifier, for example .
@@ -90,8 +89,8 @@ public class ExecutableLaunchConfiguration(string type)
///
/// Defaults to when a debugger is attached to the app host
/// and otherwise. The mode requested by the IDE for the
- /// current debug session is passed to the producer callback of
- /// .
+ /// current debug session is passed directly to mode-based producers and is available to context-based
+ /// producers through .
///
[JsonPropertyName("mode")]
public string Mode { get; set; } = System.Diagnostics.Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug;
diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs
new file mode 100644
index 00000000000..ad938176572
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Provides the runtime data used to create a launch configuration for a resource.
+///
+///
+/// Aspire creates this context after resolving the execution configuration for a specific executable
+/// creation. Environment variable values may contain secrets; only copy values into the launch
+/// configuration when the IDE requires them.
+///
+[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public sealed class LaunchConfigurationCallbackContext
+{
+ internal LaunchConfigurationCallbackContext(
+ string mode,
+ IResource resource,
+ IReadOnlyDictionary environmentVariables,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(mode);
+ ArgumentNullException.ThrowIfNull(resource);
+ ArgumentNullException.ThrowIfNull(environmentVariables);
+
+ Mode = mode;
+ Resource = resource;
+ EnvironmentVariables = environmentVariables;
+ CancellationToken = cancellationToken;
+ }
+
+ ///
+ /// Gets the requested launch mode, one of the values on .
+ ///
+ public string Mode { get; }
+
+ ///
+ /// Gets the resource being launched.
+ ///
+ public IResource Resource { get; }
+
+ ///
+ /// Gets the resolved environment variables used for this executable creation.
+ ///
+ ///
+ /// Values can contain secrets. Aspire serializes only the launch configuration returned by the
+ /// producer; integrations should copy only values required by the IDE.
+ ///
+ public IReadOnlyDictionary EnvironmentVariables { get; }
+
+ ///
+ /// Gets the cancellation token for this executable creation.
+ ///
+ public CancellationToken CancellationToken { get; }
+}
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index c18f7d675e0..767cfc92fe3 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -178,7 +178,7 @@ public async Task RunApplicationAsync(CancellationToken ct = default)
{
containers = _containerCreator.PrepareObjects().ToArray();
_containerCreator.PrepareContainerExecutables();
- executables = (await _executableCreator.PrepareObjectsAsync(ct).ConfigureAwait(false)).ToArray();
+ executables = _executableCreator.PrepareObjects(ct).ToArray();
prepareResourcesActivity.SetDcpPreparedResourceCounts(containers.Length, executables.Length);
}
diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs
index 2b02caacb3c..62a0f674aec 100644
--- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs
+++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs
@@ -54,10 +54,11 @@ public ExecutableCreator(
_appResources = appResources;
}
- public async Task>> PrepareObjectsAsync(CancellationToken cancellationToken)
+ public IEnumerable> PrepareObjects(CancellationToken cancellationToken)
{
- await PrepareProjectExecutablesAsync(cancellationToken).ConfigureAwait(false);
+ PrepareProjectExecutables(cancellationToken);
PreparePlainExecutables();
+
return _appResources.Get().OfType>();
}
@@ -105,7 +106,13 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC
var launchToolArgumentsData = configuration.AdditionalConfigurationData.OfType().FirstOrDefault();
var resolvedLaunchToolArgumentCount = launchToolArgumentsData?.Count ?? 0;
var hasPreparedProjectArguments = spec.Args is { Count: > 0 };
- await ApplyLaunchConfigurationAsync(er, exe, resolvedLaunchToolArgumentCount, hasPreparedProjectArguments, cancellationToken).ConfigureAwait(false);
+ await ApplyLaunchConfigurationAsync(
+ er,
+ exe,
+ configuration.EnvironmentVariables,
+ resolvedLaunchToolArgumentCount,
+ hasPreparedProjectArguments,
+ cancellationToken).ConfigureAwait(false);
ApplyResolvedProjectArguments(er, exe, resolvedLaunchToolArgumentCount);
var omittedLaunchToolArgumentCount = OmitLaunchToolArguments(er, spec)
@@ -188,62 +195,70 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC
}
///
- /// Applies the resource's debug launch configuration, now that endpoints are allocated and the launch
- /// configuration can reference endpoint URLs that were not available during .
+ /// Applies the resource's debug launch configuration after its execution configuration has been resolved.
///
///
- /// "project" launch types on configure their launch configurations in
- /// PrepareProjectExecutables() directly. Plain executables that carry and a
- /// "project" (e.g. DotnetProjectResource ) are prepared as plain
- /// executables, so their "project" launch configuration is applied here for IDE/F5 parity with AddProject .
- /// All other types (plain executables and project subtypes like azure-functions) are also handled here.
+ /// Delaying the producer until creation lets it reuse the exact resolved environment without evaluating
+ /// resource callbacks again. It also ensures endpoint-backed environment values are available.
///
private async Task ApplyLaunchConfigurationAsync(
RenderedModelResource er,
Executable exe,
+ IEnumerable> environmentVariables,
int resolvedLaunchToolArgumentCount,
bool hasPreparedProjectArguments,
CancellationToken cancellationToken)
{
if (er.ModelResource.HasAnnotationOfType()
- || HasProjectLaunchArgsOverride(er.ModelResource)
|| !er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation))
{
return;
}
- if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)
- {
- // ProjectResources already applied the "project" launch config in PrepareProjectExecutables().
- // Only plain executables carrying project metadata need it applied here.
- if (er.ModelResource is not ProjectResource)
- {
- if (er.ModelResource.TryGetProjectMetadata(out var plainProjectMetadata))
- {
- // Clear and re-apply the launch configuration to ensure proper restart behavior.
- await ApplyProjectLaunchConfigurationAsync(exe, er.ModelResource, plainProjectMetadata, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- throw new FailedToApplyEnvironmentException(
- $"Resource '{er.ModelResource.Name}' declares \"project\" debug launch support (WithDebugSupport) but has no project metadata. " +
- $"The \"project\" launch configuration type is reserved for .NET project resources; use a resource that carries {nameof(IProjectMetadata)} or a different launch configuration type.");
- }
- }
+ var isProjectLaunchConfiguration =
+ supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project;
+ var hasProjectLaunchArgsOverride = HasProjectLaunchArgsOverride(er.ModelResource);
+ // A project launch override already supplies the process invocation. A "project" producer would describe
+ // a launch mode that cannot be used, while custom producers can still contribute process-mode metadata.
+ if (hasProjectLaunchArgsOverride && isProjectLaunchConfiguration)
+ {
return;
}
- // We have non-project Executable that supports debugging; need to annotate it properly.
- // A previous launch-configuration failure can leave the reusable spec in Process mode. Restore the prepared
- // IDE mode before each attempt so a successful restart does not inherit that transient fallback.
- exe.Spec.ExecutionType = ExecutionType.IDE;
- var mode = _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug;
+ if (isProjectLaunchConfiguration && !er.ModelResource.TryGetProjectMetadata(out _))
+ {
+ throw new FailedToApplyEnvironmentException(
+ $"Resource '{er.ModelResource.Name}' declares \"project\" debug launch support (WithDebugSupport) but has no project metadata. " +
+ $"The \"project\" launch configuration type is reserved for .NET project resources; use a resource that carries {nameof(IProjectMetadata)} or a different launch configuration type.");
+ }
+
+ // A previous producer failure can leave the reusable spec in Process mode. Restore IDE execution on
+ // restart unless a project launch override intentionally keeps this resource in Process mode.
+ if (!hasProjectLaunchArgsOverride)
+ {
+ exe.Spec.ExecutionType = ExecutionType.IDE;
+ }
+
+ var mode = isProjectLaunchConfiguration
+ ? GetProjectLaunchConfigurationMode()
+ : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug;
+ var callbackContext = new LaunchConfigurationCallbackContext(
+ mode,
+ er.ModelResource,
+ environmentVariables.ToDictionary(
+ static variable => variable.Key,
+ static variable => variable.Value,
+ StringComparer.Ordinal),
+ cancellationToken);
+
try
{
- // Clear any existing launch configurations (needed for restart scenarios).
+ // Executable objects are reused for restarts, so replace the prior producer result.
exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty);
- await supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, mode, cancellationToken).ConfigureAwait(false);
+ await supportsDebuggingAnnotation
+ .LaunchConfigurationAnnotator(exe, callbackContext)
+ .ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -251,6 +266,11 @@ private async Task ApplyLaunchConfigurationAsync(
}
catch (Exception ex)
{
+ if (isProjectLaunchConfiguration)
+ {
+ throw;
+ }
+
if (HasIncompleteProcessCommand(er.ModelResource, supportsDebuggingAnnotation, resolvedLaunchToolArgumentCount, hasPreparedProjectArguments))
{
// This project-backed executable suppressed its process scaffold because the custom launch
@@ -333,8 +353,9 @@ private static bool HasIncompleteProcessCommand(
&& modelResource.HasLaunchToolArgsOwnedBy(annotation);
}
- private async Task PrepareProjectExecutablesAsync(CancellationToken cancellationToken)
+ private void PrepareProjectExecutables(CancellationToken cancellationToken)
{
+ cancellationToken.ThrowIfCancellationRequested();
var modelProjectResources = _model.GetProjectResources();
foreach (var project in modelProjectResources)
@@ -404,14 +425,8 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation
{
exe.Spec.ExecutionType = ExecutionType.IDE;
- if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)
- {
- // We want this annotation even if we are not using IDE execution; see ToSnapshot() for details.
- await ApplyProjectLaunchConfigurationAsync(exe, project, projectMetadata, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false);
- }
- // Non-project launch types (e.g. azure-functions) have their launch configuration
- // applied later in CreateExecutableAsync() after endpoints are allocated,
- // unless the IDE didn't send DEBUG_SESSION_INFO (handled by the fallback branch below).
+ // The active launch configuration producer runs later in CreateObjectAsync, after the
+ // resource's arguments and environment variables have been resolved.
// Keep a candidate Process command so custom IDE launch configurations whose launch-tool
// callback resolves empty have a runnable fallback. This also preserves the existing fallback
@@ -439,7 +454,7 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation
// support for their custom launch type.
exe.Spec.ExecutionType = ExecutionType.IDE;
- await ApplyProjectLaunchConfigurationAsync(exe, project, projectMetadata, supportsDebuggingAnnotation: null, cancellationToken).ConfigureAwait(false);
+ exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata));
if (executableAnnotation is null && projectMetadata.IsFileBasedApp)
{
@@ -987,20 +1002,6 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi
return true;
}
- private async Task ApplyProjectLaunchConfigurationAsync(Executable exe, IResource project, IProjectMetadata projectMetadata, SupportsDebuggingAnnotation? supportsDebuggingAnnotation, CancellationToken cancellationToken)
- {
- if (supportsDebuggingAnnotation?.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)
- {
- // The producer builds the complete configuration, so it is annotated as-is. Clearing first is
- // what makes restarts (where the Executable object is reused) end up with a single entry.
- exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty);
- await supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, GetProjectLaunchConfigurationMode(), cancellationToken).ConfigureAwait(false);
- return;
- }
-
- exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata));
- }
-
private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(IResource project, IProjectMetadata projectMetadata)
{
return ProjectLaunchConfigurationFactory.Create(project, projectMetadata, GetProjectLaunchConfigurationMode());
diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
index c139ad13536..4d50eb30aef 100644
--- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs
@@ -4833,29 +4833,31 @@ public static IResourceBuilder WithLaunchToolArgs(
}
///
- /// Adds support for debugging the resource in VS Code when running in an extension host.
+ /// Adds support for debugging the resource in an IDE or extension host.
///
/// The resource type.
/// The launch configuration type produced for the resource, typically derived from .
/// The resource builder.
- /// Launch configuration producer for the resource. It is passed the launch mode (one of the values on ) and produces the configuration that is handed to the IDE.
- /// The type tag of the launch configuration (as sent to the IDE).
+ /// A callback that receives the launch mode and produces the complete launch configuration handed to the IDE.
+ /// The type tag of the launch configuration sent to the IDE.
/// The .
///
- /// is a or , which means an
- /// asynchronous producer was written without the parameter and bound to this
- /// overload. Use instead.
+ /// is a or . Use an
+ /// asynchronous overload instead so the task result, rather than the task itself, becomes the launch configuration.
///
///
- /// Aspire invokes the launch configuration producer while preparing and creating the underlying orchestrator objects, and may invoke it
- /// several times for the same resource. Use
- ///
- /// when the configuration has to be resolved from work that is itself asynchronous, for example in the presence of
- /// build-argument callbacks contributed by other annotations.
+ /// Registering debug support is synchronous. Aspire invokes
+ /// later only for executable creations where this debug-support annotation is active, including restarts and replicas.
+ /// The callback does not run for unsupported debug sessions, publish mode, or inactive annotations superseded by
+ /// a later .
///
+ [OverloadResolutionPriority(-1)]
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")]
- public static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func launchConfigurationProducer, string launchConfigurationType)
+ public static IResourceBuilder WithDebugSupport(
+ this IResourceBuilder builder,
+ Func launchConfigurationProducer,
+ string launchConfigurationType)
where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
@@ -4864,38 +4866,95 @@ public static IResourceBuilder WithDebugSupport(this
if (typeof(Task).IsAssignableFrom(typeof(TLaunchConfiguration)) || IsValueTask(typeof(TLaunchConfiguration)))
{
throw new ArgumentException(
- $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. An asynchronous producer must take a {nameof(CancellationToken)} " +
- $"parameter so that it binds to the asynchronous {nameof(WithDebugSupport)} overload; otherwise the task itself is used as the launch configuration.",
+ $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. An asynchronous producer must bind to an asynchronous {nameof(WithDebugSupport)} overload " +
+ $"either by accepting the launch mode and a {nameof(CancellationToken)} or by accepting a {nameof(LaunchConfigurationCallbackContext)}; otherwise the task itself is used as the launch configuration.",
nameof(launchConfigurationProducer));
}
- return builder.WithDebugSupport((mode, _) => Task.FromResult(launchConfigurationProducer(mode)), launchConfigurationType);
+ return builder.WithDebugSupport(
+ (mode, _) => Task.FromResult(launchConfigurationProducer(mode)),
+ launchConfigurationType);
static bool IsValueTask(Type type)
=> type == typeof(ValueTask) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>));
}
///
- /// Adds support for debugging the resource in VS Code when running in an extension host,
- /// using a launch configuration that is produced asynchronously.
+ /// Adds support for asynchronously producing an IDE launch configuration for the resource.
///
/// The resource type.
/// The launch configuration type produced for the resource, typically derived from .
/// The resource builder.
- /// Launch configuration producer for the resource. It is passed the launch mode (one of the values on ) and produces the configuration that is handed to the IDE.
- /// The type of the resource.
+ /// A callback that receives the launch mode and cancellation token.
+ /// The type tag of the launch configuration sent to the IDE.
/// The .
///
- /// Use this overload when the launch configuration has to be resolved from work that is itself asynchronous, for
- /// example in the presence of build-argument callbacks contributed by other annotations. Aspire invokes the producer while preparing
- /// and creating the underlying orchestrator objects, and may invoke it several times for the same resource.
- /// A producer that computes everything synchronously should use
- ///
- /// instead.
+ /// Use this overload when producing the launch configuration requires asynchronous work. Aspire invokes the
+ /// producer after resolving the execution configuration and may invoke it several times for the same resource.
///
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")]
- public static IResourceBuilder WithDebugSupport(this IResourceBuilder builder, Func> launchConfigurationProducer, string launchConfigurationType)
+ public static IResourceBuilder WithDebugSupport(
+ this IResourceBuilder builder,
+ Func> launchConfigurationProducer,
+ string launchConfigurationType)
+ where T : IResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(launchConfigurationProducer);
+
+ return builder.WithDebugSupport(
+ context => launchConfigurationProducer(context.Mode, context.CancellationToken),
+ launchConfigurationType);
+ }
+
+ ///
+ /// Adds support for producing an IDE launch configuration from the resolved executable environment.
+ ///
+ /// The resource type.
+ /// The launch configuration type produced for the resource, typically derived from .
+ /// The resource builder.
+ ///
+ /// A callback that receives the resolved environment variables and asynchronously produces the complete
+ /// launch configuration handed to the IDE.
+ ///
+ /// The type tag of the launch configuration sent to the IDE.
+ /// The .
+ ///
+ /// Aspire invokes after resolving the execution configuration for
+ /// each executable creation. A producer that completes synchronously should return its result with
+ /// . Process execution does not generally require a producer, but
+ /// Aspire can still invoke a supported non-project producer for a process executable so it can contribute
+ /// launch metadata.
+ ///
+ ///
+ /// Produce a launch configuration using a resolved environment variable:
+ ///
+ /// internal sealed class MyToolLaunchConfiguration() : ExecutableLaunchConfiguration("mytool")
+ /// {
+ /// public string? TargetDirectory { get; set; }
+ /// }
+ ///
+ /// builder.AddExecutable("tool", "mytool", ".")
+ /// .WithDebugSupport(
+ /// context =>
+ /// {
+ /// context.EnvironmentVariables.TryGetValue("MYTOOL_TARGET_DIR", out var targetDirectory);
+ /// return Task.FromResult(new MyToolLaunchConfiguration
+ /// {
+ /// Mode = context.Mode,
+ /// TargetDirectory = targetDirectory
+ /// });
+ /// },
+ /// launchConfigurationType: "mytool");
+ ///
+ ///
+ [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")]
+ public static IResourceBuilder WithDebugSupport(
+ this IResourceBuilder builder,
+ Func> launchConfigurationProducer,
+ string launchConfigurationType)
where T : IResource
{
ArgumentNullException.ThrowIfNull(builder);
diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs
index 0363b909898..b3a01ad6bd3 100644
--- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs
+++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs
@@ -12,12 +12,9 @@ namespace Aspire.Hosting.ApplicationModel;
/// instead of being started as a plain process by Aspire.
///
///
-/// Added by
-/// (or its asynchronous overload). The
-/// annotation is only honored while a debug session is active; use
-/// to test for that, and
-/// to inspect the launch configuration
-/// the resource will send.
+/// Added by a WithDebugSupport overload on .
+/// The annotation is only honored while a debug session is active; use
+/// to test for that.
///
[DebuggerDisplay("Type = {GetType().Name,nq}, RequiredExtensionId = {LaunchConfigurationType,nq}")]
[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
@@ -25,8 +22,8 @@ public sealed class SupportsDebuggingAnnotation : IResourceAnnotation
{
private SupportsDebuggingAnnotation(
string launchConfigurationType,
- Func launchConfigurationAnnotator,
- Func> launchConfigurationProducer)
+ Func launchConfigurationAnnotator,
+ Func> launchConfigurationProducer)
{
LaunchConfigurationType = launchConfigurationType;
LaunchConfigurationAnnotator = launchConfigurationAnnotator;
@@ -38,7 +35,7 @@ private SupportsDebuggingAnnotation(
///
///
/// The IDE advertises the launch configuration types it can handle; a resource whose type is not
- /// advertised is started as a plain process instead.
+ /// advertised is started as a plain process instead.
///
/// Exception: when the active debug session does not
/// advertise any launch configuration types at all (for example Visual Studio, which does not send a
@@ -49,27 +46,32 @@ private SupportsDebuggingAnnotation(
public string LaunchConfigurationType { get; }
// Takes the internal DCP Executable object, so it stays internal even though the annotation is public.
- internal Func LaunchConfigurationAnnotator { get; }
+ internal Func LaunchConfigurationAnnotator { get; }
- // The producer callback passed to WithDebugSupport, with the launch configuration boxed as object.
- // Internal because it hands out an untyped object; DebugSupportExtensions.CreateLaunchConfigurationAsync is
- // the supported way to reach it.
- internal Func> LaunchConfigurationProducer { get; }
+ // The producer callback supplied to WithDebugSupport, with the launch configuration boxed as object.
+ // Internal because only Aspire constructs LaunchConfigurationCallbackContext values and because the
+ // untyped object is consumed by internal launch-configuration plumbing.
+ internal Func> LaunchConfigurationProducer { get; }
- internal static SupportsDebuggingAnnotation Create(string resourceName, string launchConfigurationType, Func> launchProfileProducer)
+ internal static SupportsDebuggingAnnotation Create(
+ string resourceName,
+ string launchConfigurationType,
+ Func> launchConfigurationProducer)
{
// The annotator stays generic over T so the DCP annotation is serialized against the concrete
// launch configuration type rather than a boxed object, which would change the emitted JSON.
return new SupportsDebuggingAnnotation(
launchConfigurationType,
- async (exe, mode, ct) => exe.AnnotateAsObjectList(Executable.LaunchConfigurationsAnnotation, await ProduceAsync(mode, ct).ConfigureAwait(false)),
+ async (exe, context) => exe.AnnotateAsObjectList(
+ Executable.LaunchConfigurationsAnnotation,
+ await ProduceAsync(context).ConfigureAwait(false)),
// The suppression is safe because ProduceAsync throws rather than returning null; the
// compiler cannot see that because T is unconstrained and so may be a nullable type.
- async (mode, ct) => (await ProduceAsync(mode, ct).ConfigureAwait(false))!);
+ async context => (await ProduceAsync(context).ConfigureAwait(false))!);
- async Task ProduceAsync(string mode, CancellationToken cancellationToken)
+ async Task ProduceAsync(LaunchConfigurationCallbackContext context)
{
- var launchConfiguration = await launchProfileProducer(mode, cancellationToken).ConfigureAwait(false);
+ var launchConfiguration = await launchConfigurationProducer(context).ConfigureAwait(false);
if (launchConfiguration is null)
{
throw new InvalidOperationException(
diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs
index 82500f1fde8..444b025f3ca 100644
--- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs
+++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs
@@ -209,7 +209,11 @@ public async Task AddDotnetProject_DebugAnnotator_ProducesProjectLaunchConfigura
Assert.True(app.Resource.TryGetLastAnnotation(out var supportsDebugging));
Assert.Equal(KnownLaunchConfigurationTypes.Project, supportsDebugging.LaunchConfigurationType);
- var launchConfig = Assert.IsType(await app.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
+ app.Resource,
+ ExecutableLaunchMode.Debug);
+ var launchConfig = Assert.IsType(
+ await app.Resource.CreateLaunchConfigurationAsync(callbackContext));
Assert.Equal(KnownLaunchConfigurationTypes.Project, launchConfig.Type);
Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode);
Assert.Equal(projectPath, launchConfig.ProjectPath);
@@ -240,7 +244,11 @@ await File.WriteAllTextAsync(Path.Combine(propertiesDir.FullName, "launchSetting
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
var app = builder.AddDotnetProject("svc", projectPath);
- var launchConfig = Assert.IsType(await app.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
+ app.Resource,
+ ExecutableLaunchMode.Debug);
+ var launchConfig = Assert.IsType(
+ await app.Resource.CreateLaunchConfigurationAsync(callbackContext));
Assert.False(launchConfig.DisableLaunchProfile);
Assert.Equal("http", launchConfig.LaunchProfile);
diff --git a/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs b/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs
index ce352fbd1fe..ef497889ca1 100644
--- a/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs
+++ b/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs
@@ -1188,7 +1188,8 @@ private static async Task InvokeLaunchConfigurationAnnota
Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging));
var exe = Executable.Create("test", "go");
- await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None);
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
+ await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext);
Assert.True(exe.TryGetAnnotationAsObjectList(
Executable.LaunchConfigurationsAnnotation,
diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs
index 872a01fea15..d5224a99fa3 100644
--- a/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs
+++ b/tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs
@@ -399,7 +399,8 @@ private static async Task InvokeLaunchConfigurati
Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging));
var exe = Executable.Create("test", "bun");
- await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None);
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
+ await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext);
Assert.True(exe.TryGetAnnotationAsObjectList(
Executable.LaunchConfigurationsAnnotation,
diff --git a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs
index aafe417b7d1..06e3533cefa 100644
--- a/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs
+++ b/tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs
@@ -631,7 +631,8 @@ private static async Task InvokeLaunchConfigurati
Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging));
var exe = Executable.Create("test", "node");
- await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None);
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
+ await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext);
Assert.True(exe.TryGetAnnotationAsObjectList(
Executable.LaunchConfigurationsAnnotation,
diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs
index 1c5eb7708c6..d7e2325fbf0 100644
--- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs
@@ -890,7 +890,11 @@ private static Task GetSingleMauiLaunchConfig
///
private static async Task DeserializeLaunchConfigurationAsync(IResource resource)
{
- var json = JsonSerializer.Serialize(await resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
+ resource,
+ ExecutableLaunchMode.Debug);
+ var json = JsonSerializer.Serialize(
+ await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext));
var launchConfiguration = JsonSerializer.Deserialize(json);
Assert.NotNull(launchConfiguration);
diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
index c7a5dd7b1e0..4f375a73e4f 100644
--- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
+++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
@@ -1633,7 +1633,8 @@ private static async Task InvokeLaunchConfigurationAn
Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging));
var exe = Executable.Create("test", "python");
- await supportsDebugging.LaunchConfigurationAnnotator(exe, ExecutableLaunchMode.Debug, CancellationToken.None);
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource);
+ await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext);
Assert.True(exe.TryGetAnnotationAsObjectList(
Executable.LaunchConfigurationsAnnotation,
diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs
new file mode 100644
index 00000000000..b29779b2cb3
--- /dev/null
+++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs
@@ -0,0 +1,34 @@
+// 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
+
+namespace Aspire.Hosting.Tests.Utils;
+
+public static class LaunchConfigurationTestHelpers
+{
+ public static LaunchConfigurationCallbackContext CreateCallbackContext(
+ IResource resource,
+ string mode = ExecutableLaunchMode.Debug,
+ IReadOnlyDictionary? environmentVariables = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+
+ return new LaunchConfigurationCallbackContext(
+ mode,
+ resource,
+ environmentVariables ?? new Dictionary(),
+ cancellationToken);
+ }
+
+ public static Task InvokeLaunchConfigurationProducerAsync(
+ IResource resource,
+ LaunchConfigurationCallbackContext callbackContext)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ ArgumentNullException.ThrowIfNull(callbackContext);
+
+ return resource.CreateLaunchConfigurationAsync(callbackContext);
+ }
+}
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index a86cf6c84b8..0d627b885d8 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -257,7 +257,7 @@ public async Task CreateExecutable_ToolHasCommandLineArgs_AnnotationsAdded(param
var exe = Assert.Single(executables);
string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"];
- string[] callArgs = [..dotnetToolExecArgs, ..toolArgs];
+ string[] callArgs = [.. dotnetToolExecArgs, .. toolArgs];
Assert.Equal(callArgs, exe.Spec.Args);
@@ -4499,6 +4499,73 @@ public async Task ProjectLaunchConfiguration_FallbackToFirstProfileInsertionOrde
Assert.Equal("Zed", plc.LaunchProfile); // first inserted wins
}
+ [Fact]
+ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnvironmentVariables()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ LaunchConfigurationCallbackContext? launchContext = null;
+ var environmentCallbackInvocationCount = 0;
+ var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
+ {
+ ProtocolsSupported = ["test"],
+ SupportedLaunchConfigurations = ["test"]
+ });
+ builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
+ builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
+ builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
+
+ var resource = new TestExecutableResource("test-working-directory");
+ builder.AddResource(resource)
+ .WithArgs("app-arg")
+ .WithEnvironment(context =>
+ {
+ var currentInvocation = Interlocked.Increment(ref environmentCallbackInvocationCount);
+ context.EnvironmentVariables["DEBUG_VALUE"] = $"resolved-{currentInvocation}";
+ })
+ .WithDebugSupport(
+ context =>
+ {
+ launchContext = context;
+ return Task.FromResult(new ExecutableLaunchConfiguration("test")
+ {
+ Mode = context.Mode
+ });
+ },
+ "test");
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [DcpExecutor.DebugSessionPortVar] = "12345",
+ [KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
+ [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
+ })
+ .Build();
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var appExecutor = CreateAppExecutor(
+ distributedAppModel,
+ kubernetesService: kubernetesService,
+ configuration: configuration);
+ using var cts = new CancellationTokenSource();
+
+ await appExecutor.RunApplicationAsync(cts.Token);
+
+ Assert.NotNull(launchContext);
+ Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode);
+ Assert.Same(resource, launchContext.Resource);
+ Assert.Equal(cts.Token, launchContext.CancellationToken);
+
+ var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name);
+ var debugValue = Assert.Single(executable.Spec.Env!, variable => variable.Name == "DEBUG_VALUE").Value;
+ Assert.Equal(1, Volatile.Read(ref environmentCallbackInvocationCount));
+ Assert.Equal("resolved-1", debugValue);
+ Assert.Equal(debugValue, launchContext.EnvironmentVariables["DEBUG_VALUE"]);
+ Assert.Equal(["app-arg"], executable.Spec.Args);
+ }
+
[Fact]
public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde()
{
@@ -6067,6 +6134,223 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura
Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_StillAppliesMauiLaunchConfiguration(bool useContextOverload)
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var projectBuilder = builder.AddProject("proj", launchProfileName: null);
+ var projectResource = projectBuilder.Resource;
+ var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType().FirstOrDefault();
+ if (defaultDebugSupport is not null)
+ {
+ projectBuilder.Resource.Annotations.Remove(defaultDebugSupport);
+ }
+
+#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ projectBuilder.Resource.Annotations.Add(
+ new ProjectLaunchArgsOverrideAnnotation(
+ ["build", "--no-restore", "/t:Run", "-p:NoBuild=true"],
+ leadingResourceArgumentToRemove: "run"));
+#pragma warning restore ASPIREPROJECTS001
+
+ var producerInvocationCount = 0;
+ LaunchConfigurationCallbackContext? launchContext = null;
+
+ if (useContextOverload)
+ {
+ projectBuilder.WithDebugSupport(
+ context =>
+ {
+ Interlocked.Increment(ref producerInvocationCount);
+ launchContext = context;
+ return Task.FromResult(CreateMauiLaunchConfiguration(context.Mode));
+ },
+ "maui");
+ }
+ else
+ {
+ projectBuilder.WithDebugSupport(
+ mode =>
+ {
+ Interlocked.Increment(ref producerInvocationCount);
+ return CreateMauiLaunchConfiguration(mode);
+ },
+ "maui");
+ }
+
+ projectBuilder.WithArgs("run", "-f", "net10.0-android");
+
+ var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
+ {
+ ProtocolsSupported = ["coreclr"],
+ SupportedLaunchConfigurations = ["maui"]
+ });
+ builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
+ builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
+ builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [DcpExecutor.DebugSessionPortVar] = "12345",
+ [KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
+ [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
+ [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
+ })
+ .Build();
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
+ var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration;
+ var appExecutor = CreateAppExecutor(
+ app.Services.GetRequiredService(),
+ kubernetesService: kubernetesService,
+ configuration: configuration,
+ distributedApplicationOptions: distributedApplicationOptions);
+
+ await appExecutor.RunApplicationAsync();
+
+ var executable = GetCreatedExecutableForResource(kubernetesService, "proj");
+ Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
+ Assert.Equal(1, Volatile.Read(ref producerInvocationCount));
+ var expectedArgs = new List
+ {
+ "build",
+ "--no-restore",
+ "/t:Run",
+ "-p:NoBuild=true",
+ "TestProject"
+ };
+ if (!string.IsNullOrEmpty(expectedConfiguration))
+ {
+ expectedArgs.AddRange(["--configuration", expectedConfiguration]);
+ }
+ expectedArgs.AddRange(["-f", "net10.0-android"]);
+ Assert.Equal(expectedArgs, executable.Spec.Args);
+ Assert.True(executable.TryGetAnnotationAsObjectList(
+ Executable.LaunchConfigurationsAnnotation,
+ out var launchConfigurations));
+ var launchConfiguration = Assert.Single(launchConfigurations);
+ Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
+ Assert.Equal("/mauiapp/MauiApp.csproj", launchConfiguration.ProjectPath);
+
+ if (useContextOverload)
+ {
+ Assert.NotNull(launchContext);
+ Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode);
+ Assert.Same(projectResource, launchContext.Resource);
+ }
+
+ static TestMauiLaunchConfiguration CreateMauiLaunchConfiguration(string mode) => new()
+ {
+ Mode = mode,
+ ProjectPath = "/mauiapp/MauiApp.csproj",
+ TargetFramework = "net10.0-android",
+ Platform = "android",
+ TargetKind = "emulator"
+ };
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MauiProjectWithLaunchArgsOverride_LaunchConfigurationProducerThrows_RemainsInProcessExecution(bool useContextOverload)
+ {
+ // The launch override already provides a runnable Process command. A custom launch producer can add
+ // metadata in that mode, but a producer fault must not discard the process invocation.
+ var builder = DistributedApplication.CreateBuilder();
+ var projectBuilder = builder.AddProject("proj", launchProfileName: null);
+ var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType().FirstOrDefault();
+ if (defaultDebugSupport is not null)
+ {
+ projectBuilder.Resource.Annotations.Remove(defaultDebugSupport);
+ }
+
+#pragma warning disable ASPIREPROJECTS001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ projectBuilder.Resource.Annotations.Add(
+ new ProjectLaunchArgsOverrideAnnotation(
+ ["build", "--no-restore", "/t:Run", "-p:NoBuild=true"],
+ leadingResourceArgumentToRemove: "run"));
+#pragma warning restore ASPIREPROJECTS001
+
+ var producerInvocationCount = 0;
+ if (useContextOverload)
+ {
+ projectBuilder.WithDebugSupport(
+ async Task (context) =>
+ {
+ Interlocked.Increment(ref producerInvocationCount);
+ await Task.Yield();
+ throw new InvalidOperationException("Test exception from async launch configuration producer");
+ },
+ "maui");
+ }
+ else
+ {
+ projectBuilder.WithDebugSupport(
+ TestMauiLaunchConfiguration (mode) =>
+ {
+ Interlocked.Increment(ref producerInvocationCount);
+ throw new InvalidOperationException("Test exception from launch configuration producer");
+ },
+ "maui");
+ }
+
+ projectBuilder.WithArgs("run", "-f", "net10.0-android");
+
+ var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo
+ {
+ ProtocolsSupported = ["coreclr"],
+ SupportedLaunchConfigurations = ["maui"]
+ });
+ builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345";
+ builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
+ builder.Configuration[KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug;
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [DcpExecutor.DebugSessionPortVar] = "12345",
+ [KnownConfigNames.DebugSessionInfo] = debugSessionInfo,
+ [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234",
+ [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
+ })
+ .Build();
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName };
+ var appExecutor = CreateAppExecutor(
+ app.Services.GetRequiredService(),
+ kubernetesService: kubernetesService,
+ configuration: configuration,
+ distributedApplicationOptions: distributedApplicationOptions);
+
+ await appExecutor.RunApplicationAsync();
+
+ var executable = GetCreatedExecutableForResource(kubernetesService, "proj");
+ Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
+ Assert.Equal(1, Volatile.Read(ref producerInvocationCount));
+
+ var expectedArgs = new List
+ {
+ "build",
+ "--no-restore",
+ "/t:Run",
+ "-p:NoBuild=true",
+ "TestProject"
+ };
+ if (GetTestAssemblyConfiguration() is { } configurationName)
+ {
+ expectedArgs.AddRange(["--configuration", configurationName]);
+ }
+ expectedArgs.AddRange(["-f", "net10.0-android"]);
+ Assert.Equal(expectedArgs, executable.Spec.Args);
+ }
+
[Fact]
public async Task ProjectResource_CustomIdeLaunch_OwnedLaunchToolArgsPreserveLaunchProfileArgs()
{
@@ -6535,13 +6819,10 @@ public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_InDebugSession
}
[Fact]
- public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare()
+ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate()
{
- // Regression guard for the async launch configuration producer. A ProjectResource has its "project"
- // launch configuration applied while DCP objects are *prepared* (PrepareProjectExecutablesAsync), not
- // when they are created, so this is the path that previously forced producers to be synchronous.
- // A producer that genuinely suspends must still be awaited to completion before the Executable is
- // handed to DCP; otherwise the annotation would be missing or hold an unresolved Task.
+ // Project launch configuration producers run after the execution configuration has been resolved.
+ // A producer that genuinely suspends must still complete before the executable is handed to DCP.
var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
{
AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
@@ -6549,7 +6830,7 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu
var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null);
projectBuilder.WithDebugSupport(
- async (mode, ct) =>
+ async (mode, _) =>
{
// Yield so the producer completes asynchronously rather than returning an already-completed task.
await Task.Yield();
@@ -6591,7 +6872,7 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuri
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(
- async (mode, ct) =>
+ async (mode, _) =>
{
await Task.Yield();
return new ExecutableLaunchConfiguration("test") { Mode = mode };
@@ -6631,7 +6912,7 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FallsBa
var debuggableExecutable = new TestExecutableResource("test-working-directory");
builder.AddResource(debuggableExecutable).WithDebugSupport(
- async (mode, ct) =>
+ async (_, _) =>
{
await Task.Yield();
throw new InvalidOperationException("Test exception from async launch configuration producer");
@@ -8728,7 +9009,9 @@ public async Task PlainExecutable_LaunchConfigurationProducerThrows_FallsBackToP
var builder = DistributedApplication.CreateBuilder();
var debuggableExecutable = new TestExecutableResource("test-working-directory");
- builder.AddResource(debuggableExecutable).WithDebugSupport((_, _) => throw new InvalidOperationException("Test exception from launch configuration producer"), "test");
+ builder.AddResource(debuggableExecutable).WithDebugSupport(
+ _ => throw new InvalidOperationException("Test exception from launch configuration producer"),
+ "test");
var runSessionInfo = new RunSessionInfo
{
@@ -8826,7 +9109,7 @@ public async Task Project_NonProjectLaunchConfig_AnnotatorThrows_FallsBackToProc
projectBuilder.Resource.Annotations.Remove(annotationToRemove);
}
projectBuilder.WithDebugSupport(
- (_, _) => throw new InvalidOperationException("Test exception from launch configuration producer"),
+ _ => throw new InvalidOperationException("Test exception from launch configuration producer"),
"azure-functions");
var configDict = new Dictionary
@@ -9165,13 +9448,13 @@ private static DcpExecutor CreateAppExecutor(
var nameGenerator = new DcpNameGenerator(configuration, Options.Create(dcpOptions));
var executionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
- {
- Services = new TestServiceProvider(configuration)
- .AddService(developerCertificateService)
- .AddService(distributedAppModel)
- .AddService(Options.Create(dcpOptions))
- .AddService(resourceLoggerService)
- });
+ {
+ Services = new TestServiceProvider(configuration)
+ .AddService(developerCertificateService)
+ .AddService(distributedAppModel)
+ .AddService(Options.Create(dcpOptions))
+ .AddService(resourceLoggerService)
+ });
var ks = kubernetesService ?? new TestKubernetesService();
var dcpEvts = events ?? new DcpExecutorEvents();
var fileSystemService = new FileSystemService(configuration);
diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs
index 33fc53c8c5c..b567d0a1a94 100644
--- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs
+++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs
@@ -4,9 +4,11 @@
#pragma warning disable ASPIREEXTENSION001 // Debug support APIs are experimental.
#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.
+using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.Dcp;
+using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
@@ -15,13 +17,62 @@ namespace Aspire.Hosting.Tests;
[Trait("Partition", "2")]
public class DebugSupportExtensionsTests
{
+ [Fact]
+ public void LaunchConfigurationCallbackContextExposesOnlyLaunchProducerInputs()
+ {
+ var contextType = typeof(LaunchConfigurationCallbackContext);
+
+ Assert.Empty(contextType.GetConstructors(BindingFlags.Public | BindingFlags.Instance));
+ Assert.Equal(
+ [
+ nameof(LaunchConfigurationCallbackContext.CancellationToken),
+ nameof(LaunchConfigurationCallbackContext.EnvironmentVariables),
+ nameof(LaunchConfigurationCallbackContext.Mode),
+ nameof(LaunchConfigurationCallbackContext.Resource)
+ ],
+ contextType.GetProperties().Select(property => property.Name).Order());
+ Assert.All(contextType.GetProperties(), property => Assert.Null(property.SetMethod));
+ }
+
+ [Fact]
+ public async Task CreateLaunchConfigurationInspectionOverloadCreatesResourceBoundContext()
+ {
+ var inspectionOverload = typeof(DebugSupportExtensions).GetMethod(
+ nameof(DebugSupportExtensions.CreateLaunchConfigurationAsync),
+ BindingFlags.Public | BindingFlags.Static,
+ [typeof(IResource), typeof(string), typeof(CancellationToken)]);
+
+ Assert.NotNull(inspectionOverload);
+
+ using var builder = TestDistributedApplicationBuilder.Create();
+ using var cts = new CancellationTokenSource();
+ LaunchConfigurationCallbackContext? observedContext = null;
+
+ var executable = builder.AddExecutable("app", "go", ".")
+ .WithDebugSupport(context =>
+ {
+ observedContext = context;
+ return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode });
+ }, "go");
+
+ var launchConfiguration = Assert.IsType(
+ await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug, cts.Token));
+
+ Assert.NotNull(observedContext);
+ Assert.Same(executable.Resource, observedContext.Resource);
+ Assert.Equal(ExecutableLaunchMode.NoDebug, observedContext.Mode);
+ Assert.Empty(observedContext.EnvironmentVariables);
+ Assert.Equal(cts.Token, observedContext.CancellationToken);
+ Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
+ }
+
[Fact]
public async Task CreateLaunchConfigurationResolvesTheLaunchProfileForProjectResources()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject("proj", launchProfileName: "http");
- var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug));
Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
Assert.Equal(GetProjectPath(project.Resource), launchConfiguration.ProjectPath);
@@ -38,7 +89,7 @@ public async Task CreateLaunchConfigurationDisablesTheLaunchProfileWhenTheResour
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject("proj", launchProfileName: null);
- var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug));
Assert.True(launchConfiguration.DisableLaunchProfile);
Assert.Equal(string.Empty, launchConfiguration.LaunchProfile);
@@ -69,7 +120,7 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForACustomPro
LaunchProfile = "https"
}, KnownLaunchConfigurationTypes.Project);
- var launchConfiguration = Assert.IsType(await project.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug));
+ var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.NoDebug));
Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
Assert.Equal("custom-path", launchConfiguration.ProjectPath);
@@ -83,7 +134,7 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForNonProject
var executable = builder.AddExecutable("app", "go", ".")
.WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }, "go");
- var launchConfiguration = Assert.IsType(await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug));
+ var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.NoDebug));
Assert.Equal("go", launchConfiguration.Type);
Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
@@ -97,13 +148,13 @@ public async Task CreateLaunchConfigurationAwaitsAnAsynchronousProducer()
// themselves asynchronous (for example build-argument callbacks contributed by other annotations).
using var builder = TestDistributedApplicationBuilder.Create();
var executable = builder.AddExecutable("app", "go", ".")
- .WithDebugSupport(async (mode, ct) =>
+ .WithDebugSupport(async (mode, _) =>
{
await Task.Yield();
return new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" };
}, "go");
- var launchConfiguration = Assert.IsType(await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
Assert.Equal("./cmd/api", launchConfiguration.Package);
@@ -117,13 +168,13 @@ public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToThePr
CancellationToken observedToken = default;
var executable = builder.AddExecutable("app", "go", ".")
- .WithDebugSupport((mode, ct) =>
+ .WithDebugSupport((mode, cancellationToken) =>
{
- observedToken = ct;
+ observedToken = cancellationToken;
return Task.FromResult(new TestGoLaunchConfiguration { Mode = mode });
}, "go");
- await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug, cts.Token);
+ await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug, cts.Token);
Assert.Equal(cts.Token, observedToken);
}
@@ -134,7 +185,7 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoDebugSuppor
using var builder = TestDistributedApplicationBuilder.Create();
var executable = builder.AddExecutable("app", "go", ".");
- var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
Assert.Contains("does not declare debug launch support", exception.Message);
}
@@ -146,9 +197,11 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta
// support without carrying metadata fails with a clear message rather than a sequence error.
using var builder = TestDistributedApplicationBuilder.Create();
var executable = builder.AddExecutable("app", "dotnet", ".");
- executable.WithDebugSupport(mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode), KnownLaunchConfigurationTypes.Project);
+ executable.WithDebugSupport(
+ mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode),
+ KnownLaunchConfigurationTypes.Project);
- var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
Assert.Contains("has no project metadata", exception.Message);
}
@@ -156,14 +209,11 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta
[Fact]
public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsNull()
{
- // TLaunchConfiguration is unconstrained, so a producer for a reference type can legitimately
- // return null. That must fail with a message that names the resource rather than flowing into
- // the non-nullable Task result or writing a null entry into the DCP annotation.
using var builder = TestDistributedApplicationBuilder.Create();
var executable = builder.AddExecutable("app", "go", ".")
.WithDebugSupport(_ => (TestGoLaunchConfiguration)null!, "go");
- var exception = await Assert.ThrowsAsync(() => executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
Assert.Contains("returned null", exception.Message);
Assert.Contains("app", exception.Message);
@@ -305,6 +355,19 @@ private static string CreateDebugSessionInfo(string[] supportedLaunchConfigurati
private static string GetProjectPath(IResource resource) => resource.Annotations.OfType().Last().ProjectPath;
+ private static Task CreateLaunchConfigurationForTestAsync(
+ IResource resource,
+ string mode = ExecutableLaunchMode.Debug,
+ CancellationToken cancellationToken = default)
+ {
+ var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
+ resource,
+ mode,
+ cancellationToken: cancellationToken);
+
+ return resource.CreateLaunchConfigurationAsync(callbackContext);
+ }
+
private sealed class TestGoLaunchConfiguration() : ExecutableLaunchConfiguration("go")
{
[JsonPropertyName("package")]
diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs
index 3e964e463b5..61883eef31e 100644
--- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs
+++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs
@@ -98,7 +98,11 @@ public async Task WithDebugSupportAddsAnnotationInRunMode()
var annotation = executable.Resource.Annotations.OfType().SingleOrDefault();
Assert.NotNull(annotation);
var exe = new Executable(new ExecutableSpec());
- await annotation.LaunchConfigurationAnnotator(exe, "NoDebug", CancellationToken.None);
+ await annotation.LaunchConfigurationAnnotator(
+ exe,
+ LaunchConfigurationTestHelpers.CreateCallbackContext(
+ executable.Resource,
+ ExecutableLaunchMode.NoDebug));
Assert.Equal("ms-python.python", annotation.LaunchConfigurationType);
Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var annotations));
@@ -124,14 +128,20 @@ public async Task WithDebugSupportAsynchronousProducerProducesTheSameAnnotationA
var syncExecutable = builder.AddExecutable("sync", "command", "workingdirectory")
.WithDebugSupport(mode => new ExecutableLaunchConfiguration("go") { Mode = mode }, "go");
var asyncExecutable = builder.AddExecutable("async", "command", "workingdirectory")
- .WithDebugSupport(async (mode, ct) =>
+ .WithDebugSupport(async (mode, _) =>
{
await Task.Yield();
return new ExecutableLaunchConfiguration("go") { Mode = mode };
}, "go");
- var syncConfiguration = Assert.IsType(await syncExecutable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
- var asyncConfiguration = Assert.IsType(await asyncExecutable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug));
+ var syncConfiguration = Assert.IsType(
+ await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(
+ syncExecutable.Resource,
+ LaunchConfigurationTestHelpers.CreateCallbackContext(syncExecutable.Resource)));
+ var asyncConfiguration = Assert.IsType(
+ await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(
+ asyncExecutable.Resource,
+ LaunchConfigurationTestHelpers.CreateCallbackContext(asyncExecutable.Resource)));
Assert.Equal(asyncConfiguration.Type, syncConfiguration.Type);
Assert.Equal(asyncConfiguration.Mode, syncConfiguration.Mode);
@@ -150,7 +160,7 @@ public void WithDebugSupportRejectsATaskReturningSynchronousProducer()
() => executable.WithDebugSupport(mode => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go"));
Assert.Equal("launchConfigurationProducer", exception.ParamName);
- Assert.Contains(nameof(CancellationToken), exception.Message);
+ Assert.Equal(CreateAsyncProducerGuardMessage(typeof(Task), "launchConfigurationProducer"), exception.Message);
}
[Fact]
@@ -163,6 +173,7 @@ public void WithDebugSupportRejectsAValueTaskReturningSynchronousProducer()
() => executable.WithDebugSupport(mode => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go"));
Assert.Equal("launchConfigurationProducer", exception.ParamName);
+ Assert.Equal(CreateAsyncProducerGuardMessage(typeof(ValueTask), "launchConfigurationProducer"), exception.Message);
}
[Fact]
@@ -317,6 +328,13 @@ public void WithLaunchToolArgsAreOwnedByMatchingLaunchConfigurationType()
annotation => Assert.False(executable.Resource.HasLaunchToolArgsOwnedBy(annotation)));
}
+ private static string CreateAsyncProducerGuardMessage(Type producerReturnType, string parameterName)
+ {
+ var guidance = $"The launch configuration producer returns '{producerReturnType}'. An asynchronous producer must bind to an asynchronous {nameof(ResourceBuilderExtensions.WithDebugSupport)} overload either by accepting the launch mode and a {nameof(CancellationToken)} or by accepting a {nameof(LaunchConfigurationCallbackContext)}; otherwise the task itself is used as the launch configuration.";
+
+ return new ArgumentException(guidance, parameterName).Message;
+ }
+
[Fact]
public void WithDebugSupportDoesNotOwnLaunchToolArgsWithoutWithLaunchToolArgs()
{