From 36a5310d8c97a5cf74eb61bcd9fdc2a9364d5eb0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 5 Aug 2026 19:40:43 -0400 Subject: [PATCH 01/30] Document launch configuration callback context design Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- ...h-configuration-callback-context-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md diff --git a/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md b/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md new file mode 100644 index 00000000000..503736f3a72 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md @@ -0,0 +1,228 @@ +# Launch Configuration Callback Context Design + +Issue: [#18956](https://github.com/microsoft/aspire/issues/18956) + +Related work: + +- [#18918](https://github.com/microsoft/aspire/pull/18918) made the debug-support seam public and added asynchronous launch configuration producers. +- [#18929](https://github.com/microsoft/aspire/issues/18929) tracks the separate argument-rewriting design problem. +- [#18906](https://github.com/microsoft/aspire/pull/18906) is the immediate Rust consumer. + +## Problem + +`WithDebugSupport` currently gives a launch configuration producer only the launch mode and, for the asynchronous overload, a cancellation token: + +```csharp +Func +Func> +``` + +By the time most producers run, `ExecutableCreator.CreateObjectAsync` has already built an `IExecutionConfigurationResult` and copied its resolved arguments and environment variables into the DCP executable spec. The producer cannot access that result. + +An integration that needs the resource environment must build another execution configuration. That runs `WithEnvironment` callbacks again and can produce a different result from the one Aspire actually gives the process. Rust needs the environment to resolve `CARGO_TARGET_DIR` and `CARGO_BUILD_TARGET`, so the duplicate pass can point the debugger at a binary that the real cargo invocation will not produce. + +The callback signature also has no room for the other standard runtime callback values Aspire already exposes elsewhere: the resource, application execution context, logger, and cancellation token. + +## Goals + +- Give the producer the exact `IExecutionConfigurationResult` that Aspire used for the DCP executable. +- Use the same callback context for every custom launch configuration type, including `project`. +- Follow the standard Aspire callback shape with resource, execution context, logger, and cancellation. +- Keep `WithDebugSupport` as a synchronous builder operation while allowing asynchronous producers. +- Avoid hidden configuration evaluation in the public inspection helper. +- Preserve existing launch type selection, fallback, restart, and error behavior. + +## Non-goals + +- Fix the order-sensitive `argsCallback` or split process arguments from IDE arguments. That remains [#18929](https://github.com/microsoft/aspire/issues/18929). +- Change the DCP run-session protocol. +- Remove Rust's resolved cargo-argument snapshot. The current debug argument callback has already removed `cargo run ... --` from the final execution arguments before the launch producer runs. +- Remove MAUI's environment re-resolution. MAUI resolves the environment from a command-line argument callback while the execution configuration is still being gathered, before a launch producer context exists. +- Export `WithDebugSupport` or its context to polyglot AppHosts. + +## Public API + +Add an experimental callback context under `Aspire.Hosting.ApplicationModel`: + +```csharp +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class LaunchConfigurationCallbackContext +{ + public required string Mode { get; init; } + + public required IResource Resource { get; init; } + + public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } + + public required DistributedApplicationExecutionContext ExecutionContext { get; init; } + + public ILogger Logger { get; init; } = NullLogger.Instance; + + public CancellationToken CancellationToken { get; init; } +} +``` + +`ExecutionConfiguration` exposes the full result rather than copying only arguments and environment variables. The result already models processed and unprocessed values, argument sensitivity, references, and additional gatherer data. Reusing it avoids a second DTO and lets future integrations consume other execution metadata without another callback signature change. + +Replace the two current producer overloads with one asynchronous producer: + +```csharp +[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, + Action? argsCallback = null) + where T : IResource; +``` + +There is no synchronous producer overload. A producer that does no asynchronous work returns `Task.FromResult(...)`. + +The method remains named `WithDebugSupport`, not `WithDebugSupportAsync`. Calling it only registers a callback and returns an `IResourceBuilder` synchronously. This matches `WithEnvironment`, `WithArgs`, `WithUrls`, and other Aspire builder APIs that accept callbacks returning `Task`. + +The existing `argsCallback` remains unchanged for this issue. + +## Runtime flow + +`ExecutableCreator` becomes the single place where every custom launch configuration producer runs: + +1. Prepare the DCP executable shape, execution type, fallback types, project arguments, and initial annotations. +2. Allocate endpoints. +3. Build the resource execution configuration once. +4. Populate the executable arguments and environment from that result. +5. Fail before the producer if `IExecutionConfigurationResult.Exception` is not `null`. +6. Create a fresh `LaunchConfigurationCallbackContext` with: + - the selected launch mode; + - the app model resource; + - the same execution configuration object used for the executable spec; + - the current application execution context; + - the resource logger; + - the current creation or restart cancellation token. +7. Invoke the producer and annotate the DCP executable with its returned launch configuration. + +The context is created per executable creation, restart, and replica. Aspire does not cache it or the execution configuration on the resource or annotation. + +### Project launch configurations + +Custom `project` launch configuration producers currently run from `PrepareProjectExecutablesAsync`, before the execution configuration exists. Move those producer invocations into `CreateObjectAsync` with the other custom launch types. + +Prepare-time code continues to decide whether the resource uses IDE execution and whether process fallback is available. The built-in project launch configuration used when no custom producer is active can remain prepare-time data. + +This move does not remove data needed by dashboard snapshots. `ResourceSnapshotBuilder` now derives project path and launch profile directly from the app model rather than reading the launch configuration annotation. + +### Restart and failure behavior + +Restarts rebuild the execution configuration and create a new callback context. Existing launch configuration annotations are cleared before the new result is applied. + +Configuration resolution errors continue to fail before producer invocation. A `null` task, a `null` launch configuration result, or a producer exception should produce a resource-specific diagnostic. Existing project and process-fallback behavior remains unchanged. + +## Inspection helper + +`DebugSupportExtensions.CreateLaunchConfigurationAsync` must not resolve configuration internally. Change it to accept an explicit callback context: + +```csharp +public static Task CreateLaunchConfigurationAsync( + this IResource resource, + LaunchConfigurationCallbackContext context); +``` + +The helper validates that `context.Resource` is the resource being inspected and that the supplied execution configuration succeeded. It then invokes the registered producer with that context. + +This keeps the helper useful for integration tests while making evaluation explicit. A caller that wants a real execution configuration can build one with `ExecutionConfigurationBuilder`; the helper never runs resource callbacks behind the caller's back. + +Only the producer's returned launch configuration is serialized to DCP. The callback context and execution configuration are not serialized automatically. Processed environment values can contain secrets, so integrations should only copy values into a launch configuration when the IDE requires them. + +## Existing caller migration + +Most in-tree callers only replace `mode` with `context.Mode` and wrap the result: + +```csharp +builder.WithDebugSupport( + context => Task.FromResult( + ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + KnownLaunchConfigurationTypes.Project); +``` + +Go, Python, JavaScript, Azure Functions, and MAUI can keep their typed resource or metadata closures. Their only required behavior change is returning a task and reading the launch mode from the context. + +Rust uses the additional runtime data: + +```csharp +builder.WithDebugSupport( + async context => + { + var cargoArgs = builder.Resource.ResolvedCargoArgs + ?? throw new InvalidOperationException( + $"Cargo arguments for resource '{builder.Resource.Name}' have not been resolved."); + var environment = context.ExecutionConfiguration.EnvironmentVariables + .ToDictionary(StringComparer.Ordinal); + + var executablePath = await ResolveDebugExecutablePathAsync( + builder.Resource, + workingDirectory, + context.ExecutionContext, + environment, + context.CancellationToken).ConfigureAwait(false); + + return new RustLaunchConfiguration + { + Mode = context.Mode, + WorkingDirectory = workingDirectory, + Cargo = new RustCargoLaunchTarget + { + Args = ["build", .. cargoArgs], + ExecutablePath = executablePath + } + }; + }, + "rust", + argsCallback); +``` + +This removes Rust's second environment-resolution pass. The cargo argument snapshot remains until #18929 changes when and how IDE-specific arguments are composed. + +## Testing + +Add focused coverage for: + +- a non-project executable producer receiving the same `IExecutionConfigurationResult` instance used to populate the DCP executable; +- a custom project producer receiving its context after execution configuration resolution; +- environment callbacks running once per executable creation when the launch producer reads the resolved environment; +- `Resource`, `ExecutionContext`, `Logger`, and `CancellationToken` propagation; +- restart creating a fresh context and configuration instead of reusing cached data; +- the inspection helper invoking the producer with the supplied context without evaluating resource callbacks; +- the inspection helper rejecting a context for a different resource or a failed execution configuration; +- clear failures for a producer that returns a `null` task or `null` launch configuration; +- existing launch type, fallback, and argument-rewrite behavior remaining unchanged. + +Update all current `WithDebugSupport` tests and integration call sites to the task-returning callback shape. The generated `api/*.cs` files are not edited manually. + +## Alternatives considered + +### Expose only arguments and environment variables + +Rejected. It creates another projection over `IExecutionConfigurationResult` and would require more callback properties if a producer later needs references or additional gatherer data. + +### Cache the last resolved configuration + +Rejected. Restarts, retries, replicas, and failed resolutions make cache invalidation part of the public behavior. A stale result is worse than the current duplicate evaluation because it can silently describe a previous launch. + +### Put a lazy configuration resolver on the context + +Rejected. It can still execute resource callbacks twice and does not guarantee that the producer sees the same object used for the DCP executable. + +### Keep separate synchronous and asynchronous producer overloads + +Rejected. With a single context parameter, an async lambda can also bind to the unconstrained synchronous generic overload with `TLaunchConfiguration` inferred as `Task`. The current API needs a second cancellation-token parameter and a runtime guard to avoid serializing the task itself. One task-returning producer removes that trap. + +### Rename the method to `WithDebugSupportAsync` + +Rejected. Registration is synchronous; only the deferred callback is asynchronous. + +## Success criteria + +- Launch configuration producers can consume the exact resolved execution configuration without another build pass. +- Rust no longer evaluates resource environment callbacks a second time to locate its debug executable. +- Every custom producer runs after configuration resolution through one lifecycle. +- Existing debug launch and fallback behavior remains green across hosting core and language integration tests. From fc48eb7eaf4c647ec38279d1e00ead1bf48b3ea4 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 5 Aug 2026 20:09:37 -0400 Subject: [PATCH 02/30] docs: add launch configuration callback plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- ...5-launch-configuration-callback-context.md | 1824 +++++++++++++++++ 1 file changed, 1824 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md diff --git a/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md b/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md new file mode 100644 index 00000000000..d1e941d3b53 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md @@ -0,0 +1,1824 @@ +# Launch Configuration Callback Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` (recommended) or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every `WithDebugSupport` launch configuration producer the exact resolved execution configuration and standard runtime callback data used to create its DCP executable. + +**Architecture:** Add one experimental `LaunchConfigurationCallbackContext` and one task-returning `WithDebugSupport` overload. `ExecutableCreator.CreateObjectAsync` will construct a fresh context after resolving arguments and environment variables, then invoke every active custom producer—including `project` producers—through the same path. The public inspection helper will require an explicit context so it never evaluates resource callbacks itself. + +**Tech Stack:** .NET 10, C# 13, Aspire hosting application model, DCP executable model, xUnit v3 with Microsoft.Testing.Platform + +--- + +## Scope and file structure + +This plan implements the framework change and migrates every `WithDebugSupport` caller present on `microsoft/main`. The Rust integration from PR #18906 is not present on this branch, so its consumer update remains a follow-up on that PR after this framework change is available; the exact Rust migration is included at the end of this plan. + +### New files + +- `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs` + - Owns the public runtime data passed to launch configuration producers. +- `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs` + - Creates explicit callback contexts and execution results for tests without evaluating resource callbacks. + +### Core files + +- `src/Aspire.Hosting/ResourceBuilderExtensions.cs` + - Replaces the two producer overloads with one context/task overload. +- `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs` + - Stores a context-based producer and annotator and supplies resource-specific producer diagnostics. +- `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs` + - Requires an explicit callback context for launch configuration inspection. +- `src/Aspire.Hosting/Dcp/ExecutableCreator.cs` + - Creates the context from the authoritative execution result and moves custom `project` producer invocation to creation time. +- `src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs` + - Updates XML documentation references to the final overload. + +### Production callers + +- `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs` +- `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs` +- `src/Aspire.Hosting.Go/GoHostingExtensions.cs` +- `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs` +- `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs` +- `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs` + +### Tests + +- `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` +- `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` +- `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` +- `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs` +- `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs` +- `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs` +- `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs` +- `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs` +- `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs` + +Do not edit any generated `src/*/api/*.cs` file. + +### Task 1: Add the callback contract and unify the DCP lifecycle + +**Consumed by:** Tasks 2, 3, 4 — production callers and tests compile against this contract, and all later behavior depends on the unified creation path + +**Files:** +- Create: `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs` +- Create: `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs` +- Modify: `src/Aspire.Hosting/ResourceBuilderExtensions.cs:4750-4850` +- Modify: `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs` +- Modify: `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs:75-130` +- Modify: `src/Aspire.Hosting/Dcp/ExecutableCreator.cs:60-215, 250-345, 816-839` +- Modify: `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` +- Modify: `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs:85-110` +- Modify: `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs:445-525, 2860-2930, 3110-3235, 3980-4045, 4860-4955, 7000-7140` +- Modify: `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs:200-247` +- Modify: `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs:1141-1155` +- Modify: `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs:1622-1636` +- Modify: `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs:629-643` +- Modify: `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs:397-411` +- Modify: `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs:882-898` + +- [ ] **Step 1: Restore the worktree SDK and dependencies** + +Run: + +```bash +./restore.sh +``` + +Expected: exit code `0`; the repository-local .NET SDK is ready. + +- [ ] **Step 2: Add a shared test helper for explicit callback contexts** + +Create `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs`: + +```csharp +// 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 + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Utils; + +public static class LaunchConfigurationTestHelpers +{ + public static LaunchConfigurationCallbackContext CreateCallbackContext( + IResource resource, + string mode = ExecutableLaunchMode.Debug, + IExecutionConfigurationResult? executionConfiguration = null, + DistributedApplicationExecutionContext? executionContext = null, + ILogger? logger = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return new LaunchConfigurationCallbackContext + { + Mode = mode, + Resource = resource, + ExecutionConfiguration = executionConfiguration ?? CreateExecutionConfigurationResult(), + ExecutionContext = executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + Logger = logger ?? NullLogger.Instance, + CancellationToken = cancellationToken + }; + } + + public static IExecutionConfigurationResult CreateExecutionConfigurationResult( + IEnumerable? arguments = null, + IEnumerable>? environmentVariables = null, + Exception? exception = null) + { + return new ExecutionConfigurationResult + { + References = [], + ArgumentsWithUnprocessed = (arguments ?? []) + .Select(value => ((object)value, value, false)) + .ToArray(), + EnvironmentVariablesWithUnprocessed = (environmentVariables ?? []) + .Select(pair => new KeyValuePair( + pair.Key, + (pair.Value, pair.Value))) + .ToArray(), + AdditionalConfigurationData = [], + Exception = exception + }; + } +} +``` + +- [ ] **Step 3: Write failing inspection-helper tests** + +In `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs`, add this private helper: + +```csharp +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); +} +``` + +Replace each existing call shaped as: + +```csharp +resource.CreateLaunchConfigurationAsync(mode, cancellationToken) +``` + +with: + +```csharp +CreateLaunchConfigurationForTestAsync(resource, mode, cancellationToken) +``` + +Use the two-argument helper call when the old call omitted its cancellation token: + +```csharp +CreateLaunchConfigurationForTestAsync(resource, mode) +``` + +Then add these tests: + +```csharp +[Fact] +public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks() +{ + using var builder = TestDistributedApplicationBuilder.Create(); + var environmentCallbackCount = 0; + LaunchConfigurationCallbackContext? observedContext = null; + + var executable = builder.AddExecutable("app", "go", ".") + .WithEnvironment(context => + { + Interlocked.Increment(ref environmentCallbackCount); + context.EnvironmentVariables["UNEXPECTED"] = "value"; + }) + .WithDebugSupport((LaunchConfigurationCallbackContext context) => + { + observedContext = context; + return Task.FromResult(new TestGoLaunchConfiguration + { + Mode = context.Mode, + Package = context.ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "EXPECTED") + .Value + }); + }, "go"); + + var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + environmentVariables: [new("EXPECTED", "./cmd/api")]); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.NoDebug, + executionConfiguration); + + var launchConfiguration = Assert.IsType( + await executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Same(callbackContext, observedContext); + Assert.Equal(0, environmentCallbackCount); + Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); + Assert.Equal("./cmd/api", launchConfiguration.Package); +} + +[Fact] +public async Task CreateLaunchConfigurationRejectsAContextForAnotherResource() +{ + using var builder = TestDistributedApplicationBuilder.Create(); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); + var other = builder.AddExecutable("other", "go", "."); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(other.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Equal("context", exception.ParamName); + Assert.Contains("other", exception.Message); + Assert.Contains("app", exception.Message); +} + +[Fact] +public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration() +{ + using var builder = TestDistributedApplicationBuilder.Create(); + var producerCalled = false; + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + context => + { + producerCalled = true; + return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); + }, + "go"); + var expectedException = new InvalidOperationException("configuration failed"); + var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + exception: expectedException); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + executionConfiguration: executionConfiguration); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Same(expectedException, exception); + Assert.False(producerCalled); +} + +[Fact] +public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsANullTask() +{ + using var builder = TestDistributedApplicationBuilder.Create(); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static (LaunchConfigurationCallbackContext _) => + (Task)null!, + "go"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Contains("returned a null task", exception.Message); + Assert.Contains("app", exception.Message); + Assert.Contains("go", exception.Message); +} + +[Fact] +public async Task CreateLaunchConfigurationWrapsAProducerExceptionWithResourceContext() +{ + using var builder = TestDistributedApplicationBuilder.Create(); + var producerException = new InvalidOperationException("producer failed"); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + (LaunchConfigurationCallbackContext _) => + Task.FromException(producerException), + "go"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Contains("app", exception.Message); + Assert.Contains("go", exception.Message); + Assert.Same(producerException, exception.InnerException); +} +``` + +Update the existing null-result test to use the final task shape: + +```csharp +var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static (LaunchConfigurationCallbackContext _) => + Task.FromResult(null!), + "go"); +var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); + +var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); +``` + +- [ ] **Step 4: Write the failing DCP context and lifecycle tests** + +Add `using System.Collections.Concurrent;` to `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs`. + +Add this non-project test next to the existing plain-executable debug tests: + +```csharp +[Fact] +public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration() +{ + var builder = DistributedApplication.CreateBuilder(); + var environmentCallbackCount = 0; + EnvironmentCallbackContext? environmentContext = null; + LaunchConfigurationCallbackContext? launchContext = null; + + var resource = new TestExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithEnvironment(context => + { + Interlocked.Increment(ref environmentCallbackCount); + environmentContext = context; + context.EnvironmentVariables["DEBUG_VALUE"] = "resolved"; + }) + .WithDebugSupport( + context => + { + launchContext = context; + var debugValue = context.ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "DEBUG_VALUE") + .Value; + + return Task.FromResult(new TestExecutionConfigurationLaunchConfiguration + { + Mode = context.Mode, + DebugValue = debugValue + }); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [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.Equal(1, environmentCallbackCount); + Assert.NotNull(environmentContext); + Assert.NotNull(launchContext); + Assert.Same(resource, launchContext.Resource); + Assert.Same(environmentContext.Resource, launchContext.Resource); + Assert.Same(environmentContext.ExecutionContext, launchContext.ExecutionContext); + Assert.Same(environmentContext.Logger, launchContext.Logger); + Assert.Equal(environmentContext.CancellationToken, launchContext.CancellationToken); + Assert.Equal(cts.Token, launchContext.CancellationToken); + + var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name); + Assert.Contains(executable.Spec.Env!, variable => variable is { Name: "DEBUG_VALUE", Value: "resolved" }); + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigurations)); + var launchConfiguration = Assert.Single(launchConfigurations); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); + Assert.Equal("resolved", launchConfiguration.DebugValue); +} +``` + +Add this test launch configuration near the other private launch configuration types at the bottom of the file: + +```csharp +private sealed class TestExecutionConfigurationLaunchConfiguration() + : ExecutableLaunchConfiguration("test") +{ + [JsonPropertyName("debug_value")] + public string DebugValue { get; set; } = string.Empty; +} +``` + +Add a failure-ordering test beside it: + +```csharp +[Fact] +public async Task PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer() +{ + var builder = DistributedApplication.CreateBuilder(); + var producerCalled = false; + var resource = new TestExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithEnvironment( + (EnvironmentCallbackContext _) => + throw new InvalidOperationException("environment failed")) + .WithDebugSupport( + context => + { + producerCalled = true; + return Task.FromResult( + new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }) + }) + .Build(); + var failedResources = new ConcurrentQueue(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Enqueue(context.Resource); + return Task.CompletedTask; + }); + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + configuration: configuration, + events: events); + + await appExecutor.RunApplicationAsync(); + + Assert.False(producerCalled); + Assert.Empty(kubernetesService.CreatedResources.OfType()); + Assert.Same(resource, Assert.Single(failedResources)); +} +``` + +Replace `ResourceRestarted_EnvironmentCallbacksApplied` with a restart regression that also records launch callback contexts: + +```csharp +[Fact] +public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext() +{ + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + + var callCount = 0; + var launchContexts = new ConcurrentQueue(); + var project = builder.AddProject("ServiceA") + .WithArgs(context => context.Args.Add("--test")) + .WithEnvironment(context => + { + var currentCall = Interlocked.Increment(ref callCount); + context.EnvironmentVariables["CALL_COUNT"] = currentCall.ToString(); + }) + .WithDebugSupport( + context => + { + launchContexts.Enqueue(context); + return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); + }, + KnownLaunchConfigurationTypes.Project); + var resource = project.Resource; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] + }), + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" }; + var events = new DcpExecutorEvents(); + var connectionStringAvailableCount = 0; + events.Subscribe(context => + { + if (ReferenceEquals(context.Resource, resource)) + { + Interlocked.Increment(ref connectionStringAvailableCount); + } + + return Task.CompletedTask; + }); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + dcpOptions: dcpOptions, + events: events, + configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); + var firstExecutable = Assert.Single(executables); + Assert.Contains(firstExecutable.Spec.Env!, variable => variable is { Name: "CALL_COUNT", Value: "1" }); + Assert.Single(firstExecutable.Spec.Args!, argument => argument == "--no-build"); + Assert.Single(firstExecutable.Spec.Args!, argument => argument == "--test"); + Assert.True(firstExecutable.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out var firstArgumentAnnotations)); + AssertEffectiveArgumentIndexesMatchSpecArgs(firstArgumentAnnotations, firstExecutable.Spec.Args); + Assert.Equal(1, connectionStringAvailableCount); + + var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); + await appExecutor.StopResourceAsync(reference, CancellationToken.None); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); + Assert.Equal(2, executables.Count); + var secondExecutable = executables[1]; + Assert.Contains(secondExecutable.Spec.Env!, variable => variable is { Name: "CALL_COUNT", Value: "2" }); + Assert.Single(secondExecutable.Spec.Args!, argument => argument == "--no-build"); + Assert.Single(secondExecutable.Spec.Args!, argument => argument == "--test"); + Assert.True(secondExecutable.TryGetAnnotationAsObjectList( + CustomResource.ResourceAppArgsAnnotation, + out var secondArgumentAnnotations)); + AssertEffectiveArgumentIndexesMatchSpecArgs(secondArgumentAnnotations, secondExecutable.Spec.Args); + Assert.True(secondExecutable.TryGetProjectLaunchConfiguration(out var secondLaunchConfiguration)); + Assert.NotNull(secondLaunchConfiguration); + Assert.Equal(2, connectionStringAvailableCount); + + var contexts = launchContexts.ToArray(); + Assert.Equal(2, contexts.Length); + Assert.NotSame(contexts[0], contexts[1]); + Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); + Assert.Equal( + "1", + contexts[0].ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "CALL_COUNT") + .Value); + Assert.Equal( + "2", + contexts[1].ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "CALL_COUNT") + .Value); +} +``` + +Rename `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare` to `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate`, update its comment to say that all custom producers run from `CreateObjectAsync`, and change its callback to: + +```csharp +projectBuilder.WithDebugSupport( + async context => + { + await Task.Yield(); + return new ProjectLaunchConfiguration + { + ProjectPath = "AsyncProducerPath", + Mode = context.Mode, + LaunchProfile = "async-profile" + }; + }, + KnownLaunchConfigurationTypes.Project); +``` + +Update the companion comment on `PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` to reference the renamed `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` test and to say both producer types now share the creation path. + +- [ ] **Step 5: Update direct test invocation helpers to the intended context shape** + +In `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs`, replace the direct annotator invocation with: + +```csharp +var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.NoDebug); +await annotation.LaunchConfigurationAnnotator(exe, callbackContext); +``` + +Replace the Go helper in `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs` with: + +```csharp +private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) +{ + Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); + + var exe = Executable.Create("test", "go"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); + + Assert.True(exe.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigs)); + + return Assert.Single(launchConfigs); +} +``` + +Replace the Python helper in `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs` with: + +```csharp +private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) +{ + Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); + + var exe = Executable.Create("test", "python"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); + + Assert.True(exe.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigs)); + + return Assert.Single(launchConfigs); +} +``` + +Replace the Node helper in `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs` with: + +```csharp +private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) +{ + Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); + + var exe = Executable.Create("test", "node"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); + + Assert.True(exe.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigs)); + + return Assert.Single(launchConfigs); +} +``` + +Replace the Bun helper in `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs` with: + +```csharp +private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) +{ + Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); + + var exe = Executable.Create("test", "bun"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); + await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); + + Assert.True(exe.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigs)); + + return Assert.Single(launchConfigs); +} +``` + +In `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs`, use the explicit helper: + +```csharp +var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + app.Resource, + ExecutableLaunchMode.Debug); +var launchConfig = Assert.IsType( + await app.Resource.CreateLaunchConfigurationAsync(callbackContext)); +``` + +Apply that replacement in both project launch configuration tests. + +In `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs`, replace `DeserializeLaunchConfigurationAsync` with: + +```csharp +private static async Task DeserializeLaunchConfigurationAsync(IResource resource) +{ + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + resource, + ExecutableLaunchMode.Debug); + var json = JsonSerializer.Serialize(await resource.CreateLaunchConfigurationAsync(callbackContext)); + var launchConfiguration = JsonSerializer.Deserialize(json); + Assert.NotNull(launchConfiguration); + + return launchConfiguration; +} +``` + +- [ ] **Step 6: Run the focused tests to verify the new API is missing** + +Run: + +```bash +dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-method "*.PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +``` + +Expected: FAIL at compile time because `LaunchConfigurationCallbackContext` does not exist and the current producer/annotator signatures do not accept it. + +- [ ] **Step 7: Add the public callback context** + +Create `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs`: + +```csharp +// 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; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Provides the runtime data used to create a launch configuration for a resource. +/// +/// +/// Aspire creates a new context for each executable creation, including restarts and replicas. +/// is the same resolved configuration used to populate the +/// underlying executable's arguments and environment variables. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class LaunchConfigurationCallbackContext +{ + /// + /// Gets the requested launch mode, one of the values on . + /// + public required string Mode { get; init; } + + /// + /// Gets the resource being launched. + /// + public required IResource Resource { get; init; } + + /// + /// Gets the resolved execution configuration used for the executable. + /// + /// + /// Processed environment values can contain secrets. Aspire serializes only the launch configuration + /// returned by the producer; integrations should copy values from this result only when the IDE requires them. + /// + public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } + + /// + /// Gets the execution context for the current AppHost invocation. + /// + public required DistributedApplicationExecutionContext ExecutionContext { get; init; } + + /// + /// Gets the resource logger for this executable creation. + /// + public ILogger Logger { get; init; } = NullLogger.Instance; + + /// + /// Gets the cancellation token for this executable creation. + /// + public CancellationToken CancellationToken { get; init; } +} +``` + +- [ ] **Step 8: Change the annotation to store context-based delegates and diagnose producer failures** + +In `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs`, change the constructor and internal properties to: + +```csharp +private SupportsDebuggingAnnotation( + string launchConfigurationType, + Func launchConfigurationAnnotator, + Func> launchConfigurationProducer, + bool rewritesArgumentsForDebugging) +{ + LaunchConfigurationType = launchConfigurationType; + LaunchConfigurationAnnotator = launchConfigurationAnnotator; + LaunchConfigurationProducer = launchConfigurationProducer; + RewritesArgumentsForDebugging = rewritesArgumentsForDebugging; +} + +internal Func LaunchConfigurationAnnotator { get; } + +internal Func> LaunchConfigurationProducer { get; } +``` + +Replace `Create` with: + +```csharp +internal static SupportsDebuggingAnnotation Create( + string resourceName, + string launchConfigurationType, + Func> launchConfigurationProducer, + bool rewritesArgumentsForDebugging = false) +{ + return new SupportsDebuggingAnnotation( + launchConfigurationType, + async (exe, context) => + exe.AnnotateAsObjectList( + Executable.LaunchConfigurationsAnnotation, + await ProduceAsync(context).ConfigureAwait(false)), + async context => (await ProduceAsync(context).ConfigureAwait(false))!, + rewritesArgumentsForDebugging); + + async Task ProduceAsync(LaunchConfigurationCallbackContext context) + { + Task? producerTask; + try + { + producerTask = launchConfigurationProducer(context); + } + catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) + { + throw CreateProducerException(exception); + } + + if (producerTask is null) + { + throw new InvalidOperationException( + $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned a null task. " + + "The producer must return a task that produces the complete launch configuration."); + } + + T launchConfiguration; + try + { + launchConfiguration = await producerTask.ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) + { + throw CreateProducerException(exception); + } + + if (launchConfiguration is null) + { + throw new InvalidOperationException( + $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned null. " + + "The producer owns the complete launch configuration, so it must always return one."); + } + + return launchConfiguration; + } + + InvalidOperationException CreateProducerException(Exception innerException) + { + return new InvalidOperationException( + $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' failed.", + innerException); + } +} +``` + +- [ ] **Step 9: Add the context overload while retaining temporary migration adapters** + +In `src/Aspire.Hosting/ResourceBuilderExtensions.cs`, add the final overload and move the current registration body into it: + +```csharp +[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, + Action? argsCallback = null) + where T : IResource +{ + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + var supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( + builder.Resource.Name, + launchConfigurationType, + launchConfigurationProducer, + rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder); + + if (argsCallback is not null && builder is IResourceBuilder resourceWithArgs) + { + resourceWithArgs.WithArgs(context => + { + if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) + && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) + { + argsCallback(context); + } + }); + } + + return builder.WithAnnotation(supportsDebuggingAnnotation); +} +``` + +Keep the two old overloads only until Task 3, but turn them into adapters: + +```csharp +return builder.WithDebugSupport( + context => Task.FromResult(launchConfigurationProducer(context.Mode)), + launchConfigurationType, + argsCallback); +``` + +```csharp +return builder.WithDebugSupport( + context => launchConfigurationProducer(context.Mode, context.CancellationToken), + launchConfigurationType, + argsCallback); +``` + +Retain the old sync-overload `Task`/`ValueTask` guard until Task 3 so unchanged callers keep their current diagnostic during the migration. + +- [ ] **Step 10: Make inspection consume an explicit context** + +Add `using System.Runtime.ExceptionServices;` to `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs`. + +Replace `CreateLaunchConfigurationAsync` with: + +```csharp +/// +/// Creates the launch configuration that this resource sends to the IDE using an explicitly resolved callback context. +/// +/// The resource to inspect. It must carry a . +/// The callback context containing the resolved execution configuration and launch data. +/// The launch configuration, typically an . +/// belongs to a different resource. +/// The resource does not declare debug launch support. +/// +/// This method never resolves arguments or environment variables. Callers that need a real execution +/// configuration must build it explicitly with and place it +/// on . +/// +[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, + 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 (context.ExecutionConfiguration.Exception is { } configurationException) + { + ExceptionDispatchInfo.Throw(configurationException); + } + + if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) + { + throw new InvalidOperationException( + $"Resource '{resource.Name}' does not declare debug launch support. " + + $"Call {nameof(ResourceBuilderExtensions.WithDebugSupport)} on the resource first. " + + "Note that it only adds the annotation in run mode."); + } + + return supportsDebuggingAnnotation.LaunchConfigurationProducer(context); +} +``` + +- [ ] **Step 11: Invoke every custom producer from `CreateObjectAsync`** + +In `PrepareProjectExecutablesAsync`, remove the custom producer call: + +```csharp +if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) +{ + await ApplyProjectLaunchConfigurationAsync( + exe, + project, + projectMetadata, + supportsDebuggingAnnotation, + cancellationToken).ConfigureAwait(false); +} +``` + +Keep the prepare-time `CreateProjectLaunchConfiguration(...)` calls for: + +- `ProjectLaunchArgsOverrideAnnotation` +- project resources without an active custom producer +- the Visual Studio fallback path where no supported custom launch type is active + +Because producer invocation was the only asynchronous work in project preparation, make preparation synchronous while preserving the interface's task-returning method: + +```csharp +public Task>> PrepareObjectsAsync( + CancellationToken cancellationToken) +{ + PrepareProjectExecutables(cancellationToken); + PreparePlainExecutables(); + + return Task.FromResult( + _appResources.Get().OfType>()); +} +``` + +Change the project-preparation method signature from: + +```csharp +private async Task PrepareProjectExecutablesAsync(CancellationToken cancellationToken) +``` + +to: + +```csharp +private void PrepareProjectExecutables(CancellationToken cancellationToken) +``` + +Insert this as its first statement: + +```csharp +cancellationToken.ThrowIfCancellationRequested(); +``` + +Insert the same statement as the first statement inside its `foreach (var project in modelProjectResources)` loop. The only statements removed from that loop are the active custom-producer call shown above and the `await` on the built-in fallback call, which becomes the synchronous call shown below. + +Replace the debug-producer block in `CreateObjectAsync`, after the execution configuration error check, with: + +```csharp +if (!er.ModelResource.HasAnnotationOfType() + && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) +{ + var isProjectLaunchConfiguration = + supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; + + 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."); + } + + var mode = isProjectLaunchConfiguration + ? GetProjectLaunchConfigurationMode() + : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; + var callbackContext = new LaunchConfigurationCallbackContext + { + Mode = mode, + Resource = er.ModelResource, + ExecutionConfiguration = configuration, + ExecutionContext = _executionContext, + Logger = resourceLogger, + CancellationToken = cancellationToken + }; + + try + { + // Executable objects are reused for restarts. Clear the prior launch configuration before + // applying the freshly resolved producer result. + exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); + await supportsDebuggingAnnotation + .LaunchConfigurationAnnotator(exe, callbackContext) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + !isProjectLaunchConfiguration + && !supportsDebuggingAnnotation.RewritesArgumentsForDebugging) + { + _logger.LogWarning( + exception, + "Failed to apply launch configuration for resource '{ResourceName}'. Falling back to process execution.", + er.ModelResource.Name); + exe.Spec.ExecutionType = ExecutionType.Process; + } +} +``` + +Delete the stale comments that say `project` producers run during preparation. The comment above the new block should read: + +```csharp +// Invoke the active launch configuration producer only after the resource execution configuration +// has been resolved. This gives every launch type, including "project", the exact arguments and +// environment used for this executable creation. +``` + +Replace the now-obsolete async helper with a built-in-only helper: + +```csharp +private void ApplyProjectLaunchConfiguration( + Executable exe, + IResource project, + IProjectMetadata projectMetadata) +{ + exe.SetProjectLaunchConfiguration( + ProjectLaunchConfigurationFactory.Create( + project, + projectMetadata, + GetProjectLaunchConfigurationMode())); +} +``` + +Update the Visual Studio/default fallback call in `PrepareProjectExecutablesAsync` to: + +```csharp +ApplyProjectLaunchConfiguration(exe, project, projectMetadata); +``` + +Do not call `ApplyProjectLaunchConfiguration` for an active `SupportsDebuggingAnnotation`; its producer now owns the complete result in `CreateObjectAsync`. + +Replace the remaining prepare-time producer comment with: + +```csharp +// The active custom launch configuration is applied later in CreateObjectAsync, after endpoints +// and the resource execution configuration have been resolved. +``` + +- [ ] **Step 12: Run the focused core and direct-producer tests** + +Run: + +```bash +dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-method "*.ProjectLaunchConfiguration_UsesProjectDebugSupportProducer_InDebugSession" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-method "*.AddDotnetProject_DebugAnnotator_ProducesProjectLaunchConfiguration" --filter-method "*.AddDotnetProject_LaunchConfiguration_ResolvesEffectiveLaunchProfile" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Go.Tests/Aspire.Hosting.Go.Tests.csproj --no-launch-profile -- --filter-method "*.WithVSCodeDebugging_PopulatesGoLaunchConfiguration" --filter-method "*.WithVSCodeDebugging_OmitsBuildFlagsWhenNoneConfigured" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj --no-launch-profile -- --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForScriptEntrypoint" --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForModuleEntrypoint" --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForExecutableEntrypoint" --filter-method "*.WithDebugSupport_PropagatesWorkingDirectoryOverride_ForExecutableEntrypoint" --filter-method "*.WithDebugSupport_PropagatesWorkingDirectoryOverride" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.JavaScript.Tests/Aspire.Hosting.JavaScript.Tests.csproj --no-launch-profile -- --filter-method "*.NodeApp_DirectFile_ProducesNodeRuntimeExecutable" --filter-method "*.ViteApp_DevServer_ProducesPackageManagerRuntimeExecutable" --filter-method "*.BunApp_DirectFile_ProducesBunRuntimeExecutable" --filter-method "*.BunApp_WithRunScriptAndPackageManager_ProducesBunRuntimeExecutable" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj --no-launch-profile -- --filter-method "*.AddMauiPlatform_EmitsMauiIdeLaunchConfiguration" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +``` + +Expected: all selected tests PASS. + +- [ ] **Step 13: Commit the foundational runtime change** + +```bash +git add \ + src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs \ + src/Aspire.Hosting/ResourceBuilderExtensions.cs \ + src/Aspire.Hosting/SupportsDebuggingAnnotation.cs \ + src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs \ + src/Aspire.Hosting/Dcp/ExecutableCreator.cs \ + tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs \ + tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs \ + tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs \ + tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs \ + tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs \ + tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs \ + tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs \ + tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs \ + tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs \ + tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +git commit -m "Add launch configuration callback context" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" +``` + +### Task 2: Migrate production launch configuration producers + +**Consumed by:** Tasks 3, 4 — the legacy overloads cannot be removed until every production caller uses the new contract + +**Files:** +- Modify: `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs:507-509` +- Modify: `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs:188-193` +- Modify: `src/Aspire.Hosting.Go/GoHostingExtensions.cs:699-747` +- Modify: `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs:941-1015` +- Modify: `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs:2155-2295` +- Modify: `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs:21-45` + +- [ ] **Step 1: Migrate the built-in project producer** + +In `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs`, replace the current registration with: + +```csharp +builder.WithDebugSupport( + context => Task.FromResult( + ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + KnownLaunchConfigurationTypes.Project); +``` + +Using `context.Resource` here ensures the producer consumes the same resource represented by the callback context rather than relying on a captured builder. + +- [ ] **Step 2: Migrate Azure Functions** + +In `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs`, replace the producer with: + +```csharp +.WithDebugSupport( + context => Task.FromResult(new AzureFunctionsLaunchConfiguration + { + ProjectPath = projectMetadata.ProjectPath, + Mode = context.Mode + }), + "azure-functions"); +``` + +- [ ] **Step 3: Migrate Go** + +In `src/Aspire.Hosting.Go/GoHostingExtensions.cs`, replace the launch producer body with: + +```csharp +return builder.WithDebugSupport( + context => + { + // Resolve annotations when DCP creates the launch configuration so later + // resource mutations such as WithWorkingDirectory(...) are reflected. + var workingDirectory = Path.GetFullPath(resource.WorkingDirectory); + var packagePath = resource.TryGetLastAnnotation(out var packagePathAnnotation) + ? packagePathAnnotation.PackagePath + : "."; + var buildFlags = BuildFlagsString(resource); + + return Task.FromResult(new GoLaunchConfiguration + { + Program = Path.GetFullPath(packagePath, workingDirectory), + Mode = context.Mode, + WorkingDirectory = workingDirectory, + BuildFlags = buildFlags.Length > 0 ? buildFlags : null + }); + }, + "go", + static context => + { + if (context.Args is not [string runCommand, ..] || runCommand != "run") + { + return; + } + + context.Args.RemoveAt(0); + + while (context.Args is [string arg, ..] && IsGoRunBuildFlag(arg)) + { + context.Args.RemoveAt(0); + } + + if (context.Args.Count > 0) + { + context.Args.RemoveAt(0); + } + }); +``` + +Keep the existing raw command-shape comment above the argument rewrite. + +- [ ] **Step 4: Migrate Python** + +In `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs`, keep the existing path/interpreter logic and change only the callback shape and return: + +```csharp +builder.WithDebugSupport( + context => + { + var workingDirectory = builder.Resource.WorkingDirectory; + + string programPath; + string module; + + if (entrypointType == EntrypointType.Script) + { + programPath = Path.GetFullPath(entrypoint, workingDirectory); + module = string.Empty; + } + else + { + programPath = workingDirectory; + module = entrypoint; + } + + string interpreterPath; + if (!builder.Resource.TryGetLastAnnotation(out var annotation) + || annotation.VirtualEnvironment is null) + { + interpreterPath = string.Empty; + } + else + { + var venvPath = Path.IsPathRooted(annotation.VirtualEnvironment.VirtualEnvironmentPath) + ? annotation.VirtualEnvironment.VirtualEnvironmentPath + : Path.GetFullPath(annotation.VirtualEnvironment.VirtualEnvironmentPath, workingDirectory); + + interpreterPath = OperatingSystem.IsWindows() + ? Path.Join(venvPath, "Scripts", "python.exe") + : Path.Join(venvPath, "bin", "python"); + } + + return Task.FromResult(new PythonLaunchConfiguration + { + ProgramPath = programPath, + Module = module, + Mode = context.Mode, + InterpreterPath = interpreterPath, + WorkingDirectory = workingDirectory + }); + }, + "python", + static argsContext => + { + // Remove entrypoint-specific arguments that VS Code will handle. + // We need to verify the annotation to ensure we remove the correct args. + if (!argsContext.Resource.TryGetLastAnnotation(out var annotation)) + { + return; + } + + // For Module type: remove "-m" and module name (2 args) + if (annotation.Type == EntrypointType.Module) + { + if (argsContext.Args is [string arg0, string arg1, ..] + && arg0 == "-m" + && arg1 == annotation.Entrypoint) + { + argsContext.Args.RemoveAt(0); + argsContext.Args.RemoveAt(0); + } + } + // For Script type: remove script path (1 arg) + else if (annotation.Type == EntrypointType.Script) + { + if (argsContext.Args is [string arg0, ..] + && arg0 == annotation.Entrypoint) + { + argsContext.Args.RemoveAt(0); + } + } + }); +``` + +- [ ] **Step 5: Migrate JavaScript and browser debugging** + +For the script-path overload in `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs`, use: + +```csharp +return builder.WithDebugSupport( + context => + { + var hasRunScript = resource.TryGetLastAnnotation(out _); + var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation); + var isPackageManagerScript = hasRunScript && hasPackageManager; + + return Task.FromResult(new JavaScriptLaunchConfiguration(launchConfigType) + { + ScriptPath = Path.GetFullPath(scriptPath, workingDirectory), + Mode = context.Mode, + RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType, + LaunchMethod = isPackageManagerScript + ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager + : JavaScriptLaunchConfiguration.LaunchMethodDirect, + WorkingDirectory = workingDirectory + }); + }, + launchConfigType); +``` + +For the package-manager overload, use: + +```csharp +return builder.WithDebugSupport( + context => + { + var packageManager = "npm"; + if (resource.TryGetLastAnnotation(out var pmAnnotation)) + { + packageManager = pmAnnotation.ExecutableName; + } + + return Task.FromResult(new JavaScriptLaunchConfiguration("node") + { + ScriptPath = string.Empty, + Mode = context.Mode, + RuntimeExecutable = packageManager, + LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager, + WorkingDirectory = workingDirectory + }); + }, + "node"); +``` + +For browser debugging, use: + +```csharp +.WithDebugSupport( + context => + { + EndpointAnnotation? endpointAnnotation = null; + if (parentResource.TryGetAnnotationsOfType(out var endpoints)) + { + endpointAnnotation = endpoints.FirstOrDefault(endpoint => endpoint.UriScheme == "https") + ?? endpoints.FirstOrDefault(endpoint => endpoint.UriScheme == "http"); + } + + if (endpointAnnotation is null) + { + throw new InvalidOperationException( + $"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to."); + } + + var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); + + return Task.FromResult(new BrowserLaunchConfiguration + { + Mode = context.Mode, + Url = endpointReference.Url, + WebRoot = parentResource.WorkingDirectory, + Browser = browser + }); + }, + BrowserCapability); +``` + +- [ ] **Step 6: Migrate MAUI** + +In `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs`, replace the producer with: + +```csharp +return resourceBuilder.WithDebugSupport( + context => Task.FromResult(new MauiLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = projectPath, + TargetFramework = targetFramework, + Platform = platform, + TargetKind = targetKind, + Device = device, + RuntimeIdentifier = runtimeIdentifier, + MsBuildProperties = msBuildProperties + }), + MauiLaunchConfigurationType); +``` + +Do not change MAUI's separate environment evaluation inside its command-line argument callback; that occurs before a launch callback context exists and is explicitly outside this issue. + +- [ ] **Step 7: Build every migrated production project** + +Run: + +```bash +dotnet build src/Aspire.Hosting/Aspire.Hosting.csproj --no-restore +dotnet build src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj --no-restore +dotnet build src/Aspire.Hosting.Go/Aspire.Hosting.Go.csproj --no-restore +dotnet build src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj --no-restore +dotnet build src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj --no-restore +dotnet build src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj --no-restore +``` + +Expected: all builds PASS with `0` warnings introduced by this change. + +- [ ] **Step 8: Commit the production migrations** + +```bash +git add \ + src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs \ + src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs \ + src/Aspire.Hosting.Go/GoHostingExtensions.cs \ + src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs \ + src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs \ + src/Aspire.Hosting.Maui/MauiPlatformHelper.cs +git commit -m "Migrate debug launch configuration producers" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" +``` + +### Task 3: Remove legacy overloads and migrate all tests + +**Consumed by:** Task 4 — final validation assumes only the new API remains + +**Files:** +- Modify: `src/Aspire.Hosting/ResourceBuilderExtensions.cs:4750-4850` +- Modify: `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs:10-25` +- Modify: `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs:75-125` +- Modify: `src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs:45-105` +- Modify: `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` +- Modify: `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` +- Modify: `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` +- Modify: `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs:308-360` + +- [ ] **Step 1: Delete both legacy producer overloads** + +Delete these signatures and their implementations from `ResourceBuilderExtensions.cs`: + +```csharp +Func +``` + +```csharp +Func> +``` + +This also deletes the `Task`/`ValueTask` runtime guard; overload resolution can no longer infer `TLaunchConfiguration` as a task because there is only one task-returning producer shape. + +Keep the method named `WithDebugSupport`. Do not add `WithDebugSupportAsync`: registration returns the builder synchronously, and only the deferred producer is asynchronous. + +Keep one final overload with this XML documentation: + +```csharp +/// +/// 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. +/// +/// A callback that receives the resolved execution configuration and runtime launch context, and asynchronously +/// produces the complete launch configuration handed to the IDE. +/// +/// The type tag of the launch configuration sent to the IDE. +/// Optional callback to add or modify command-line arguments while this debug support annotation is active. +/// The . +/// +/// Registering debug support is synchronous; Aspire invokes +/// later for each executable creation, restart, or replica. A producer that completes synchronously should +/// return its result with . +/// +[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, + Action? argsCallback = null) + where T : IResource +``` + +- [ ] **Step 2: Update all API documentation references** + +In `SupportsDebuggingAnnotation.cs`, replace the old overload references with: + +```csharp +/// Added by . +``` + +In `DebugSupportExtensions.cs`, describe the explicit context helper and reference the same final overload. Remove all wording about "its asynchronous overload." + +In `ExecutableLaunchConfiguration.cs`, replace both old `WithDebugSupport` cref values with: + +```csharp + +``` + +Update the `Mode` remarks to say that the requested mode is available through `LaunchConfigurationCallbackContext.Mode`. + +- [ ] **Step 3: Migrate every core test producer** + +Apply these exact callback transformations in: + +- `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` +- `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` +- `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` +- `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs` + +Mode-dependent synchronous producer: + +```csharp +// Before +mode => new ExecutableLaunchConfiguration("test") { Mode = mode } + +// After +context => Task.FromResult( + new ExecutableLaunchConfiguration("test") { Mode = context.Mode }) +``` + +Producer that ignores the context: + +```csharp +// Before +_ => new ExecutableLaunchConfiguration("test") + +// After +static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")) +``` + +Genuinely asynchronous producer: + +```csharp +// Before +async (mode, cancellationToken) => +{ + await Task.Yield(); + return new ExecutableLaunchConfiguration("test") { Mode = mode }; +} + +// After +async context => +{ + await Task.Yield(); + return new ExecutableLaunchConfiguration("test") { Mode = context.Mode }; +} +``` + +Where an existing producer reads its `cancellationToken` parameter, replace that read with `context.CancellationToken`; do not add a new cancellation check to producers that did not previously perform one. + +Custom project producer: + +```csharp +context => Task.FromResult(new ProjectLaunchConfiguration +{ + Mode = context.Mode, + ProjectPath = "ProducerSuppliedPath", + DisableLaunchProfile = true +}) +``` + +Argument-rewriting registrations retain the existing `argsCallback` unchanged: + +```csharp +.WithDebugSupport( + context => Task.FromResult( + new ExecutableLaunchConfiguration("custom") { Mode = context.Mode }), + "custom", + context => context.Args.Add("rewritten-arg")) +``` + +Update the two method-group callbacks in `DcpExecutorTests.cs` to: + +```csharp +static Task CreateProjectLaunchConfiguration( + LaunchConfigurationCallbackContext context) +{ + throw new InvalidOperationException("Project launch configuration failed."); +} +``` + +```csharp +static Task ThrowingLaunchConfiguration( + LaunchConfigurationCallbackContext context) +{ + throw new InvalidOperationException("Launch configuration failed."); +} +``` + +Delete these obsolete tests from `ExecutableResourceBuilderExtensionTests.cs`: + +- `WithDebugSupportAsynchronousProducerProducesTheSameAnnotationAsTheSynchronousOne` +- `WithDebugSupportRejectsATaskReturningSynchronousProducer` +- `WithDebugSupportRejectsAValueTaskReturningSynchronousProducer` + +The replacement coverage is: + +- `CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks` +- `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` +- the null-task and null-result diagnostics + +- [ ] **Step 4: Update failure assertions for the resource-specific wrapper** + +Where DCP tests currently assert only the raw producer text, keep that assertion against the logged exception chain and also assert the resource-specific outer diagnostic: + +```csharp +Assert.Contains( + logLines, + line => line.Content.Contains( + "The \"project\" launch configuration producer for resource 'TestDotnetProject' failed.", + StringComparison.Ordinal)); +Assert.Contains( + logLines, + line => line.Content.Contains( + "Project launch configuration failed.", + StringComparison.Ordinal)); +``` + +For non-project fallback tests, continue asserting that: + +- non-rewriting producers fall back to `ExecutionType.Process`; +- argument-rewriting producers fail rather than offering an invalid process fallback; +- project producers fail without a process fallback. + +- [ ] **Step 5: Audit that no legacy producer shape remains** + +Run: + +```bash +rg -n -U '\.WithDebugSupport\(\s*(?:async\s*)?\([^)]*,[^)]*\)\s*=>' src tests --glob '*.cs' +rg -n -U '\.WithDebugSupport\(\s*[A-Za-z_][A-Za-z0-9_]*\s*=>\s*new ' src tests --glob '*.cs' +rg -n 'Func|Func>' src/Aspire.Hosting --glob '*.cs' --glob '!api/*.cs' +``` + +Expected: no matches. Matches in generated `src/*/api/*.cs` files are ignored and must not be edited. + +- [ ] **Step 6: Run the core regression tests** + +Run: + +```bash +dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_*Debug*" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_*LaunchConfiguration*" --filter-method "*.ProjectLaunchConfiguration_*" --filter-method "*.DotnetProjectExecutable_*" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-method "*.AddDotnetProject_*Debug*" --filter-method "*.AddDotnetProject_LaunchConfiguration_ResolvesEffectiveLaunchProfile" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +``` + +Expected: all selected tests PASS. + +- [ ] **Step 7: Commit the final API shape and test migration** + +```bash +git add \ + src/Aspire.Hosting/ResourceBuilderExtensions.cs \ + src/Aspire.Hosting/SupportsDebuggingAnnotation.cs \ + src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs \ + src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs \ + tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs \ + tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs \ + tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs \ + tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +git commit -m "Finalize debug callback context API" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ + -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" +``` + +### Task 4: Validate the complete change + +**Consumed by:** nothing + +**Files:** +- Verify only; no source files should change + +- [ ] **Step 1: Build the repository without native AOT** + +Run: + +```bash +./build.sh --build /p:SkipNativeBuild=true +``` + +Expected: build PASS with no new warnings. + +- [ ] **Step 2: Run the affected test projects** + +Run: + +```bash +dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_*Debug*" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_*LaunchConfiguration*" --filter-method "*.ProjectLaunchConfiguration_*" --filter-method "*.DotnetProjectExecutable_*" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Go.Tests/Aspire.Hosting.Go.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.JavaScript.Tests/Aspire.Hosting.JavaScript.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +dotnet test --project tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +``` + +Expected: all projects PASS. + +- [ ] **Step 3: Verify the diff and generated API boundary** + +Run: + +```bash +git diff --check +git diff --name-only HEAD~3..HEAD -- 'src/*/api/*.cs' +rg -n -U '\.WithDebugSupport\(\s*(?:async\s*)?\([^)]*,[^)]*\)\s*=>' src tests --glob '*.cs' +rg -n 'Func|Func>' src/Aspire.Hosting --glob '*.cs' --glob '!api/*.cs' +git status --short +``` + +Expected: + +- `git diff --check` prints nothing. +- No generated API file is listed. +- Both legacy-shape searches print nothing. +- `git status --short` is clean. + +## Rust PR #18906 follow-up + +After this framework change is available on the Rust PR branch, replace its launch producer with: + +```csharp +builder.WithDebugSupport( + async context => + { + var cargoArgs = builder.Resource.ResolvedCargoArgs + ?? throw new InvalidOperationException( + $"Cargo arguments for resource '{builder.Resource.Name}' have not been resolved."); + var environment = context.ExecutionConfiguration.EnvironmentVariables + .ToDictionary(StringComparer.Ordinal); + + var executablePath = await ResolveDebugExecutablePathAsync( + builder.Resource, + workingDirectory, + context.ExecutionContext, + environment, + context.CancellationToken).ConfigureAwait(false); + + return new RustLaunchConfiguration + { + Mode = context.Mode, + WorkingDirectory = workingDirectory, + Cargo = new RustCargoLaunchTarget + { + Args = ["build", .. cargoArgs], + ExecutablePath = executablePath + } + }; + }, + "rust", + argsCallback); +``` + +Delete Rust's second environment `ExecutionConfigurationBuilder` pass. Keep `ResolvedCargoArgs` until issue #18929 changes how process and IDE arguments are composed. From ad326eaf21b7a13d084cdd27704e7767c0dd3a2b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 13:02:34 -0400 Subject: [PATCH 03/30] Add launch configuration callback context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- .../DebugSupportExtensions.cs | 42 ++- .../LaunchConfigurationCallbackContext.cs | 55 ++++ src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 117 +++---- .../ResourceBuilderExtensions.cs | 51 ++- .../SupportsDebuggingAnnotation.cs | 62 +++- .../DotnetProjectResourceTests.cs | 12 +- .../Aspire.Hosting.Go.Tests/AddGoAppTests.cs | 3 +- .../AddBunAppTests.cs | 3 +- .../AddNodeAppTests.cs | 3 +- .../MauiPlatformExtensionsTests.cs | 5 +- .../AddPythonAppTests.cs | 3 +- .../Utils/LaunchConfigurationTestHelpers.cs | 54 ++++ .../Dcp/DcpExecutorTests.cs | 304 ++++++++++++++++-- .../Dcp/RecordingDcpObjectFactory.cs | 51 +++ .../DebugSupportExtensionsTests.cs | 170 +++++++++- ...ExecutableResourceBuilderExtensionTests.cs | 13 +- 16 files changed, 789 insertions(+), 159 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs create mode 100644 tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs create mode 100644 tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index 99d9d924973..6bcca221479 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using System.Text.Json; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -74,33 +75,44 @@ public static bool SupportsDebugging(this IResource resource, IConfiguration con } /// - /// Creates the launch configuration that this resource sends to the IDE for the given launch mode. + /// Creates the launch configuration that this resource sends to the IDE using an explicitly resolved callback context. /// /// The resource to inspect. It must carry a . - /// The launch mode, one of the values on . - /// A token to cancel the operation. + /// The callback context containing the resolved execution configuration 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 - /// - /// (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. + /// Launch configuration is created by invoking the producer callback passed to , + /// which owns the complete configuration; Aspire serializes the result as-is. /// /// - /// 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 method never resolves arguments or environment variables. Callers that need a real execution + /// configuration must build it explicitly with and place it + /// on . /// /// [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, + LaunchConfigurationCallbackContext context) { ArgumentNullException.ThrowIfNull(resource); - ArgumentNullException.ThrowIfNull(mode); + 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 (context.ExecutionConfiguration.Exception is { } configurationException) + { + ExceptionDispatchInfo.Throw(configurationException); + } if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) { @@ -110,7 +122,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/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs new file mode 100644 index 00000000000..1ecbc229d8a --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -0,0 +1,55 @@ +// 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; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Provides the runtime data used to create a launch configuration for a resource. +/// +/// +/// Aspire creates a new context for each executable creation, including restarts and replicas. +/// is the same resolved configuration used to populate the +/// underlying executable's arguments and environment variables. Only the launch configuration returned +/// by the producer is serialized for the IDE. Processed environment values can contain secrets. +/// +[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class LaunchConfigurationCallbackContext +{ + /// + /// Gets the requested launch mode, one of the values on . + /// + public required string Mode { get; init; } + + /// + /// Gets the resource being launched. + /// + public required IResource Resource { get; init; } + + /// + /// Gets the resolved execution configuration used for the executable. + /// + /// + /// Processed environment values can contain secrets. Aspire serializes only the launch configuration + /// returned by the producer; integrations should copy values from this result only when the IDE requires them. + /// + public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } + + /// + /// Gets the execution context for the current AppHost invocation. + /// + public required DistributedApplicationExecutionContext ExecutionContext { get; init; } + + /// + /// Gets the resource logger for this executable creation. + /// + public ILogger Logger { get; init; } = NullLogger.Instance; + + /// + /// Gets the cancellation token for this executable creation. + /// + public CancellationToken CancellationToken { get; init; } +} diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 42b5f123920..a6840c0a8be 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -55,11 +55,12 @@ public ExecutableCreator( _appResources = appResources; } - public async Task>> PrepareObjectsAsync(CancellationToken cancellationToken) + public Task>> PrepareObjectsAsync(CancellationToken cancellationToken) { - await PrepareProjectExecutablesAsync(cancellationToken).ConfigureAwait(false); + PrepareProjectExecutables(cancellationToken); PreparePlainExecutables(); - return _appResources.Get().OfType>(); + return Task.FromResult>>( + _appResources.Get().OfType>()); } public bool IsReadyToCreate(RenderedModelResource resource, EmptyCreationContext context) @@ -158,65 +159,69 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC throw new FailedToApplyEnvironmentException($"Failed to apply configuration to executable {er.ModelResource.Name}", configuration.Exception); } - // Invoke the debug configuration callback now that endpoints are allocated. - // This allows launch configurations to access endpoint URLs that were not - // available during PrepareExecutables(). - // "project" launch types on ProjectResources configure their launch configs in - // PrepareProjectExecutables() directly. Plain executables that carry IProjectMetadata and a - // "project" SupportsDebuggingAnnotation (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. - if (!er.ModelResource.HasAnnotationOfType() + // Invoke the active launch configuration producer only after the resource execution configuration + // has been resolved. This gives every launch type, including "project", the exact arguments and + // environment used for this executable creation. + if (exe.Spec.ExecutionType == ExecutionType.IDE + && !er.ModelResource.HasAnnotationOfType() && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) { - if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) + var isProjectLaunchConfiguration = + supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; + + if (isProjectLaunchConfiguration && !er.ModelResource.TryGetProjectMetadata(out _)) { - // 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."); - } - } + 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."); } - else + + var mode = isProjectLaunchConfiguration + ? GetProjectLaunchConfigurationMode() + : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; + var callbackContext = new LaunchConfigurationCallbackContext { - // We have non-project Executable that supports debugging; need to annotate it properly. + Mode = mode, + Resource = er.ModelResource, + ExecutionConfiguration = configuration, + ExecutionContext = _executionContext, + Logger = resourceLogger, + CancellationToken = cancellationToken + }; - var mode = _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; - try - { - // Clear any existing launch configurations (needed for restart scenarios). - exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); - await supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, mode, cancellationToken).ConfigureAwait(false); - } - // Only fall back to Process when Spec.Args still forms a runnable command. - catch (Exception ex) when (!supportsDebuggingAnnotation.RewritesArgumentsForDebugging) - { - _logger.LogWarning(ex, "Failed to apply launch configuration for resource '{ResourceName}'. Falling back to process execution.", er.ModelResource.Name); - exe.Spec.ExecutionType = ExecutionType.Process; - } + try + { + // Executable objects are reused for restarts. Clear the prior launch configuration before + // applying the freshly resolved producer result. + exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); + await supportsDebuggingAnnotation + .LaunchConfigurationAnnotator(exe, callbackContext) + .ConfigureAwait(false); + } + catch (Exception exception) when ( + (exception is not OperationCanceledException || !callbackContext.CancellationToken.IsCancellationRequested) + && !isProjectLaunchConfiguration + && !supportsDebuggingAnnotation.RewritesArgumentsForDebugging) + { + _logger.LogWarning( + exception, + "Failed to apply launch configuration for resource '{ResourceName}'. Falling back to process execution.", + er.ModelResource.Name); + exe.Spec.ExecutionType = ExecutionType.Process; } } await factory.CreateDcpObjectsAsync([exe], cancellationToken).ConfigureAwait(false); } - private async Task PrepareProjectExecutablesAsync(CancellationToken cancellationToken) + private void PrepareProjectExecutables(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var modelProjectResources = _model.GetProjectResources(); foreach (var project in modelProjectResources) { + cancellationToken.ThrowIfCancellationRequested(); if (!project.TryGetProjectMetadata(out var projectMetadata)) { throw new InvalidOperationException($"Project resource '{project.Name}' is missing required metadata."); // Should never happen. @@ -291,15 +296,6 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; } - 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). - // File-based apps (.cs files) are not supported by all IDEs (e.g. Visual Studio // returns 500 for them). Populate fallback process args so that when the IDE // rejects the launch request and DCP falls back to ExecutionType.Process, the @@ -337,7 +333,7 @@ private async Task PrepareProjectExecutablesAsync(CancellationToken cancellation exe.Spec.ExecutionType = ExecutionType.IDE; exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; - await ApplyProjectLaunchConfigurationAsync(exe, project, projectMetadata, supportsDebuggingAnnotation: null, cancellationToken).ConfigureAwait(false); + ApplyProjectLaunchConfiguration(exe, project, projectMetadata); } else { @@ -813,17 +809,8 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi return true; } - private async Task ApplyProjectLaunchConfigurationAsync(Executable exe, IResource project, IProjectMetadata projectMetadata, SupportsDebuggingAnnotation? supportsDebuggingAnnotation, CancellationToken cancellationToken) + private void ApplyProjectLaunchConfiguration(Executable exe, IResource project, IProjectMetadata projectMetadata) { - 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)); } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index c15be44ba95..22c05997f32 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4776,8 +4776,9 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// overload. Use instead. /// /// - /// 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 + /// Aspire invokes active custom launch configuration producers from the executable creation path after + /// endpoints and the resource execution configuration have resolved. The producer may be invoked several + /// times for the same resource, such as during restarts or when creating replicas. 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. @@ -4798,7 +4799,10 @@ public static IResourceBuilder WithDebugSupport(this nameof(launchConfigurationProducer)); } - return builder.WithDebugSupport((mode, _) => Task.FromResult(launchConfigurationProducer(mode)), launchConfigurationType, argsCallback); + return builder.WithDebugSupport( + context => Task.FromResult(launchConfigurationProducer(context.Mode)), + launchConfigurationType, + argsCallback); static bool IsValueTask(Type type) => type == typeof(ValueTask) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)); @@ -4816,9 +4820,10 @@ static bool IsValueTask(Type type) /// Optional callback to add or modify command line arguments when running in an extension host. Useful if the entrypoint is usually provided as an argument to the resource executable. /// 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. + /// Use this overload when the launch configuration has to be resolved from work that is itself asynchronous. + /// Aspire invokes active custom launch configuration producers from the executable creation path after + /// endpoints and the resource execution configuration have resolved, and may invoke them several times for + /// the same resource. /// A producer that computes everything synchronously should use /// /// instead. @@ -4831,6 +4836,35 @@ public static IResourceBuilder WithDebugSupport(this ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + return builder.WithDebugSupport( + context => launchConfigurationProducer(context.Mode, context.CancellationToken), + launchConfigurationType, + argsCallback); + } + + /// + /// Adds support for debugging the resource in VS Code when running in an extension host, using a + /// callback context that contains the resolved execution configuration. + /// + /// The resource type. + /// The launch configuration type produced for the resource, typically derived from . + /// The resource builder. + /// Launch configuration producer for the resource. + /// The type of the resource. + /// Optional callback to add or modify command line arguments when running in an extension host. + /// The . + [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, + Action? argsCallback = null) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) { return builder; @@ -4840,14 +4874,13 @@ public static IResourceBuilder WithDebugSupport(this builder.Resource.Name, launchConfigurationType, launchConfigurationProducer, - rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder - ); + rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder); if (argsCallback is not null && builder is IResourceBuilder resourceWithArgs) { resourceWithArgs.WithArgs(ctx => { - // Make sure that we do not call the callback if we aren't the active (last) SupportsDebuggingAnnotation, + // Make sure that we do not call the callback if we aren't the active (last) SupportsDebuggingAnnotation, // because the callback may be specific to the launch configuration type. if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 3bb4d4cc8a1..ef55b6cae80 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -25,8 +25,8 @@ public sealed class SupportsDebuggingAnnotation : IResourceAnnotation { private SupportsDebuggingAnnotation( string launchConfigurationType, - Func launchConfigurationAnnotator, - Func> launchConfigurationProducer, + Func launchConfigurationAnnotator, + Func> launchConfigurationProducer, bool rewritesArgumentsForDebugging) { LaunchConfigurationType = launchConfigurationType; @@ -51,12 +51,12 @@ 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; } + internal Func> LaunchConfigurationProducer { get; } /// /// Indicates that the debug support rewrites the resource's command-line arguments while a debug @@ -80,21 +80,50 @@ private SupportsDebuggingAnnotation( /// public bool RewritesArgumentsForDebugging { get; } - internal static SupportsDebuggingAnnotation Create(string resourceName, string launchConfigurationType, Func> launchProfileProducer, bool rewritesArgumentsForDebugging = false) + internal static SupportsDebuggingAnnotation Create( + string resourceName, + string launchConfigurationType, + Func> launchConfigurationProducer, + bool rewritesArgumentsForDebugging = false) { - // 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)), - // 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 (exe, context) => + exe.AnnotateAsObjectList( + Executable.LaunchConfigurationsAnnotation, + await ProduceAsync(context).ConfigureAwait(false)), + async context => (await ProduceAsync(context).ConfigureAwait(false))!, rewritesArgumentsForDebugging); - async Task ProduceAsync(string mode, CancellationToken cancellationToken) + async Task ProduceAsync(LaunchConfigurationCallbackContext context) { - var launchConfiguration = await launchProfileProducer(mode, cancellationToken).ConfigureAwait(false); + Task? producerTask; + try + { + producerTask = launchConfigurationProducer(context); + } + catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) + { + throw CreateProducerException(exception); + } + + if (producerTask is null) + { + throw new InvalidOperationException( + $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned a null task. " + + "The producer must return a task that produces the complete launch configuration."); + } + + T launchConfiguration; + try + { + launchConfiguration = await producerTask.ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) + { + throw CreateProducerException(exception); + } + if (launchConfiguration is null) { throw new InvalidOperationException( @@ -104,5 +133,12 @@ async Task ProduceAsync(string mode, CancellationToken cancellationToken) return launchConfiguration; } + + InvalidOperationException CreateProducerException(Exception innerException) + { + return new InvalidOperationException( + $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' failed.", + innerException); + } } } diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index ad6ff85961c..ae9fed0c060 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 258bfa64e04..b2b175b2728 100644 --- a/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs +++ b/tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs @@ -1151,7 +1151,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..43d02644bc7 100644 --- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -890,7 +890,10 @@ 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 resource.CreateLaunchConfigurationAsync(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 8d37bc4875f..8cbd9da21fb 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -1624,7 +1624,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..87937c7b244 --- /dev/null +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -0,0 +1,54 @@ +// 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 + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests.Utils; + +public static class LaunchConfigurationTestHelpers +{ + public static LaunchConfigurationCallbackContext CreateCallbackContext( + IResource resource, + string mode = ExecutableLaunchMode.Debug, + IExecutionConfigurationResult? executionConfiguration = null, + DistributedApplicationExecutionContext? executionContext = null, + ILogger? logger = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resource); + + return new LaunchConfigurationCallbackContext + { + Mode = mode, + Resource = resource, + ExecutionConfiguration = executionConfiguration ?? CreateExecutionConfigurationResult(), + ExecutionContext = executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + Logger = logger ?? NullLogger.Instance, + CancellationToken = cancellationToken + }; + } + + public static IExecutionConfigurationResult CreateExecutionConfigurationResult( + IEnumerable? arguments = null, + IEnumerable>? environmentVariables = null, + Exception? exception = null) + { + return new ExecutionConfigurationResult + { + References = [], + ArgumentsWithUnprocessed = (arguments ?? []) + .Select(value => ((object)value, value, false)) + .ToArray(), + EnvironmentVariablesWithUnprocessed = (environmentVariables ?? []) + .Select(pair => new KeyValuePair( + pair.Key, + (pair.Value, pair.Value))) + .ToArray(), + AdditionalConfigurationData = [], + Exception = exception + }; + } +} diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 16607f9434f..fe490e822fe 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -444,7 +444,7 @@ public async Task RunApplicationAsync_AllowsContainerNameMatchingContainerTunnel } [Fact] - public async Task ResourceRestarted_EnvironmentCallbacksApplied() + public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext() { var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions { @@ -452,16 +452,25 @@ public async Task ResourceRestarted_EnvironmentCallbacksApplied() }); var callCount = 0; - var resource = builder.AddProject("ServiceA") + var launchContexts = new ConcurrentQueue(); + var project = builder.AddProject("ServiceA") .WithArgs(c => { c.Args.Add("--test"); }) .WithEnvironment(c => { - Interlocked.Increment(ref callCount); - c.EnvironmentVariables["CALL_COUNT"] = callCount.ToString(); - }).Resource; + var currentCall = Interlocked.Increment(ref callCount); + c.EnvironmentVariables["CALL_COUNT"] = currentCall.ToString(); + }) + .WithDebugSupport( + context => + { + launchContexts.Enqueue(context); + return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); + }, + KnownLaunchConfigurationTypes.Project); + var resource = project.Resource; var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -481,23 +490,39 @@ public async Task ResourceRestarted_EnvironmentCallbacksApplied() }); var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] + }), + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + dcpOptions: dcpOptions, + events: events, + configuration: configuration); await appExecutor.RunApplicationAsync(); var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - var exe1 = Assert.Single(executables); - var callCount1 = exe1.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); + var firstExecutable = Assert.Single(executables); + var callCount1 = firstExecutable.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); Assert.Equal("1", callCount1.Value); - Assert.Single(exe1.Spec.Args!, a => a == "--no-build"); - Assert.Single(exe1.Spec.Args!, a => a == "--test"); - Assert.True(exe1.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations1)); - Assert.Single(argAnnotations1, a => a.Argument == "--test"); - AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations1, exe1.Spec.Args); + Assert.Single(firstExecutable.Spec.Args!, a => a == "--test"); + Assert.True(firstExecutable.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var firstArgumentAnnotations)); + Assert.Single(firstArgumentAnnotations, a => a.Argument == "--test"); + AssertEffectiveArgumentIndexesMatchSpecArgs(firstArgumentAnnotations, firstExecutable.Spec.Args); Assert.Equal(1, connectionStringAvailableCount); - var reference = appExecutor.GetResource(exe1.Metadata.Name); + var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); await appExecutor.StopResourceAsync(reference, CancellationToken.None); @@ -506,16 +531,33 @@ public async Task ResourceRestarted_EnvironmentCallbacksApplied() executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); Assert.Equal(2, executables.Count); - var exe2 = executables[1]; - var callCount2 = exe2.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); + var secondExecutable = executables[1]; + var callCount2 = secondExecutable.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); Assert.Equal("2", callCount2.Value); - Assert.Single(exe2.Spec.Args!, a => a == "--no-build"); - Assert.Single(exe2.Spec.Args!, a => a == "--test"); - Assert.True(exe2.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations2)); - Assert.Single(argAnnotations2, a => a.Argument == "--test"); - AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations2, exe2.Spec.Args); + Assert.Single(secondExecutable.Spec.Args!, a => a == "--test"); + Assert.True(secondExecutable.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var secondArgumentAnnotations)); + Assert.Single(secondArgumentAnnotations, a => a.Argument == "--test"); + AssertEffectiveArgumentIndexesMatchSpecArgs(secondArgumentAnnotations, secondExecutable.Spec.Args); Assert.Equal(2, connectionStringAvailableCount); + + Assert.True(secondExecutable.TryGetProjectLaunchConfiguration(out var secondLaunchConfiguration)); + Assert.NotNull(secondLaunchConfiguration); + + var contexts = launchContexts.ToArray(); + Assert.Equal(2, contexts.Length); + Assert.NotSame(contexts[0], contexts[1]); + Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); + Assert.Equal( + "1", + contexts[0].ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "CALL_COUNT") + .Value); + Assert.Equal( + "2", + contexts[1].ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "CALL_COUNT") + .Value); } [Fact] @@ -3576,6 +3618,135 @@ public async Task ProjectLaunchConfiguration_FallbackToFirstProfileInsertionOrde Assert.Equal("Zed", plc.LaunchProfile); // first inserted wins } + [Fact] + public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration() + { + var builder = DistributedApplication.CreateBuilder(); + var environmentCallbackCount = 0; + EnvironmentCallbackContext? environmentContext = null; + LaunchConfigurationCallbackContext? launchContext = null; + + var resource = new TestExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithEnvironment(context => + { + Interlocked.Increment(ref environmentCallbackCount); + environmentContext = context; + context.EnvironmentVariables["DEBUG_VALUE"] = "resolved"; + }) + .WithDebugSupport( + context => + { + launchContext = context; + var debugValue = context.ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "DEBUG_VALUE") + .Value; + + return Task.FromResult(new TestExecutionConfigurationLaunchConfiguration + { + Mode = context.Mode, + DebugValue = debugValue + }); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [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.Equal(1, environmentCallbackCount); + Assert.NotNull(environmentContext); + Assert.NotNull(launchContext); + Assert.Same(resource, launchContext.Resource); + Assert.Same(environmentContext.Resource, launchContext.Resource); + Assert.Same(environmentContext.ExecutionContext, launchContext.ExecutionContext); + Assert.Same(environmentContext.Logger, launchContext.Logger); + Assert.Equal(environmentContext.CancellationToken, launchContext.CancellationToken); + Assert.Equal(cts.Token, launchContext.CancellationToken); + + var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name); + Assert.Contains(executable.Spec.Env!, variable => variable is { Name: "DEBUG_VALUE", Value: "resolved" }); + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigurations)); + var launchConfiguration = Assert.Single(launchConfigurations); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); + Assert.Equal("resolved", launchConfiguration.DebugValue); + } + + [Fact] + public async Task PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer() + { + var builder = DistributedApplication.CreateBuilder(); + var producerCalled = false; + var resource = new TestExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithEnvironment( + (EnvironmentCallbackContext _) => + throw new InvalidOperationException("environment failed")) + .WithDebugSupport( + context => + { + producerCalled = true; + return Task.FromResult( + new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }) + }) + .Build(); + var failedResources = new ConcurrentQueue(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Enqueue(context.Resource); + return Task.CompletedTask; + }); + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + configuration: configuration, + events: events); + + await appExecutor.RunApplicationAsync(); + + Assert.False(producerCalled); + Assert.Empty(kubernetesService.CreatedResources.OfType()); + Assert.Same(resource, Assert.Single(failedResources)); + } + [Fact] public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() { @@ -5313,13 +5484,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. + // Regression guard for the async launch configuration producer. All custom producers run from + // CreateObjectAsync after endpoints and execution configuration resolve. var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions { AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName @@ -5327,11 +5495,15 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null); projectBuilder.WithDebugSupport( - async (mode, ct) => + async context => { - // Yield so the producer completes asynchronously rather than returning an already-completed task. await Task.Yield(); - return new ProjectLaunchConfiguration { ProjectPath = "AsyncProducerPath", Mode = mode, LaunchProfile = "async-profile" }; + return new ProjectLaunchConfiguration + { + ProjectPath = "AsyncProducerPath", + Mode = context.Mode, + LaunchProfile = "async-profile" + }; }, KnownLaunchConfigurationTypes.Project); @@ -5362,9 +5534,8 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu [Fact] public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate() { - // The companion to ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare: a - // non-"project" launch configuration is applied when the Executable is created (after endpoints are - // allocated), which is the other producer call site. + // Both custom producer types share the CreateObjectAsync path after endpoints and execution + // configuration resolve. var builder = DistributedApplication.CreateBuilder(); var debuggableExecutable = new TestExecutableResource("test-working-directory"); @@ -5435,6 +5606,56 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FallsBa Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); } + [Fact] + public async Task PlainExecutable_LaunchConfigurationProducerCancellation_DoesNotFallBackToProcess() + { + var builder = DistributedApplication.CreateBuilder(); + using var cancellationSource = new CancellationTokenSource(); + + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + context => + { + cancellationSource.Cancel(); + return Task.FromCanceled(context.CancellationToken); + }, + "test"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }) + }) + .Build(); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + ExecutableCreator? executableCreator = null; + _ = CreateAppExecutor( + distributedAppModel, + kubernetesService: new TestKubernetesService(), + configuration: configuration, + executableCreatorCreated: creator => executableCreator = creator); + Assert.NotNull(executableCreator); + + var renderedExecutable = Assert.Single( + await executableCreator.PrepareObjectsAsync(CancellationToken.None)); + var objectFactory = new RecordingDcpObjectFactory(); + await Assert.ThrowsAnyAsync( + () => executableCreator.CreateObjectAsync( + renderedExecutable, + EmptyCreationContext.s_instance, + NullLogger.Instance, + objectFactory, + cancellationSource.Token)); + Assert.Equal(0, objectFactory.CreateDcpObjectsCallCount); + } + [Fact] public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsInProcessMode() { @@ -5444,6 +5665,14 @@ public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsIn }); var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null); + var launchConfigurationProducerCalled = false; + projectBuilder.WithDebugSupport( + context => + { + launchConfigurationProducerCalled = true; + return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); + }, + KnownLaunchConfigurationTypes.Project); #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", "/t:Run"])); #pragma warning restore ASPIREPROJECTS001 @@ -5466,6 +5695,7 @@ public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsIn var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA"); Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); Assert.Null(exe.Spec.FallbackExecutionTypes); + Assert.False(launchConfigurationProducerCalled); Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceProjectArgsAnnotation, out var projectArgs)); Assert.Collection( @@ -7305,6 +7535,7 @@ private static DcpExecutor CreateAppExecutor( DcpExecutorEvents? events = null, Hosting.Eventing.IDistributedApplicationEventing? distributedApplicationEventing = null, ILogger? containerCreatorLogger = null, + Action? executableCreatorCreated = null, ILogger? logger = null, DistributedApplicationOptions? distributedApplicationOptions = null) { @@ -7362,6 +7593,7 @@ private static DcpExecutor CreateAppExecutor( aspireStore, NullLogger.Instance, appResources); + executableCreatorCreated?.Invoke(executableCreator); var containerCreator = new ContainerCreator( configuration, @@ -7573,6 +7805,12 @@ public TestMauiLaunchConfiguration() : base("maui") public Dictionary? MsBuildProperties { get; set; } } + private sealed class TestExecutionConfigurationLaunchConfiguration() : ExecutableLaunchConfiguration("test") + { + [JsonPropertyName("debug_value")] + public string DebugValue { get; set; } = string.Empty; + } + private sealed class TestProjectWithLaunchSettings : IProjectMetadata { public string ProjectPath => "TestProjectWithLaunchSettings"; diff --git a/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs b/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs new file mode 100644 index 00000000000..c3568ae5041 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Dcp; +using Aspire.Hosting.Dcp.Model; + +namespace Aspire.Hosting.Tests.Dcp; + +internal sealed class RecordingDcpObjectFactory : IDcpObjectFactory +{ + public int CreateDcpObjectsCallCount { get; private set; } + + public Task CreateDcpObjectsAsync( + IEnumerable objects, + CancellationToken cancellationToken) + where TDcpResource : CustomResource, IKubernetesStaticMetadata + { + CreateDcpObjectsCallCount++; + return Task.CompletedTask; + } + + public Task CreateRenderedResourcesAsync( + IObjectCreator creator, + IEnumerable> resources, + TContext context, + CancellationToken cancellationToken) + where TDcpResource : CustomResource, IKubernetesStaticMetadata + => throw new NotSupportedException(); + + public Task PatchDcpObjectAsync( + TDcpResource obj, + Action change, + CancellationToken cancellationToken) + where TDcpResource : CustomResource, IKubernetesStaticMetadata + => throw new NotSupportedException(); + + public Task UpdateWithEffectiveAddressInfo( + IEnumerable services, + CancellationToken cancellationToken, + TimeSpan? timeout = null) + => throw new NotSupportedException(); + + public Task> WaitForStateAsync( + IEnumerable objects, + Func stateSelector, + IReadOnlyCollection finalStates, + TimeSpan timeout, + CancellationToken cancellationToken) + where TDcpResource : CustomResource, IKubernetesStaticMetadata + => throw new NotSupportedException(); +} diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index 33fc53c8c5c..4e50620af8f 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -7,6 +7,7 @@ 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; @@ -21,7 +22,7 @@ public async Task CreateLaunchConfigurationResolvesTheLaunchProfileForProjectRes 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 +39,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 +70,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 +84,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); @@ -103,7 +104,7 @@ public async Task CreateLaunchConfigurationAwaitsAnAsynchronousProducer() 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); @@ -123,7 +124,7 @@ public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToThePr 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 +135,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); } @@ -148,28 +149,156 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta var executable = builder.AddExecutable("app", "dotnet", "."); 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); + Assert.NotNull(exception.InnerException); + Assert.Contains("has no project metadata", exception.InnerException.Message); } [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"); + .WithDebugSupport( + static (LaunchConfigurationCallbackContext _) => + Task.FromResult(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); Assert.Contains("go", exception.Message); } + [Fact] + public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var environmentCallbackCount = 0; + LaunchConfigurationCallbackContext? observedContext = null; + + var executable = builder.AddExecutable("app", "go", ".") + .WithEnvironment(context => + { + Interlocked.Increment(ref environmentCallbackCount); + context.EnvironmentVariables["UNEXPECTED"] = "value"; + }) + .WithDebugSupport((LaunchConfigurationCallbackContext context) => + { + observedContext = context; + return Task.FromResult(new TestGoLaunchConfiguration + { + Mode = context.Mode, + Package = context.ExecutionConfiguration.EnvironmentVariables + .Single(pair => pair.Key == "EXPECTED") + .Value + }); + }, "go"); + + var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + environmentVariables: [new("EXPECTED", "./cmd/api")]); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.NoDebug, + executionConfiguration); + + var launchConfiguration = Assert.IsType( + await executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Same(callbackContext, observedContext); + Assert.Equal(0, environmentCallbackCount); + Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); + Assert.Equal("./cmd/api", launchConfiguration.Package); + } + + [Fact] + public async Task CreateLaunchConfigurationRejectsAContextForAnotherResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static (LaunchConfigurationCallbackContext context) => + Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); + var other = builder.AddExecutable("other", "go", "."); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(other.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Equal("context", exception.ParamName); + Assert.Contains("other", exception.Message); + Assert.Contains("app", exception.Message); + } + + [Fact] + public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var producerCalled = false; + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + (LaunchConfigurationCallbackContext context) => + { + producerCalled = true; + return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); + }, + "go"); + var expectedException = new InvalidOperationException("configuration failed"); + var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + exception: expectedException); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + executionConfiguration: executionConfiguration); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Same(expectedException, exception); + Assert.False(producerCalled); + } + + [Fact] + public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsANullTask() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static (LaunchConfigurationCallbackContext _) => + (Task)null!, + "go"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Contains("returned a null task", exception.Message); + Assert.Contains("app", exception.Message); + Assert.Contains("go", exception.Message); + } + + [Fact] + public async Task CreateLaunchConfigurationWrapsAProducerExceptionWithResourceContext() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var producerException = new InvalidOperationException("producer failed"); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + (LaunchConfigurationCallbackContext _) => + Task.FromException(producerException), + "go"); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); + + var exception = await Assert.ThrowsAsync( + () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); + + Assert.Contains("app", exception.Message); + Assert.Contains("go", exception.Message); + Assert.Same(producerException, exception.InnerException); + } + [Fact] public void SupportsDebuggingReturnsFalseWhenTheResourceHasNoDebugSupport() { @@ -305,6 +434,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 602c75bfb42..2972df20bcb 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -97,7 +97,10 @@ 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); + var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.NoDebug); + await annotation.LaunchConfigurationAnnotator(exe, callbackContext); Assert.Equal("ms-python.python", annotation.LaunchConfigurationType); Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var annotations)); @@ -129,8 +132,12 @@ public async Task WithDebugSupportAsynchronousProducerProducesTheSameAnnotationA 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 syncExecutable.Resource.CreateLaunchConfigurationAsync( + LaunchConfigurationTestHelpers.CreateCallbackContext(syncExecutable.Resource, ExecutableLaunchMode.Debug))); + var asyncConfiguration = Assert.IsType( + await asyncExecutable.Resource.CreateLaunchConfigurationAsync( + LaunchConfigurationTestHelpers.CreateCallbackContext(asyncExecutable.Resource, ExecutableLaunchMode.Debug))); Assert.Equal(asyncConfiguration.Type, syncConfiguration.Type); Assert.Equal(asyncConfiguration.Mode, syncConfiguration.Mode); From bfb9027b5261550150b88259245cd9c8f48bd3b3 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:01:34 -0400 Subject: [PATCH 04/30] Fix launch producer restart handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 6 +- .../ResourceBuilderExtensions.cs | 1 + .../Dcp/DcpExecutorTests.cs | 62 +++++++++++++++++++ ...ExecutableResourceBuilderExtensionTests.cs | 16 +++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index a6840c0a8be..60707ade590 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -162,10 +162,14 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC // Invoke the active launch configuration producer only after the resource execution configuration // has been resolved. This gives every launch type, including "project", the exact arguments and // environment used for this executable creation. - if (exe.Spec.ExecutionType == ExecutionType.IDE + if (!HasProjectLaunchArgsOverride(er.ModelResource) && !er.ModelResource.HasAnnotationOfType() && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) { + // A transient producer failure may have changed the reused executable to Process on a prior + // creation. Recompute the intended execution type from the immutable resource model each time. + exe.Spec.ExecutionType = ExecutionType.IDE; + var isProjectLaunchConfiguration = supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 22c05997f32..e2c37506598 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4855,6 +4855,7 @@ public static IResourceBuilder WithDebugSupport(this /// The . [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] + [OverloadResolutionPriority(1)] public static IResourceBuilder WithDebugSupport( this IResourceBuilder builder, Func> launchConfigurationProducer, diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index fe490e822fe..806203d92df 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -560,6 +560,68 @@ public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchConte .Value); } + [Fact] + public async Task ResourceRestarted_RetriesLaunchConfigurationAfterTransientProducerFailure() + { + var builder = DistributedApplication.CreateBuilder(); + var producerCallCount = 0; + var resource = builder.AddExecutable("app", "command", ".") + .WithDebugSupport( + context => + { + if (Interlocked.Increment(ref producerCallCount) == 1) + { + throw new InvalidOperationException("transient producer failure"); + } + + return Task.FromResult( + new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test") + .Resource; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [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); + + await appExecutor.RunApplicationAsync(); + + var firstExecutable = Assert.Single( + GetCreatedExecutablesForResource(kubernetesService, resource.Name)); + Assert.Equal(ExecutionType.Process, firstExecutable.Spec.ExecutionType); + + var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); + await appExecutor.StopResourceAsync(reference, CancellationToken.None); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); + Assert.Equal(2, executables.Count); + var secondExecutable = executables[1]; + Assert.Equal(2, producerCallCount); + Assert.Equal(ExecutionType.IDE, secondExecutable.Spec.ExecutionType); + Assert.True(secondExecutable.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigurations)); + Assert.Single(launchConfigurations); + } + [Fact] public async Task EndpointPortsExecutableNotReplicatedProxiedNoPortNoTargetPort() { diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 2972df20bcb..3558962ac7c 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -143,6 +143,22 @@ await asyncExecutable.Resource.CreateLaunchConfigurationAsync( Assert.Equal(asyncConfiguration.Mode, syncConfiguration.Mode); } + [Fact] + public async Task WithDebugSupportBindsAContextIgnoringAsyncProducerToTheContextOverload() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var executable = builder.AddExecutable("async", "command", "workingdirectory") + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go"); + + var launchConfiguration = Assert.IsType( + await executable.Resource.CreateLaunchConfigurationAsync( + LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource))); + + Assert.Equal("go", launchConfiguration.Type); + } + [Fact] public void WithDebugSupportRejectsATaskReturningSynchronousProducer() { From 9a8c99a6b41df81e22b31cf93f8edd3f70ad5026 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:16:11 -0400 Subject: [PATCH 05/30] Reset debug execution before rebuilding arguments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 21 ++++--- .../Dcp/DcpExecutorTests.cs | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 60707ade590..58e441f35ec 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -91,6 +91,19 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC spec.Args.AddRange(projectArgs); } + SupportsDebuggingAnnotation? supportsDebuggingAnnotation = null; + if (!HasProjectLaunchArgsOverride(er.ModelResource) + && !er.ModelResource.HasAnnotationOfType() + && er.ModelResource.SupportsDebugging(_configuration, out var activeDebuggingAnnotation)) + { + supportsDebuggingAnnotation = activeDebuggingAnnotation; + + // Executable objects are reused for restarts, and a prior producer failure may have changed + // the execution type to Process. Reset it before building arguments because launch-profile + // arguments are executable in Process mode but display-only in IDE mode. + spec.ExecutionType = ExecutionType.IDE; + } + var (configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, cancellationToken).ConfigureAwait(false); spec.PemCertificates = pemCertificates; @@ -162,14 +175,8 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC // Invoke the active launch configuration producer only after the resource execution configuration // has been resolved. This gives every launch type, including "project", the exact arguments and // environment used for this executable creation. - if (!HasProjectLaunchArgsOverride(er.ModelResource) - && !er.ModelResource.HasAnnotationOfType() - && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) + if (supportsDebuggingAnnotation is not null) { - // A transient producer failure may have changed the reused executable to Process on a prior - // creation. Recompute the intended execution type from the immutable resource model each time. - exe.Spec.ExecutionType = ExecutionType.IDE; - var isProjectLaunchConfiguration = supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 806203d92df..425844dc5bd 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -622,6 +622,69 @@ public async Task ResourceRestarted_RetriesLaunchConfigurationAfterTransientProd Assert.Single(launchConfigurations); } + [Fact] + public async Task ProjectResourceRestarted_RebuildsArgumentsForIdeAfterTransientProducerFailure() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + var producerCallCount = 0; + var resource = builder.AddProject("ServiceA", launchProfileName: "http") + .WithArgs("--apphost") + .WithDebugSupport( + context => + { + if (Interlocked.Increment(ref producerCallCount) == 1) + { + throw new InvalidOperationException("transient producer failure"); + } + + return Task.FromResult( + new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test") + .Resource; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [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); + + await appExecutor.RunApplicationAsync(); + + var firstExecutable = Assert.Single( + GetCreatedExecutablesForResource(kubernetesService, resource.Name)); + Assert.Equal(ExecutionType.Process, firstExecutable.Spec.ExecutionType); + + var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); + await appExecutor.StopResourceAsync(reference, CancellationToken.None); + await appExecutor.StartResourceAsync(reference, CancellationToken.None); + + var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); + Assert.Equal(2, executables.Count); + var secondExecutable = executables[1]; + Assert.Equal(2, producerCallCount); + Assert.Equal(ExecutionType.IDE, secondExecutable.Spec.ExecutionType); + Assert.Equal(["--apphost"], secondExecutable.Spec.Args); + } + [Fact] public async Task EndpointPortsExecutableNotReplicatedProxiedNoPortNoTargetPort() { From e9ea853d61d09da5cccb6c005dea5e72cc27af42 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:28:49 -0400 Subject: [PATCH 06/30] Migrate debug launch configuration producers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- ...AzureFunctionsProjectResourceExtensions.cs | 8 ++++++- src/Aspire.Hosting.Go/GoHostingExtensions.cs | 8 +++---- .../JavaScriptHostingExtensions.cs | 24 +++++++++---------- src/Aspire.Hosting.Maui/MauiPlatformHelper.cs | 24 ++++++++++--------- .../PythonAppResourceBuilderExtensions.cs | 8 +++---- .../ProjectResourceBuilderExtensions.cs | 3 ++- 6 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs index 810a5148b94..140774779cd 100644 --- a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs @@ -190,7 +190,13 @@ private static IResourceBuilder AddAzureFunctions .WithIconName("Flash") .WithAnnotation(projectMetadata) .WithAnnotation(new AzureFunctionsAnnotation()) - .WithDebugSupport(mode => new AzureFunctionsLaunchConfiguration { ProjectPath = projectMetadata.ProjectPath, Mode = mode }, "azure-functions"); + .WithDebugSupport( + context => Task.FromResult(new AzureFunctionsLaunchConfiguration + { + ProjectPath = projectMetadata.ProjectPath, + Mode = context.Mode + }), + "azure-functions"); #pragma warning restore ASPIREEXTENSION001 // Only validate Azure Functions Core Tools in run mode (not during publish) diff --git a/src/Aspire.Hosting.Go/GoHostingExtensions.cs b/src/Aspire.Hosting.Go/GoHostingExtensions.cs index e1d298d7f1e..2e9900034c6 100644 --- a/src/Aspire.Hosting.Go/GoHostingExtensions.cs +++ b/src/Aspire.Hosting.Go/GoHostingExtensions.cs @@ -757,7 +757,7 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder var resource = builder.Resource; return builder.WithDebugSupport( - mode => + context => { // Resolve annotations when DCP creates the launch configuration so later // resource mutations such as WithWorkingDirectory(...) are reflected. @@ -767,13 +767,13 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder : "."; var buildFlags = BuildFlagsString(resource); - return new GoLaunchConfiguration + return Task.FromResult(new GoLaunchConfiguration { Program = Path.GetFullPath(packagePath, workingDirectory), - Mode = mode, + Mode = context.Mode, WorkingDirectory = workingDirectory, BuildFlags = buildFlags.Length > 0 ? buildFlags : null - }; + }); }, "go", static ctx => diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index b9552eb7ed5..d8709bfbbe3 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -2161,21 +2161,21 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder var workingDirectory = Path.GetFullPath(resource.WorkingDirectory); return builder.WithDebugSupport( - mode => + context => { // Compute at run time so the launch config reflects the final annotation state var hasRunScript = resource.TryGetLastAnnotation(out _); var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation); var isPackageManagerScript = hasRunScript && hasPackageManager; - return new JavaScriptLaunchConfiguration(launchConfigType) + return Task.FromResult(new JavaScriptLaunchConfiguration(launchConfigType) { ScriptPath = Path.GetFullPath(scriptPath, workingDirectory), - Mode = mode, + Mode = context.Mode, RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType, LaunchMethod = isPackageManagerScript ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager : JavaScriptLaunchConfiguration.LaunchMethodDirect, WorkingDirectory = workingDirectory - }; + }); }, launchConfigType); } @@ -2196,7 +2196,7 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder } return builder.WithDebugSupport( - mode => + context => { // Fall back to "npm" (the default for these frameworks) if no package manager annotation is present. var packageManager = "npm"; @@ -2205,14 +2205,14 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder packageManager = pmAnnotation.ExecutableName; } - return new JavaScriptLaunchConfiguration("node") + return Task.FromResult(new JavaScriptLaunchConfiguration("node") { ScriptPath = string.Empty, - Mode = mode, + Mode = context.Mode, RuntimeExecutable = packageManager, LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager, WorkingDirectory = workingDirectory - }; + }); }, "node"); } @@ -2265,7 +2265,7 @@ public static IResourceBuilder WithBrowserDebugger( .WaitFor(builder) .ExcludeFromManifest() .WithDebugSupport( - mode => + context => { // Resolve endpoint at run time so dynamically added endpoints are reflected EndpointAnnotation? endpointAnnotation = null; @@ -2283,13 +2283,13 @@ public static IResourceBuilder WithBrowserDebugger( var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); - return new BrowserLaunchConfiguration + return Task.FromResult(new BrowserLaunchConfiguration { - Mode = mode, + Mode = context.Mode, Url = endpointReference.Url, WebRoot = parentResource.WorkingDirectory, Browser = browser - }; + }); }, BrowserCapability); diff --git a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs index bd4dc588126..1172051dfd1 100644 --- a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs +++ b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs @@ -29,17 +29,19 @@ internal static IResourceBuilder WithMauiIdeLaunchConfiguration( Dictionary? msBuildProperties = null) where T : ProjectResource { #pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental - return resourceBuilder.WithDebugSupport(mode => new MauiLaunchConfiguration - { - Mode = mode, - ProjectPath = projectPath, - TargetFramework = targetFramework, - Platform = platform, - TargetKind = targetKind, - Device = device, - RuntimeIdentifier = runtimeIdentifier, - MsBuildProperties = msBuildProperties - }, MauiLaunchConfigurationType); + return resourceBuilder.WithDebugSupport( + context => Task.FromResult(new MauiLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = projectPath, + TargetFramework = targetFramework, + Platform = platform, + TargetKind = targetKind, + Device = device, + RuntimeIdentifier = runtimeIdentifier, + MsBuildProperties = msBuildProperties + }), + MauiLaunchConfigurationType); #pragma warning restore ASPIREEXTENSION001 } diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 949ea8b8e0e..fa839294c8f 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -939,7 +939,7 @@ public static IResourceBuilder WithDebugging( var entrypoint = entrypointAnnotation.Entrypoint; builder.WithDebugSupport( - mode => + context => { // Compute paths inside the lambda so a later WithWorkingDirectory(...) override is respected. var workingDirectory = builder.Resource.WorkingDirectory; @@ -984,14 +984,14 @@ public static IResourceBuilder WithDebugging( } } - return new PythonLaunchConfiguration + return Task.FromResult(new PythonLaunchConfiguration { ProgramPath = programPath, Module = module, - Mode = mode, + Mode = context.Mode, InterpreterPath = interpreterPath, WorkingDirectory = workingDirectory - }; + }); }, "python", static ctx => diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index a7b5bf72705..a71431b5de2 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -505,7 +505,8 @@ public static IResourceBuilder WithProjectDefaults ProjectLaunchConfigurationFactory.Create(builder.Resource, mode), + context => Task.FromResult( + ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), KnownLaunchConfigurationTypes.Project); // File-based apps (a bare .cs file) are a .NET 10 SDK feature. The check lives here rather than in From 9efdff804d43e3cddde3ae728b0e270acdf94a60 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:36:02 -0400 Subject: [PATCH 07/30] Finalize debug callback context API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- .../ExecutableLaunchConfiguration.cs | 7 +- .../ResourceBuilderExtensions.cs | 96 +-------- .../SupportsDebuggingAnnotation.cs | 5 +- .../DotnetProjectResourceTests.cs | 9 +- .../Dcp/DcpExecutorTests.cs | 203 +++++++++++++----- .../DebugSupportExtensionsTests.cs | 57 +++-- ...ExecutableResourceBuilderExtensionTests.cs | 88 +++----- 7 files changed, 235 insertions(+), 230 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs index 21b0f95cd08..bc8fb299dda 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. +/// . /// /// /// 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 available to the producer callback through + /// . /// [JsonPropertyName("mode")] public string Mode { get; set; } = System.Diagnostics.Debugger.IsAttached ? ExecutableLaunchMode.Debug : ExecutableLaunchMode.NoDebug; diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index e2c37506598..77bcff3ba2f 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4761,101 +4761,25 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde } /// - /// 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). - /// Optional callback to add or modify command line arguments when running in an extension host. Useful if the entrypoint is usually provided as an argument to the resource executable. - /// The . - /// - /// is a or , which means an - /// asynchronous producer was written without the parameter and bound to this - /// overload. Use instead. - /// - /// - /// Aspire invokes active custom launch configuration producers from the executable creation path after - /// endpoints and the resource execution configuration have resolved. The producer may be invoked several - /// times for the same resource, such as during restarts or when creating replicas. 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. - /// - [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, Action? argsCallback = null) - where T : IResource - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(launchConfigurationProducer); - - 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.", - nameof(launchConfigurationProducer)); - } - - return builder.WithDebugSupport( - context => Task.FromResult(launchConfigurationProducer(context.Mode)), - launchConfigurationType, - argsCallback); - - 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. - /// - /// 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. - /// Optional callback to add or modify command line arguments when running in an extension host. Useful if the entrypoint is usually provided as an argument to the resource executable. + /// + /// A callback that receives the resolved execution configuration and runtime launch context, and asynchronously + /// produces the complete launch configuration handed to the IDE. + /// + /// The type tag of the launch configuration sent to the IDE. + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. /// The . /// - /// Use this overload when the launch configuration has to be resolved from work that is itself asynchronous. - /// Aspire invokes active custom launch configuration producers from the executable creation path after - /// endpoints and the resource execution configuration have resolved, and may invoke them several times for - /// the same resource. - /// A producer that computes everything synchronously should use - /// - /// instead. + /// Registering debug support is synchronous; Aspire invokes + /// later for each executable creation, restart, or replica. A producer that completes synchronously should + /// return its result with . /// [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, Action? argsCallback = null) - where T : IResource - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(launchConfigurationProducer); - - return builder.WithDebugSupport( - context => launchConfigurationProducer(context.Mode, context.CancellationToken), - launchConfigurationType, - argsCallback); - } - - /// - /// Adds support for debugging the resource in VS Code when running in an extension host, using a - /// callback context that contains the resolved execution configuration. - /// - /// The resource type. - /// The launch configuration type produced for the resource, typically derived from . - /// The resource builder. - /// Launch configuration producer for the resource. - /// The type of the resource. - /// Optional callback to add or modify command line arguments when running in an extension host. - /// The . - [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] - [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] - [OverloadResolutionPriority(1)] public static IResourceBuilder WithDebugSupport( this IResourceBuilder builder, Func> launchConfigurationProducer, diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index ef55b6cae80..58cf1690cc1 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -12,9 +12,8 @@ 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 +/// Added by . +/// 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. diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index ae9fed0c060..d0d36924058 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs @@ -331,7 +331,9 @@ public async Task AddDotnetProject_InDebugSession_KeepsDotnetRunArgs_WhenActiveC var projectPath = Path.Combine(builder.AppHostDirectory, "MyService", "MyService.csproj"); var app = builder.AddDotnetProject("svc", projectPath, o => o.ExcludeLaunchProfile = true) .WithArgs("--config", "prod.yaml") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("custom"), "custom"); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("custom")), + "custom"); using var application = builder.Build(); var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services); @@ -364,7 +366,10 @@ public async Task AddDotnetProject_InDebugSession_OmitsDotnetRunScaffolding_When var projectPath = Path.Combine(builder.AppHostDirectory, "MyService", "MyService.csproj"); var app = builder.AddDotnetProject("svc", projectPath, o => o.ExcludeLaunchProfile = true) .WithArgs("--config", "prod.yaml") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("custom"), "custom", ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("custom")), + "custom", + ctx => ctx.Args.Add("rewritten-arg")); using var application = builder.Build(); var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 425844dc5bd..3cda2f2ad0a 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3485,12 +3485,14 @@ public async Task ProjectLaunchConfiguration_UsesProjectDebugSupportProducer_InD projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(_ => new ProjectLaunchConfiguration - { - Mode = ExecutableLaunchMode.NoDebug, - ProjectPath = "ProducerSuppliedPath", - DisableLaunchProfile = true - }, "project"); + projectBuilder.WithDebugSupport( + static _ => Task.FromResult(new ProjectLaunchConfiguration + { + Mode = ExecutableLaunchMode.NoDebug, + ProjectPath = "ProducerSuppliedPath", + DisableLaunchProfile = true + }), + "project"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -3880,7 +3882,9 @@ public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); // No SupportsDebuggingAnnotation for this one @@ -3939,7 +3943,9 @@ public async Task PersistentPlainExecutable_ExtensionMode_RunsInProcess() var executable = new TestExecutableResource("test-working-directory"); builder.AddResource(executable) - .WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test") + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test") .WithPersistentLifetime(); var configDict = new Dictionary @@ -3985,7 +3991,7 @@ public async Task ProjectResource_WithArgumentRewritingDebugSupport_DoesNotOffer } projectBuilder.WithDebugSupport( - mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, + context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), "test", argsCallback: _ => { /* rewrites arguments for debugging */ }); @@ -4429,7 +4435,9 @@ public async Task PlainExecutable_ExtensionMode_UnsupportedDebugMode_RunsInProce // Create executable resources with SupportsDebuggingAnnotation var executable = new TestExecutableResource("test-working-directory"); - builder.AddResource(executable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(executable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate debug session port and extension endpoint (extension mode) var configDict = new Dictionary @@ -4465,7 +4473,9 @@ public async Task PlainExecutable_NoExtensionMode_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); builder.AddResource(nonDebuggableExecutable); @@ -4507,7 +4517,9 @@ public async Task CustomExecutable_NoDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate no debug session port and no extension endpoint (no debug session info) var configDict = new Dictionary @@ -4543,7 +4555,9 @@ public async Task CustomExecutable_InvalidDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate debug session port with invalid JSON in DebugSessionInfo var configDict = new Dictionary @@ -4579,7 +4593,9 @@ public async Task CustomExecutable_DebugSessionInfoWithNullSupportedLaunchConfig // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate debug session info with null SupportedLaunchConfigurations var runSessionInfo = new RunSessionInfo @@ -4621,7 +4637,9 @@ public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProces // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate debug session info with SupportedLaunchConfigurations that do not match the executable type var runSessionInfo = new RunSessionInfo @@ -4663,7 +4681,9 @@ public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), + "test"); // Simulate debug session info with SupportedLaunchConfigurations that match the executable type var runSessionInfo = new RunSessionInfo @@ -4741,7 +4761,11 @@ public async Task Project_WithTerminal_RunsAsProcess_InDebugSessionWhenDebugSupp var debugArgsCallbackInvoked = false; var resource = builder.AddProject("ServiceA").WithTerminal(); resource.WithDebugSupport( - mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode }, + context => Task.FromResult(new ProjectLaunchConfiguration + { + ProjectPath = "/test/path", + Mode = context.Mode + }), "project", argsCallback: _ => debugArgsCallbackInvoked = true); @@ -4936,7 +4960,9 @@ public async Task ProjectWithNonProjectAnnotation_DebugSessionWithoutInfo_FallsB { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var configDict = new Dictionary { @@ -4976,7 +5002,9 @@ public async Task ProjectWithNonProjectAnnotation_VSCodeExplicitlyUnsupported_Ru { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -5032,7 +5060,9 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_VSCodeE Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), + launchConfigurationType) .WithArgs(resourceArgs); var runSessionInfo = new RunSessionInfo @@ -5092,7 +5122,9 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebug Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), + launchConfigurationType) .WithArgs(resourceArgs); var configDict = new Dictionary @@ -5144,7 +5176,9 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebug Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), + launchConfigurationType) .WithArgs(resourceArgs); var kubernetesService = new TestKubernetesService(); @@ -5188,18 +5222,20 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport(mode => new TestMauiLaunchConfiguration - { - Mode = mode, - ProjectPath = "/tmp/mauiapp/MauiApp.csproj", - TargetFramework = "net10.0-android", - Platform = "android", - TargetKind = "emulator", - MsBuildProperties = new Dictionary + .WithDebugSupport( + context => Task.FromResult(new TestMauiLaunchConfiguration { - ["AdbTarget"] = "-e" - } - }, "maui") + Mode = context.Mode, + ProjectPath = "/tmp/mauiapp/MauiApp.csproj", + TargetFramework = "net10.0-android", + Platform = "android", + TargetKind = "emulator", + MsBuildProperties = new Dictionary + { + ["AdbTarget"] = "-e" + } + }), + "maui") .WithArgs("run", "-f", "net10.0-android", "-p:AdbTarget=-e"); var runSessionInfo = new RunSessionInfo @@ -5270,7 +5306,9 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchP Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport(mode => new ExecutableLaunchConfiguration("maui") { Mode = mode }, "maui") + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("maui") { Mode = context.Mode }), + "maui") .WithArgs("run", "-f", "net10.0-ios", "-p:_DeviceName=:v2:udid=E25BBE37-69BA-4720-B6FD-D54C97791E79"); var runSessionInfo = new RunSessionInfo @@ -5330,7 +5368,9 @@ public async Task ProjectWithNonProjectAnnotation_NoDebugSession_RunsInProcess() { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -5357,7 +5397,9 @@ public async Task ProjectWithNonProjectAnnotation_VSCodeWithMatchingSupport_Runs { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -5404,7 +5446,9 @@ public async Task StandardAndCustomProjects_VSScenario_BothRunInIde() { customProject.Resource.Annotations.Remove(annotationToRemove); } - customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + customProject.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var configDict = new Dictionary { @@ -5454,7 +5498,9 @@ public async Task StandardAndCustomProjects_VSCodeScenario_BothRunInIde() { customProject.Resource.Annotations.Remove(annotationToRemove); } - customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + customProject.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -5500,7 +5546,9 @@ public async Task ProjectWithNonProjectAnnotation_VSFallback_HasProcessFallbackE { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var configDict = new Dictionary { @@ -5659,16 +5707,16 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu [Fact] public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate() { - // Both custom producer types share the CreateObjectAsync path after endpoints and execution - // configuration resolve. + // Like ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate, this producer + // runs from CreateObjectAsync after endpoints and execution configuration resolve. var builder = DistributedApplication.CreateBuilder(); var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - async (mode, ct) => + async context => { await Task.Yield(); - return new ExecutableLaunchConfiguration("test") { Mode = mode }; + return new ExecutableLaunchConfiguration("test") { Mode = context.Mode }; }, "test"); @@ -5705,7 +5753,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"); @@ -6073,7 +6121,9 @@ public async Task DotnetProjectExecutable_InDebugSession_GetsIdeExecutionWithPro builder.AddResource(resource) .WithAnnotation(new TestProjectWithLaunchSettings()) .WithAnnotation(new LaunchProfileAnnotation("http")) - .WithDebugSupport(mode => ProjectLaunchConfigurationFactory.Create(resource, mode), KnownLaunchConfigurationTypes.Project); + .WithDebugSupport( + context => Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + KnownLaunchConfigurationTypes.Project); var configDict = new Dictionary { @@ -6141,7 +6191,13 @@ public async Task DotnetProjectExecutable_ProjectLaunchUnsupported_RunsInProcess var resource = new TestDotnetProjectExecutableResource("test-working-directory"); builder.AddResource(resource) .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project"); + .WithDebugSupport( + context => Task.FromResult(new ProjectLaunchConfiguration + { + ProjectPath = "TestProjectWithLaunchSettings", + Mode = context.Mode + }), + "project"); var configDict = new Dictionary { @@ -6171,7 +6227,13 @@ public async Task DotnetProjectExecutable_PersistentLifetime_InDebugSession_Runs var resource = new TestDotnetProjectExecutableResource("test-working-directory"); builder.AddResource(resource) .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project") + .WithDebugSupport( + context => Task.FromResult(new ProjectLaunchConfiguration + { + ProjectPath = "TestProjectWithLaunchSettings", + Mode = context.Mode + }), + "project") .WithPersistentLifetime(); var configDict = new Dictionary @@ -6244,9 +6306,18 @@ public async Task DotnetProjectExecutable_ProjectLaunchConfigurationFailure_Fail logLines.AddRange(lines); } - Assert.Contains(logLines, line => line.Content.Contains("Project launch configuration failed.", StringComparison.Ordinal)); + Assert.Contains( + logLines, + line => line.Content.Contains( + "The \"project\" launch configuration producer for resource 'TestDotnetProject' failed.", + StringComparison.Ordinal)); + Assert.Contains( + logLines, + line => line.Content.Contains( + "Project launch configuration failed.", + StringComparison.Ordinal)); - static Task CreateProjectLaunchConfiguration(string mode, CancellationToken cancellationToken) + static Task CreateProjectLaunchConfiguration(LaunchConfigurationCallbackContext context) { throw new InvalidOperationException("Project launch configuration failed."); } @@ -6265,7 +6336,7 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OmitsP builder.AddResource(debuggableExecutable) .WithArgs("run", "app-arg") .WithDebugSupport( - mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, + context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), "test", argsCallback: static ctx => { @@ -6348,7 +6419,7 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Launch Assert.Empty(kubernetesService.CreatedResources.OfType()); Assert.Same(resource, Assert.Single(failedResources)); - static Task ThrowingLaunchConfiguration(string mode, CancellationToken cancellationToken) + static Task ThrowingLaunchConfiguration(LaunchConfigurationCallbackContext context) { throw new InvalidOperationException("Launch configuration failed."); } @@ -6366,7 +6437,13 @@ public async Task PlainExecutable_ProjectDebugSupportWithoutProjectMetadata_Fail var resource = new TestExecutableResource("test-working-directory"); builder.AddResource(resource) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode }, "project"); + .WithDebugSupport( + context => Task.FromResult(new ProjectLaunchConfiguration + { + ProjectPath = "/test/path", + Mode = context.Mode + }), + "project"); var configDict = new Dictionary { @@ -6409,7 +6486,13 @@ public async Task DotnetProjectExecutable_RespectsDebugSessionRunMode(string run builder.AddResource(resource) .WithAnnotation(new TestProjectWithLaunchSettings()) .WithAnnotation(new LaunchProfileAnnotation("http")) - .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project"); + .WithDebugSupport( + context => Task.FromResult(new ProjectLaunchConfiguration + { + ProjectPath = "TestProjectWithLaunchSettings", + Mode = context.Mode + }), + "project"); var configDict = new Dictionary { @@ -7246,7 +7329,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( + static _ => throw new InvalidOperationException("Test exception from launch configuration producer"), + "test"); var runSessionInfo = new RunSessionInfo { @@ -7298,7 +7383,9 @@ public async Task Project_NonProjectLaunchConfig_ExtensionMode_RunsInIde() { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); var configDict = new Dictionary { @@ -7344,7 +7431,7 @@ public async Task Project_NonProjectLaunchConfig_AnnotatorThrows_FallsBackToProc projectBuilder.Resource.Annotations.Remove(annotationToRemove); } projectBuilder.WithDebugSupport( - (_, _) => throw new InvalidOperationException("Test exception from launch configuration producer"), + static _ => throw new InvalidOperationException("Test exception from launch configuration producer"), "azure-functions"); var configDict = new Dictionary @@ -7384,7 +7471,9 @@ public async Task Project_NonProjectLaunchConfig_UnsupportedByExtension_RunsInPr { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); + projectBuilder.WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), + "azure-functions"); // Extension does NOT list "azure-functions" in SupportedLaunchConfigurations var configDict = new Dictionary diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index 4e50620af8f..7f8aa00ff1e 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -63,12 +63,14 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForACustomPro // owns the whole configuration, so its output is returned (and sent) verbatim. using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: "http") - .WithDebugSupport(mode => new ProjectLaunchConfiguration - { - Mode = mode, - ProjectPath = "custom-path", - LaunchProfile = "https" - }, KnownLaunchConfigurationTypes.Project); + .WithDebugSupport( + context => Task.FromResult(new ProjectLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = "custom-path", + LaunchProfile = "https" + }), + KnownLaunchConfigurationTypes.Project); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.NoDebug)); @@ -82,7 +84,13 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForNonProject { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }, "go"); + .WithDebugSupport( + context => Task.FromResult(new TestGoLaunchConfiguration + { + Mode = context.Mode, + Package = "./cmd/api" + }), + "go"); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.NoDebug)); @@ -98,10 +106,14 @@ 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 context => { await Task.Yield(); - return new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }; + return new TestGoLaunchConfiguration + { + Mode = context.Mode, + Package = "./cmd/api" + }; }, "go"); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); @@ -118,10 +130,10 @@ public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToThePr CancellationToken observedToken = default; var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport((mode, ct) => + .WithDebugSupport(context => { - observedToken = ct; - return Task.FromResult(new TestGoLaunchConfiguration { Mode = mode }); + observedToken = context.CancellationToken; + return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); }, "go"); await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug, cts.Token); @@ -147,7 +159,10 @@ 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( + context => Task.FromResult( + ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + KnownLaunchConfigurationTypes.Project); var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); @@ -314,7 +329,9 @@ public void SupportsDebuggingReturnsFalseWhenNoDebugSessionIsActive() { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); + .WithDebugSupport( + context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(debugSessionPort: null), out _)); } @@ -338,7 +355,9 @@ public void SupportsDebuggingReturnsFalseForANonProjectTypeWhenTheIdeSendsNoCapa // capabilities cannot be assumed to know how to launch a "go" resource. using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); + .WithDebugSupport( + context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(), out _)); } @@ -352,7 +371,9 @@ public void SupportsDebuggingHonorsTheAdvertisedCapabilityList(string[] supporte { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); + .WithDebugSupport( + context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); var configuration = CreateConfiguration(debugSessionInfo: CreateDebugSessionInfo(supportedLaunchConfigurations)); @@ -401,7 +422,9 @@ public void SupportsDebuggingFallsBackToTheImplicitProjectRuleWhenDebugSessionIn using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: "http"); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); + .WithDebugSupport( + context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); var configuration = CreateConfiguration(debugSessionInfo: "{ not json"); diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 3558962ac7c..b90e0a1d989 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -92,7 +92,7 @@ public async Task WithDebugSupportAddsAnnotationInRunMode() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var launchConfig = new ExecutableLaunchConfiguration("python"); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => launchConfig, "ms-python.python"); + .WithDebugSupport(_ => Task.FromResult(launchConfig), "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); @@ -113,38 +113,16 @@ public void WithDebugSupportDoesNotAddAnnotationInPublishMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("python"), "ms-python.python"); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("python")), + "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.Null(annotation); } [Fact] - public async Task WithDebugSupportAsynchronousProducerProducesTheSameAnnotationAsTheSynchronousOne() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - 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) => - { - await Task.Yield(); - return new ExecutableLaunchConfiguration("go") { Mode = mode }; - }, "go"); - - var syncConfiguration = Assert.IsType( - await syncExecutable.Resource.CreateLaunchConfigurationAsync( - LaunchConfigurationTestHelpers.CreateCallbackContext(syncExecutable.Resource, ExecutableLaunchMode.Debug))); - var asyncConfiguration = Assert.IsType( - await asyncExecutable.Resource.CreateLaunchConfigurationAsync( - LaunchConfigurationTestHelpers.CreateCallbackContext(asyncExecutable.Resource, ExecutableLaunchMode.Debug))); - - Assert.Equal(asyncConfiguration.Type, syncConfiguration.Type); - Assert.Equal(asyncConfiguration.Mode, syncConfiguration.Mode); - } - - [Fact] - public async Task WithDebugSupportBindsAContextIgnoringAsyncProducerToTheContextOverload() + public async Task WithDebugSupportSupportsAContextIgnoringProducer() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var executable = builder.AddExecutable("async", "command", "workingdirectory") @@ -159,34 +137,6 @@ await executable.Resource.CreateLaunchConfigurationAsync( Assert.Equal("go", launchConfiguration.Type); } - [Fact] - public void WithDebugSupportRejectsATaskReturningSynchronousProducer() - { - // `mode => Task.FromResult(...)` binds to the synchronous overload (overload resolution only - // looks at the lambda's parameter count) with TLaunchConfiguration inferred as Task, so the - // task itself would be serialized as the launch configuration. It must be rejected up front. - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var executable = builder.AddExecutable("myexe", "command", "workingdirectory"); - - var exception = Assert.Throws( - () => executable.WithDebugSupport(mode => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); - - Assert.Equal("launchConfigurationProducer", exception.ParamName); - Assert.Contains(nameof(CancellationToken), exception.Message); - } - - [Fact] - public void WithDebugSupportRejectsAValueTaskReturningSynchronousProducer() - { - using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var executable = builder.AddExecutable("myexe", "command", "workingdirectory"); - - var exception = Assert.Throws( - () => executable.WithDebugSupport(mode => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); - - Assert.Equal("launchConfigurationProducer", exception.ParamName); - } - [Fact] public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() { @@ -204,7 +154,10 @@ public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() var executable = builder.AddExecutable("myexe", "command", "workingdirectory") .WithArgs("base-arg") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go", + ctx => ctx.Args.Add("rewritten-arg")); var args = await ArgumentEvaluator.GetArgumentListAsync(executable.Resource); @@ -234,8 +187,13 @@ public async Task WithDebugSupportArgsCallbackDoesNotRunWhenLaterDebugSupportSup var executable = builder.AddExecutable("myexe", "command", "workingdirectory") .WithArgs("base-arg") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")) - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("project"), "project"); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go", + ctx => ctx.Args.Add("rewritten-arg")) + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("project")), + "project"); var args = await ArgumentEvaluator.GetArgumentListAsync(executable.Resource); @@ -251,7 +209,10 @@ public void WithDebugSupportReportsRewritesArgumentsWhenResourceSupportsArgs() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go", + ctx => ctx.Args.Add("rewritten-arg")); var annotation = executable.Resource.Annotations.OfType().Single(); Assert.True(annotation.RewritesArgumentsForDebugging); @@ -266,7 +227,10 @@ public void WithDebugSupportDoesNotReportRewritesArgumentsWhenResourceHasNoArgs( using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var resource = builder.AddResource(new DebuggableResourceWithoutArgs("noargs")) - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go", + ctx => ctx.Args.Add("rewritten-arg")); var annotation = resource.Resource.Annotations.OfType().Single(); Assert.False(annotation.RewritesArgumentsForDebugging); @@ -278,7 +242,9 @@ public void WithDebugSupportDoesNotReportRewritesArgumentsWhenNoArgsCallbackProv using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go"); + .WithDebugSupport( + static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), + "go"); var annotation = executable.Resource.Annotations.OfType().Single(); Assert.False(annotation.RewritesArgumentsForDebugging); From 65a56a752ec2c6fa415935ef91bb7dac68154b6d Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:47:35 -0400 Subject: [PATCH 08/30] Migrate debug support playground Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- .../ProjectResourceExtensions.AppHost/AppHost.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs b/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs index 59c3a5fcaca..362243130b8 100644 --- a/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs +++ b/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs @@ -33,7 +33,11 @@ // exactly as Aspire.Hosting.Azure.Functions does with "azure-functions". builder.AddProject("custom-debug-service") .WithDebugSupport( - mode => new CustomLaunchConfiguration { Mode = mode, ProjectPath = "CustomDebugService" }, + context => Task.FromResult(new CustomLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = "CustomDebugService" + }), "custom-debug-type"); builder.Build().Run(); From ac329750b15774572a501c2e962c2976a63680ea Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 14:56:15 -0400 Subject: [PATCH 09/30] Fix debug support documentation whitespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- src/Aspire.Hosting/SupportsDebuggingAnnotation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 58cf1690cc1..8af23663375 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -39,7 +39,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 From b0fcfd3ebc6cf6b80571b521f546fd85db235bfd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 6 Aug 2026 15:37:39 -0400 Subject: [PATCH 10/30] Address callback context review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e --- .../CompatibilitySuppressions.xml | 7 ++ .../Dcp/DcpExecutorTests.cs | 65 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index bd08efb4a92..a604bf888db 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,6 +1,13 @@  + + CP0002 + M:Aspire.Hosting.ResourceBuilderExtensions.WithDebugSupport``2(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Func{System.String,``1},System.String,System.Action{Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext}) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0006 M:Aspire.Hosting.IInteractionService.PromptProgressAsync(System.String,System.String,Aspire.Hosting.ProgressInteractionOptions,System.Threading.CancellationToken) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 3cda2f2ad0a..ab77489fba3 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -443,6 +443,71 @@ public async Task RunApplicationAsync_AllowsContainerNameMatchingContainerTunnel Assert.Equal(3, kubernetesService.CreatedResources.OfType().Count()); } + [Fact] + public async Task ProjectReplicas_CreateFreshLaunchConfigurationContexts() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + + var launchContexts = new ConcurrentQueue(); + var project = builder.AddProject("ServiceA") + .WithReplicas(2) + .WithEnvironment("REPLICA_VALUE", "resolved") + .WithDebugSupport( + context => + { + launchContexts.Enqueue(context); + return Task.FromResult( + ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); + }, + KnownLaunchConfigurationTypes.Project); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] + }), + [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); + + await appExecutor.RunApplicationAsync(); + + var executables = GetCreatedExecutablesForResource(kubernetesService, project.Resource.Name); + Assert.Equal(2, executables.Count); + Assert.All(executables, executable => + { + Assert.Equal(ExecutionType.IDE, executable.Spec.ExecutionType); + Assert.True(executable.TryGetProjectLaunchConfiguration(out var launchConfiguration)); + Assert.NotNull(launchConfiguration); + }); + + var contexts = launchContexts.ToArray(); + Assert.Equal(2, contexts.Length); + Assert.NotSame(contexts[0], contexts[1]); + Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); + Assert.All(contexts, context => Assert.Same(project.Resource, context.Resource)); + Assert.All( + contexts, + context => Assert.Contains( + context.ExecutionConfiguration.EnvironmentVariables, + pair => pair is { Key: "REPLICA_VALUE", Value: "resolved" })); + } + [Fact] public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext() { From 8de33ceaad4312d5128343b6aa08e1a84891345c Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 09:52:55 -0400 Subject: [PATCH 11/30] Remove agent planning artifacts and restore load-bearing comments Drop the two docs/superpowers/ files from the PR. They are agent task-tracking artifacts (the plan opens with a "REQUIRED SUB-SKILL" directive for agentic workers), not product documentation, and docs/superpowers/ does not exist on main - the repo uses docs/plans and docs/specs. Restore the two WHY comments in SupportsDebuggingAnnotation.Create that were dropped during the callback-context migration while the code they explain is unchanged. Without the first, a future refactor to Func<..., Task> silently changes the emitted DCP JSON; without the second, the `!` suppression looks unjustified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1a526bf7-ac97-43ca-8e70-4cb6989dd278 --- ...5-launch-configuration-callback-context.md | 1824 ----------------- ...h-configuration-callback-context-design.md | 228 --- .../SupportsDebuggingAnnotation.cs | 4 + 3 files changed, 4 insertions(+), 2052 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md delete mode 100644 docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md diff --git a/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md b/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md deleted file mode 100644 index d1e941d3b53..00000000000 --- a/docs/superpowers/plans/2026-08-05-launch-configuration-callback-context.md +++ /dev/null @@ -1,1824 +0,0 @@ -# Launch Configuration Callback Context Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` (recommended) or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give every `WithDebugSupport` launch configuration producer the exact resolved execution configuration and standard runtime callback data used to create its DCP executable. - -**Architecture:** Add one experimental `LaunchConfigurationCallbackContext` and one task-returning `WithDebugSupport` overload. `ExecutableCreator.CreateObjectAsync` will construct a fresh context after resolving arguments and environment variables, then invoke every active custom producer—including `project` producers—through the same path. The public inspection helper will require an explicit context so it never evaluates resource callbacks itself. - -**Tech Stack:** .NET 10, C# 13, Aspire hosting application model, DCP executable model, xUnit v3 with Microsoft.Testing.Platform - ---- - -## Scope and file structure - -This plan implements the framework change and migrates every `WithDebugSupport` caller present on `microsoft/main`. The Rust integration from PR #18906 is not present on this branch, so its consumer update remains a follow-up on that PR after this framework change is available; the exact Rust migration is included at the end of this plan. - -### New files - -- `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs` - - Owns the public runtime data passed to launch configuration producers. -- `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs` - - Creates explicit callback contexts and execution results for tests without evaluating resource callbacks. - -### Core files - -- `src/Aspire.Hosting/ResourceBuilderExtensions.cs` - - Replaces the two producer overloads with one context/task overload. -- `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs` - - Stores a context-based producer and annotator and supplies resource-specific producer diagnostics. -- `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs` - - Requires an explicit callback context for launch configuration inspection. -- `src/Aspire.Hosting/Dcp/ExecutableCreator.cs` - - Creates the context from the authoritative execution result and moves custom `project` producer invocation to creation time. -- `src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs` - - Updates XML documentation references to the final overload. - -### Production callers - -- `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs` -- `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs` -- `src/Aspire.Hosting.Go/GoHostingExtensions.cs` -- `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs` -- `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs` -- `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs` - -### Tests - -- `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` -- `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` -- `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` -- `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs` -- `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs` -- `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs` -- `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs` -- `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs` -- `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs` - -Do not edit any generated `src/*/api/*.cs` file. - -### Task 1: Add the callback contract and unify the DCP lifecycle - -**Consumed by:** Tasks 2, 3, 4 — production callers and tests compile against this contract, and all later behavior depends on the unified creation path - -**Files:** -- Create: `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs` -- Create: `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs` -- Modify: `src/Aspire.Hosting/ResourceBuilderExtensions.cs:4750-4850` -- Modify: `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs` -- Modify: `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs:75-130` -- Modify: `src/Aspire.Hosting/Dcp/ExecutableCreator.cs:60-215, 250-345, 816-839` -- Modify: `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` -- Modify: `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs:85-110` -- Modify: `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs:445-525, 2860-2930, 3110-3235, 3980-4045, 4860-4955, 7000-7140` -- Modify: `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs:200-247` -- Modify: `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs:1141-1155` -- Modify: `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs:1622-1636` -- Modify: `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs:629-643` -- Modify: `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs:397-411` -- Modify: `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs:882-898` - -- [ ] **Step 1: Restore the worktree SDK and dependencies** - -Run: - -```bash -./restore.sh -``` - -Expected: exit code `0`; the repository-local .NET SDK is ready. - -- [ ] **Step 2: Add a shared test helper for explicit callback contexts** - -Create `tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs`: - -```csharp -// 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 - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aspire.Hosting.Tests.Utils; - -public static class LaunchConfigurationTestHelpers -{ - public static LaunchConfigurationCallbackContext CreateCallbackContext( - IResource resource, - string mode = ExecutableLaunchMode.Debug, - IExecutionConfigurationResult? executionConfiguration = null, - DistributedApplicationExecutionContext? executionContext = null, - ILogger? logger = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(resource); - - return new LaunchConfigurationCallbackContext - { - Mode = mode, - Resource = resource, - ExecutionConfiguration = executionConfiguration ?? CreateExecutionConfigurationResult(), - ExecutionContext = executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), - Logger = logger ?? NullLogger.Instance, - CancellationToken = cancellationToken - }; - } - - public static IExecutionConfigurationResult CreateExecutionConfigurationResult( - IEnumerable? arguments = null, - IEnumerable>? environmentVariables = null, - Exception? exception = null) - { - return new ExecutionConfigurationResult - { - References = [], - ArgumentsWithUnprocessed = (arguments ?? []) - .Select(value => ((object)value, value, false)) - .ToArray(), - EnvironmentVariablesWithUnprocessed = (environmentVariables ?? []) - .Select(pair => new KeyValuePair( - pair.Key, - (pair.Value, pair.Value))) - .ToArray(), - AdditionalConfigurationData = [], - Exception = exception - }; - } -} -``` - -- [ ] **Step 3: Write failing inspection-helper tests** - -In `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs`, add this private helper: - -```csharp -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); -} -``` - -Replace each existing call shaped as: - -```csharp -resource.CreateLaunchConfigurationAsync(mode, cancellationToken) -``` - -with: - -```csharp -CreateLaunchConfigurationForTestAsync(resource, mode, cancellationToken) -``` - -Use the two-argument helper call when the old call omitted its cancellation token: - -```csharp -CreateLaunchConfigurationForTestAsync(resource, mode) -``` - -Then add these tests: - -```csharp -[Fact] -public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks() -{ - using var builder = TestDistributedApplicationBuilder.Create(); - var environmentCallbackCount = 0; - LaunchConfigurationCallbackContext? observedContext = null; - - var executable = builder.AddExecutable("app", "go", ".") - .WithEnvironment(context => - { - Interlocked.Increment(ref environmentCallbackCount); - context.EnvironmentVariables["UNEXPECTED"] = "value"; - }) - .WithDebugSupport((LaunchConfigurationCallbackContext context) => - { - observedContext = context; - return Task.FromResult(new TestGoLaunchConfiguration - { - Mode = context.Mode, - Package = context.ExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "EXPECTED") - .Value - }); - }, "go"); - - var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( - environmentVariables: [new("EXPECTED", "./cmd/api")]); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - ExecutableLaunchMode.NoDebug, - executionConfiguration); - - var launchConfiguration = Assert.IsType( - await executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Same(callbackContext, observedContext); - Assert.Equal(0, environmentCallbackCount); - Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); - Assert.Equal("./cmd/api", launchConfiguration.Package); -} - -[Fact] -public async Task CreateLaunchConfigurationRejectsAContextForAnotherResource() -{ - using var builder = TestDistributedApplicationBuilder.Create(); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); - var other = builder.AddExecutable("other", "go", "."); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(other.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Equal("context", exception.ParamName); - Assert.Contains("other", exception.Message); - Assert.Contains("app", exception.Message); -} - -[Fact] -public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration() -{ - using var builder = TestDistributedApplicationBuilder.Create(); - var producerCalled = false; - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => - { - producerCalled = true; - return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); - }, - "go"); - var expectedException = new InvalidOperationException("configuration failed"); - var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( - exception: expectedException); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - executionConfiguration: executionConfiguration); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Same(expectedException, exception); - Assert.False(producerCalled); -} - -[Fact] -public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsANullTask() -{ - using var builder = TestDistributedApplicationBuilder.Create(); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext _) => - (Task)null!, - "go"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Contains("returned a null task", exception.Message); - Assert.Contains("app", exception.Message); - Assert.Contains("go", exception.Message); -} - -[Fact] -public async Task CreateLaunchConfigurationWrapsAProducerExceptionWithResourceContext() -{ - using var builder = TestDistributedApplicationBuilder.Create(); - var producerException = new InvalidOperationException("producer failed"); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - (LaunchConfigurationCallbackContext _) => - Task.FromException(producerException), - "go"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Contains("app", exception.Message); - Assert.Contains("go", exception.Message); - Assert.Same(producerException, exception.InnerException); -} -``` - -Update the existing null-result test to use the final task shape: - -```csharp -var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext _) => - Task.FromResult(null!), - "go"); -var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); - -var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); -``` - -- [ ] **Step 4: Write the failing DCP context and lifecycle tests** - -Add `using System.Collections.Concurrent;` to `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs`. - -Add this non-project test next to the existing plain-executable debug tests: - -```csharp -[Fact] -public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration() -{ - var builder = DistributedApplication.CreateBuilder(); - var environmentCallbackCount = 0; - EnvironmentCallbackContext? environmentContext = null; - LaunchConfigurationCallbackContext? launchContext = null; - - var resource = new TestExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithEnvironment(context => - { - Interlocked.Increment(ref environmentCallbackCount); - environmentContext = context; - context.EnvironmentVariables["DEBUG_VALUE"] = "resolved"; - }) - .WithDebugSupport( - context => - { - launchContext = context; - var debugValue = context.ExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "DEBUG_VALUE") - .Value; - - return Task.FromResult(new TestExecutionConfigurationLaunchConfiguration - { - Mode = context.Mode, - DebugValue = debugValue - }); - }, - "test"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [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.Equal(1, environmentCallbackCount); - Assert.NotNull(environmentContext); - Assert.NotNull(launchContext); - Assert.Same(resource, launchContext.Resource); - Assert.Same(environmentContext.Resource, launchContext.Resource); - Assert.Same(environmentContext.ExecutionContext, launchContext.ExecutionContext); - Assert.Same(environmentContext.Logger, launchContext.Logger); - Assert.Equal(environmentContext.CancellationToken, launchContext.CancellationToken); - Assert.Equal(cts.Token, launchContext.CancellationToken); - - var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name); - Assert.Contains(executable.Spec.Env!, variable => variable is { Name: "DEBUG_VALUE", Value: "resolved" }); - Assert.True(executable.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigurations)); - var launchConfiguration = Assert.Single(launchConfigurations); - Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); - Assert.Equal("resolved", launchConfiguration.DebugValue); -} -``` - -Add this test launch configuration near the other private launch configuration types at the bottom of the file: - -```csharp -private sealed class TestExecutionConfigurationLaunchConfiguration() - : ExecutableLaunchConfiguration("test") -{ - [JsonPropertyName("debug_value")] - public string DebugValue { get; set; } = string.Empty; -} -``` - -Add a failure-ordering test beside it: - -```csharp -[Fact] -public async Task PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer() -{ - var builder = DistributedApplication.CreateBuilder(); - var producerCalled = false; - var resource = new TestExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithEnvironment( - (EnvironmentCallbackContext _) => - throw new InvalidOperationException("environment failed")) - .WithDebugSupport( - context => - { - producerCalled = true; - return Task.FromResult( - new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }) - }) - .Build(); - var failedResources = new ConcurrentQueue(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Enqueue(context.Resource); - return Task.CompletedTask; - }); - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor( - distributedAppModel, - kubernetesService: kubernetesService, - configuration: configuration, - events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.False(producerCalled); - Assert.Empty(kubernetesService.CreatedResources.OfType()); - Assert.Same(resource, Assert.Single(failedResources)); -} -``` - -Replace `ResourceRestarted_EnvironmentCallbacksApplied` with a restart regression that also records launch callback contexts: - -```csharp -[Fact] -public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext() -{ - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - - var callCount = 0; - var launchContexts = new ConcurrentQueue(); - var project = builder.AddProject("ServiceA") - .WithArgs(context => context.Args.Add("--test")) - .WithEnvironment(context => - { - var currentCall = Interlocked.Increment(ref callCount); - context.EnvironmentVariables["CALL_COUNT"] = currentCall.ToString(); - }) - .WithDebugSupport( - context => - { - launchContexts.Enqueue(context); - return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); - }, - KnownLaunchConfigurationTypes.Project); - var resource = project.Resource; - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] - }), - [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug - }) - .Build(); - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" }; - var events = new DcpExecutorEvents(); - var connectionStringAvailableCount = 0; - events.Subscribe(context => - { - if (ReferenceEquals(context.Resource, resource)) - { - Interlocked.Increment(ref connectionStringAvailableCount); - } - - return Task.CompletedTask; - }); - var appExecutor = CreateAppExecutor( - distributedAppModel, - kubernetesService: kubernetesService, - dcpOptions: dcpOptions, - events: events, - configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - var firstExecutable = Assert.Single(executables); - Assert.Contains(firstExecutable.Spec.Env!, variable => variable is { Name: "CALL_COUNT", Value: "1" }); - Assert.Single(firstExecutable.Spec.Args!, argument => argument == "--no-build"); - Assert.Single(firstExecutable.Spec.Args!, argument => argument == "--test"); - Assert.True(firstExecutable.TryGetAnnotationAsObjectList( - CustomResource.ResourceAppArgsAnnotation, - out var firstArgumentAnnotations)); - AssertEffectiveArgumentIndexesMatchSpecArgs(firstArgumentAnnotations, firstExecutable.Spec.Args); - Assert.Equal(1, connectionStringAvailableCount); - - var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); - await appExecutor.StopResourceAsync(reference, CancellationToken.None); - await appExecutor.StartResourceAsync(reference, CancellationToken.None); - - executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - Assert.Equal(2, executables.Count); - var secondExecutable = executables[1]; - Assert.Contains(secondExecutable.Spec.Env!, variable => variable is { Name: "CALL_COUNT", Value: "2" }); - Assert.Single(secondExecutable.Spec.Args!, argument => argument == "--no-build"); - Assert.Single(secondExecutable.Spec.Args!, argument => argument == "--test"); - Assert.True(secondExecutable.TryGetAnnotationAsObjectList( - CustomResource.ResourceAppArgsAnnotation, - out var secondArgumentAnnotations)); - AssertEffectiveArgumentIndexesMatchSpecArgs(secondArgumentAnnotations, secondExecutable.Spec.Args); - Assert.True(secondExecutable.TryGetProjectLaunchConfiguration(out var secondLaunchConfiguration)); - Assert.NotNull(secondLaunchConfiguration); - Assert.Equal(2, connectionStringAvailableCount); - - var contexts = launchContexts.ToArray(); - Assert.Equal(2, contexts.Length); - Assert.NotSame(contexts[0], contexts[1]); - Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); - Assert.Equal( - "1", - contexts[0].ExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "CALL_COUNT") - .Value); - Assert.Equal( - "2", - contexts[1].ExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "CALL_COUNT") - .Value); -} -``` - -Rename `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare` to `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate`, update its comment to say that all custom producers run from `CreateObjectAsync`, and change its callback to: - -```csharp -projectBuilder.WithDebugSupport( - async context => - { - await Task.Yield(); - return new ProjectLaunchConfiguration - { - ProjectPath = "AsyncProducerPath", - Mode = context.Mode, - LaunchProfile = "async-profile" - }; - }, - KnownLaunchConfigurationTypes.Project); -``` - -Update the companion comment on `PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` to reference the renamed `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` test and to say both producer types now share the creation path. - -- [ ] **Step 5: Update direct test invocation helpers to the intended context shape** - -In `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs`, replace the direct annotator invocation with: - -```csharp -var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - ExecutableLaunchMode.NoDebug); -await annotation.LaunchConfigurationAnnotator(exe, callbackContext); -``` - -Replace the Go helper in `tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs` with: - -```csharp -private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) -{ - Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); - - var exe = Executable.Create("test", "go"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); - await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); - - Assert.True(exe.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigs)); - - return Assert.Single(launchConfigs); -} -``` - -Replace the Python helper in `tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs` with: - -```csharp -private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) -{ - Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); - - var exe = Executable.Create("test", "python"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); - await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); - - Assert.True(exe.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigs)); - - return Assert.Single(launchConfigs); -} -``` - -Replace the Node helper in `tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs` with: - -```csharp -private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) -{ - Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); - - var exe = Executable.Create("test", "node"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); - await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); - - Assert.True(exe.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigs)); - - return Assert.Single(launchConfigs); -} -``` - -Replace the Bun helper in `tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs` with: - -```csharp -private static async Task InvokeLaunchConfigurationAnnotatorAsync(IResource resource) -{ - Assert.True(resource.TryGetLastAnnotation(out var supportsDebugging)); - - var exe = Executable.Create("test", "bun"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(resource); - await supportsDebugging.LaunchConfigurationAnnotator(exe, callbackContext); - - Assert.True(exe.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigs)); - - return Assert.Single(launchConfigs); -} -``` - -In `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs`, use the explicit helper: - -```csharp -var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - app.Resource, - ExecutableLaunchMode.Debug); -var launchConfig = Assert.IsType( - await app.Resource.CreateLaunchConfigurationAsync(callbackContext)); -``` - -Apply that replacement in both project launch configuration tests. - -In `tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs`, replace `DeserializeLaunchConfigurationAsync` with: - -```csharp -private static async Task DeserializeLaunchConfigurationAsync(IResource resource) -{ - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - resource, - ExecutableLaunchMode.Debug); - var json = JsonSerializer.Serialize(await resource.CreateLaunchConfigurationAsync(callbackContext)); - var launchConfiguration = JsonSerializer.Deserialize(json); - Assert.NotNull(launchConfiguration); - - return launchConfiguration; -} -``` - -- [ ] **Step 6: Run the focused tests to verify the new API is missing** - -Run: - -```bash -dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-method "*.PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -``` - -Expected: FAIL at compile time because `LaunchConfigurationCallbackContext` does not exist and the current producer/annotator signatures do not accept it. - -- [ ] **Step 7: Add the public callback context** - -Create `src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs`: - -```csharp -// 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; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aspire.Hosting.ApplicationModel; - -/// -/// Provides the runtime data used to create a launch configuration for a resource. -/// -/// -/// Aspire creates a new context for each executable creation, including restarts and replicas. -/// is the same resolved configuration used to populate the -/// underlying executable's arguments and environment variables. -/// -[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class LaunchConfigurationCallbackContext -{ - /// - /// Gets the requested launch mode, one of the values on . - /// - public required string Mode { get; init; } - - /// - /// Gets the resource being launched. - /// - public required IResource Resource { get; init; } - - /// - /// Gets the resolved execution configuration used for the executable. - /// - /// - /// Processed environment values can contain secrets. Aspire serializes only the launch configuration - /// returned by the producer; integrations should copy values from this result only when the IDE requires them. - /// - public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } - - /// - /// Gets the execution context for the current AppHost invocation. - /// - public required DistributedApplicationExecutionContext ExecutionContext { get; init; } - - /// - /// Gets the resource logger for this executable creation. - /// - public ILogger Logger { get; init; } = NullLogger.Instance; - - /// - /// Gets the cancellation token for this executable creation. - /// - public CancellationToken CancellationToken { get; init; } -} -``` - -- [ ] **Step 8: Change the annotation to store context-based delegates and diagnose producer failures** - -In `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs`, change the constructor and internal properties to: - -```csharp -private SupportsDebuggingAnnotation( - string launchConfigurationType, - Func launchConfigurationAnnotator, - Func> launchConfigurationProducer, - bool rewritesArgumentsForDebugging) -{ - LaunchConfigurationType = launchConfigurationType; - LaunchConfigurationAnnotator = launchConfigurationAnnotator; - LaunchConfigurationProducer = launchConfigurationProducer; - RewritesArgumentsForDebugging = rewritesArgumentsForDebugging; -} - -internal Func LaunchConfigurationAnnotator { get; } - -internal Func> LaunchConfigurationProducer { get; } -``` - -Replace `Create` with: - -```csharp -internal static SupportsDebuggingAnnotation Create( - string resourceName, - string launchConfigurationType, - Func> launchConfigurationProducer, - bool rewritesArgumentsForDebugging = false) -{ - return new SupportsDebuggingAnnotation( - launchConfigurationType, - async (exe, context) => - exe.AnnotateAsObjectList( - Executable.LaunchConfigurationsAnnotation, - await ProduceAsync(context).ConfigureAwait(false)), - async context => (await ProduceAsync(context).ConfigureAwait(false))!, - rewritesArgumentsForDebugging); - - async Task ProduceAsync(LaunchConfigurationCallbackContext context) - { - Task? producerTask; - try - { - producerTask = launchConfigurationProducer(context); - } - catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) - { - throw CreateProducerException(exception); - } - - if (producerTask is null) - { - throw new InvalidOperationException( - $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned a null task. " + - "The producer must return a task that produces the complete launch configuration."); - } - - T launchConfiguration; - try - { - launchConfiguration = await producerTask.ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) - { - throw CreateProducerException(exception); - } - - if (launchConfiguration is null) - { - throw new InvalidOperationException( - $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned null. " + - "The producer owns the complete launch configuration, so it must always return one."); - } - - return launchConfiguration; - } - - InvalidOperationException CreateProducerException(Exception innerException) - { - return new InvalidOperationException( - $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' failed.", - innerException); - } -} -``` - -- [ ] **Step 9: Add the context overload while retaining temporary migration adapters** - -In `src/Aspire.Hosting/ResourceBuilderExtensions.cs`, add the final overload and move the current registration body into it: - -```csharp -[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, - Action? argsCallback = null) - where T : IResource -{ - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(launchConfigurationProducer); - - if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) - { - return builder; - } - - var supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( - builder.Resource.Name, - launchConfigurationType, - launchConfigurationProducer, - rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder); - - if (argsCallback is not null && builder is IResourceBuilder resourceWithArgs) - { - resourceWithArgs.WithArgs(context => - { - if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) - && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) - { - argsCallback(context); - } - }); - } - - return builder.WithAnnotation(supportsDebuggingAnnotation); -} -``` - -Keep the two old overloads only until Task 3, but turn them into adapters: - -```csharp -return builder.WithDebugSupport( - context => Task.FromResult(launchConfigurationProducer(context.Mode)), - launchConfigurationType, - argsCallback); -``` - -```csharp -return builder.WithDebugSupport( - context => launchConfigurationProducer(context.Mode, context.CancellationToken), - launchConfigurationType, - argsCallback); -``` - -Retain the old sync-overload `Task`/`ValueTask` guard until Task 3 so unchanged callers keep their current diagnostic during the migration. - -- [ ] **Step 10: Make inspection consume an explicit context** - -Add `using System.Runtime.ExceptionServices;` to `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs`. - -Replace `CreateLaunchConfigurationAsync` with: - -```csharp -/// -/// Creates the launch configuration that this resource sends to the IDE using an explicitly resolved callback context. -/// -/// The resource to inspect. It must carry a . -/// The callback context containing the resolved execution configuration and launch data. -/// The launch configuration, typically an . -/// belongs to a different resource. -/// The resource does not declare debug launch support. -/// -/// This method never resolves arguments or environment variables. Callers that need a real execution -/// configuration must build it explicitly with and place it -/// on . -/// -[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, - 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 (context.ExecutionConfiguration.Exception is { } configurationException) - { - ExceptionDispatchInfo.Throw(configurationException); - } - - if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) - { - throw new InvalidOperationException( - $"Resource '{resource.Name}' does not declare debug launch support. " + - $"Call {nameof(ResourceBuilderExtensions.WithDebugSupport)} on the resource first. " + - "Note that it only adds the annotation in run mode."); - } - - return supportsDebuggingAnnotation.LaunchConfigurationProducer(context); -} -``` - -- [ ] **Step 11: Invoke every custom producer from `CreateObjectAsync`** - -In `PrepareProjectExecutablesAsync`, remove the custom producer call: - -```csharp -if (supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project) -{ - await ApplyProjectLaunchConfigurationAsync( - exe, - project, - projectMetadata, - supportsDebuggingAnnotation, - cancellationToken).ConfigureAwait(false); -} -``` - -Keep the prepare-time `CreateProjectLaunchConfiguration(...)` calls for: - -- `ProjectLaunchArgsOverrideAnnotation` -- project resources without an active custom producer -- the Visual Studio fallback path where no supported custom launch type is active - -Because producer invocation was the only asynchronous work in project preparation, make preparation synchronous while preserving the interface's task-returning method: - -```csharp -public Task>> PrepareObjectsAsync( - CancellationToken cancellationToken) -{ - PrepareProjectExecutables(cancellationToken); - PreparePlainExecutables(); - - return Task.FromResult( - _appResources.Get().OfType>()); -} -``` - -Change the project-preparation method signature from: - -```csharp -private async Task PrepareProjectExecutablesAsync(CancellationToken cancellationToken) -``` - -to: - -```csharp -private void PrepareProjectExecutables(CancellationToken cancellationToken) -``` - -Insert this as its first statement: - -```csharp -cancellationToken.ThrowIfCancellationRequested(); -``` - -Insert the same statement as the first statement inside its `foreach (var project in modelProjectResources)` loop. The only statements removed from that loop are the active custom-producer call shown above and the `await` on the built-in fallback call, which becomes the synchronous call shown below. - -Replace the debug-producer block in `CreateObjectAsync`, after the execution configuration error check, with: - -```csharp -if (!er.ModelResource.HasAnnotationOfType() - && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) -{ - var isProjectLaunchConfiguration = - supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; - - 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."); - } - - var mode = isProjectLaunchConfiguration - ? GetProjectLaunchConfigurationMode() - : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; - var callbackContext = new LaunchConfigurationCallbackContext - { - Mode = mode, - Resource = er.ModelResource, - ExecutionConfiguration = configuration, - ExecutionContext = _executionContext, - Logger = resourceLogger, - CancellationToken = cancellationToken - }; - - try - { - // Executable objects are reused for restarts. Clear the prior launch configuration before - // applying the freshly resolved producer result. - exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); - await supportsDebuggingAnnotation - .LaunchConfigurationAnnotator(exe, callbackContext) - .ConfigureAwait(false); - } - catch (Exception exception) when ( - !isProjectLaunchConfiguration - && !supportsDebuggingAnnotation.RewritesArgumentsForDebugging) - { - _logger.LogWarning( - exception, - "Failed to apply launch configuration for resource '{ResourceName}'. Falling back to process execution.", - er.ModelResource.Name); - exe.Spec.ExecutionType = ExecutionType.Process; - } -} -``` - -Delete the stale comments that say `project` producers run during preparation. The comment above the new block should read: - -```csharp -// Invoke the active launch configuration producer only after the resource execution configuration -// has been resolved. This gives every launch type, including "project", the exact arguments and -// environment used for this executable creation. -``` - -Replace the now-obsolete async helper with a built-in-only helper: - -```csharp -private void ApplyProjectLaunchConfiguration( - Executable exe, - IResource project, - IProjectMetadata projectMetadata) -{ - exe.SetProjectLaunchConfiguration( - ProjectLaunchConfigurationFactory.Create( - project, - projectMetadata, - GetProjectLaunchConfigurationMode())); -} -``` - -Update the Visual Studio/default fallback call in `PrepareProjectExecutablesAsync` to: - -```csharp -ApplyProjectLaunchConfiguration(exe, project, projectMetadata); -``` - -Do not call `ApplyProjectLaunchConfiguration` for an active `SupportsDebuggingAnnotation`; its producer now owns the complete result in `CreateObjectAsync`. - -Replace the remaining prepare-time producer comment with: - -```csharp -// The active custom launch configuration is applied later in CreateObjectAsync, after endpoints -// and the resource execution configuration have been resolved. -``` - -- [ ] **Step 12: Run the focused core and direct-producer tests** - -Run: - -```bash -dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-method "*.ProjectLaunchConfiguration_UsesProjectDebugSupportProducer_InDebugSession" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-method "*.AddDotnetProject_DebugAnnotator_ProducesProjectLaunchConfiguration" --filter-method "*.AddDotnetProject_LaunchConfiguration_ResolvesEffectiveLaunchProfile" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Go.Tests/Aspire.Hosting.Go.Tests.csproj --no-launch-profile -- --filter-method "*.WithVSCodeDebugging_PopulatesGoLaunchConfiguration" --filter-method "*.WithVSCodeDebugging_OmitsBuildFlagsWhenNoneConfigured" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj --no-launch-profile -- --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForScriptEntrypoint" --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForModuleEntrypoint" --filter-method "*.WithDebugSupport_PopulatesWorkingDirectory_ForExecutableEntrypoint" --filter-method "*.WithDebugSupport_PropagatesWorkingDirectoryOverride_ForExecutableEntrypoint" --filter-method "*.WithDebugSupport_PropagatesWorkingDirectoryOverride" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.JavaScript.Tests/Aspire.Hosting.JavaScript.Tests.csproj --no-launch-profile -- --filter-method "*.NodeApp_DirectFile_ProducesNodeRuntimeExecutable" --filter-method "*.ViteApp_DevServer_ProducesPackageManagerRuntimeExecutable" --filter-method "*.BunApp_DirectFile_ProducesBunRuntimeExecutable" --filter-method "*.BunApp_WithRunScriptAndPackageManager_ProducesBunRuntimeExecutable" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj --no-launch-profile -- --filter-method "*.AddMauiPlatform_EmitsMauiIdeLaunchConfiguration" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -``` - -Expected: all selected tests PASS. - -- [ ] **Step 13: Commit the foundational runtime change** - -```bash -git add \ - src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs \ - src/Aspire.Hosting/ResourceBuilderExtensions.cs \ - src/Aspire.Hosting/SupportsDebuggingAnnotation.cs \ - src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs \ - src/Aspire.Hosting/Dcp/ExecutableCreator.cs \ - tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs \ - tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs \ - tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs \ - tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs \ - tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs \ - tests/Aspire.Hosting.Go.Tests/AddGoAppTests.cs \ - tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs \ - tests/Aspire.Hosting.JavaScript.Tests/AddNodeAppTests.cs \ - tests/Aspire.Hosting.JavaScript.Tests/AddBunAppTests.cs \ - tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs -git commit -m "Add launch configuration callback context" \ - -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ - -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" -``` - -### Task 2: Migrate production launch configuration producers - -**Consumed by:** Tasks 3, 4 — the legacy overloads cannot be removed until every production caller uses the new contract - -**Files:** -- Modify: `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs:507-509` -- Modify: `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs:188-193` -- Modify: `src/Aspire.Hosting.Go/GoHostingExtensions.cs:699-747` -- Modify: `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs:941-1015` -- Modify: `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs:2155-2295` -- Modify: `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs:21-45` - -- [ ] **Step 1: Migrate the built-in project producer** - -In `src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs`, replace the current registration with: - -```csharp -builder.WithDebugSupport( - context => Task.FromResult( - ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), - KnownLaunchConfigurationTypes.Project); -``` - -Using `context.Resource` here ensures the producer consumes the same resource represented by the callback context rather than relying on a captured builder. - -- [ ] **Step 2: Migrate Azure Functions** - -In `src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs`, replace the producer with: - -```csharp -.WithDebugSupport( - context => Task.FromResult(new AzureFunctionsLaunchConfiguration - { - ProjectPath = projectMetadata.ProjectPath, - Mode = context.Mode - }), - "azure-functions"); -``` - -- [ ] **Step 3: Migrate Go** - -In `src/Aspire.Hosting.Go/GoHostingExtensions.cs`, replace the launch producer body with: - -```csharp -return builder.WithDebugSupport( - context => - { - // Resolve annotations when DCP creates the launch configuration so later - // resource mutations such as WithWorkingDirectory(...) are reflected. - var workingDirectory = Path.GetFullPath(resource.WorkingDirectory); - var packagePath = resource.TryGetLastAnnotation(out var packagePathAnnotation) - ? packagePathAnnotation.PackagePath - : "."; - var buildFlags = BuildFlagsString(resource); - - return Task.FromResult(new GoLaunchConfiguration - { - Program = Path.GetFullPath(packagePath, workingDirectory), - Mode = context.Mode, - WorkingDirectory = workingDirectory, - BuildFlags = buildFlags.Length > 0 ? buildFlags : null - }); - }, - "go", - static context => - { - if (context.Args is not [string runCommand, ..] || runCommand != "run") - { - return; - } - - context.Args.RemoveAt(0); - - while (context.Args is [string arg, ..] && IsGoRunBuildFlag(arg)) - { - context.Args.RemoveAt(0); - } - - if (context.Args.Count > 0) - { - context.Args.RemoveAt(0); - } - }); -``` - -Keep the existing raw command-shape comment above the argument rewrite. - -- [ ] **Step 4: Migrate Python** - -In `src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs`, keep the existing path/interpreter logic and change only the callback shape and return: - -```csharp -builder.WithDebugSupport( - context => - { - var workingDirectory = builder.Resource.WorkingDirectory; - - string programPath; - string module; - - if (entrypointType == EntrypointType.Script) - { - programPath = Path.GetFullPath(entrypoint, workingDirectory); - module = string.Empty; - } - else - { - programPath = workingDirectory; - module = entrypoint; - } - - string interpreterPath; - if (!builder.Resource.TryGetLastAnnotation(out var annotation) - || annotation.VirtualEnvironment is null) - { - interpreterPath = string.Empty; - } - else - { - var venvPath = Path.IsPathRooted(annotation.VirtualEnvironment.VirtualEnvironmentPath) - ? annotation.VirtualEnvironment.VirtualEnvironmentPath - : Path.GetFullPath(annotation.VirtualEnvironment.VirtualEnvironmentPath, workingDirectory); - - interpreterPath = OperatingSystem.IsWindows() - ? Path.Join(venvPath, "Scripts", "python.exe") - : Path.Join(venvPath, "bin", "python"); - } - - return Task.FromResult(new PythonLaunchConfiguration - { - ProgramPath = programPath, - Module = module, - Mode = context.Mode, - InterpreterPath = interpreterPath, - WorkingDirectory = workingDirectory - }); - }, - "python", - static argsContext => - { - // Remove entrypoint-specific arguments that VS Code will handle. - // We need to verify the annotation to ensure we remove the correct args. - if (!argsContext.Resource.TryGetLastAnnotation(out var annotation)) - { - return; - } - - // For Module type: remove "-m" and module name (2 args) - if (annotation.Type == EntrypointType.Module) - { - if (argsContext.Args is [string arg0, string arg1, ..] - && arg0 == "-m" - && arg1 == annotation.Entrypoint) - { - argsContext.Args.RemoveAt(0); - argsContext.Args.RemoveAt(0); - } - } - // For Script type: remove script path (1 arg) - else if (annotation.Type == EntrypointType.Script) - { - if (argsContext.Args is [string arg0, ..] - && arg0 == annotation.Entrypoint) - { - argsContext.Args.RemoveAt(0); - } - } - }); -``` - -- [ ] **Step 5: Migrate JavaScript and browser debugging** - -For the script-path overload in `src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs`, use: - -```csharp -return builder.WithDebugSupport( - context => - { - var hasRunScript = resource.TryGetLastAnnotation(out _); - var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation); - var isPackageManagerScript = hasRunScript && hasPackageManager; - - return Task.FromResult(new JavaScriptLaunchConfiguration(launchConfigType) - { - ScriptPath = Path.GetFullPath(scriptPath, workingDirectory), - Mode = context.Mode, - RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType, - LaunchMethod = isPackageManagerScript - ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager - : JavaScriptLaunchConfiguration.LaunchMethodDirect, - WorkingDirectory = workingDirectory - }); - }, - launchConfigType); -``` - -For the package-manager overload, use: - -```csharp -return builder.WithDebugSupport( - context => - { - var packageManager = "npm"; - if (resource.TryGetLastAnnotation(out var pmAnnotation)) - { - packageManager = pmAnnotation.ExecutableName; - } - - return Task.FromResult(new JavaScriptLaunchConfiguration("node") - { - ScriptPath = string.Empty, - Mode = context.Mode, - RuntimeExecutable = packageManager, - LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager, - WorkingDirectory = workingDirectory - }); - }, - "node"); -``` - -For browser debugging, use: - -```csharp -.WithDebugSupport( - context => - { - EndpointAnnotation? endpointAnnotation = null; - if (parentResource.TryGetAnnotationsOfType(out var endpoints)) - { - endpointAnnotation = endpoints.FirstOrDefault(endpoint => endpoint.UriScheme == "https") - ?? endpoints.FirstOrDefault(endpoint => endpoint.UriScheme == "http"); - } - - if (endpointAnnotation is null) - { - throw new InvalidOperationException( - $"Resource '{parentResource.Name}' does not have an HTTP or HTTPS endpoint. Browser debugging requires an endpoint to navigate to."); - } - - var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); - - return Task.FromResult(new BrowserLaunchConfiguration - { - Mode = context.Mode, - Url = endpointReference.Url, - WebRoot = parentResource.WorkingDirectory, - Browser = browser - }); - }, - BrowserCapability); -``` - -- [ ] **Step 6: Migrate MAUI** - -In `src/Aspire.Hosting.Maui/MauiPlatformHelper.cs`, replace the producer with: - -```csharp -return resourceBuilder.WithDebugSupport( - context => Task.FromResult(new MauiLaunchConfiguration - { - Mode = context.Mode, - ProjectPath = projectPath, - TargetFramework = targetFramework, - Platform = platform, - TargetKind = targetKind, - Device = device, - RuntimeIdentifier = runtimeIdentifier, - MsBuildProperties = msBuildProperties - }), - MauiLaunchConfigurationType); -``` - -Do not change MAUI's separate environment evaluation inside its command-line argument callback; that occurs before a launch callback context exists and is explicitly outside this issue. - -- [ ] **Step 7: Build every migrated production project** - -Run: - -```bash -dotnet build src/Aspire.Hosting/Aspire.Hosting.csproj --no-restore -dotnet build src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj --no-restore -dotnet build src/Aspire.Hosting.Go/Aspire.Hosting.Go.csproj --no-restore -dotnet build src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj --no-restore -dotnet build src/Aspire.Hosting.JavaScript/Aspire.Hosting.JavaScript.csproj --no-restore -dotnet build src/Aspire.Hosting.Maui/Aspire.Hosting.Maui.csproj --no-restore -``` - -Expected: all builds PASS with `0` warnings introduced by this change. - -- [ ] **Step 8: Commit the production migrations** - -```bash -git add \ - src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs \ - src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs \ - src/Aspire.Hosting.Go/GoHostingExtensions.cs \ - src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs \ - src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs \ - src/Aspire.Hosting.Maui/MauiPlatformHelper.cs -git commit -m "Migrate debug launch configuration producers" \ - -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ - -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" -``` - -### Task 3: Remove legacy overloads and migrate all tests - -**Consumed by:** Task 4 — final validation assumes only the new API remains - -**Files:** -- Modify: `src/Aspire.Hosting/ResourceBuilderExtensions.cs:4750-4850` -- Modify: `src/Aspire.Hosting/SupportsDebuggingAnnotation.cs:10-25` -- Modify: `src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs:75-125` -- Modify: `src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs:45-105` -- Modify: `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` -- Modify: `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` -- Modify: `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` -- Modify: `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs:308-360` - -- [ ] **Step 1: Delete both legacy producer overloads** - -Delete these signatures and their implementations from `ResourceBuilderExtensions.cs`: - -```csharp -Func -``` - -```csharp -Func> -``` - -This also deletes the `Task`/`ValueTask` runtime guard; overload resolution can no longer infer `TLaunchConfiguration` as a task because there is only one task-returning producer shape. - -Keep the method named `WithDebugSupport`. Do not add `WithDebugSupportAsync`: registration returns the builder synchronously, and only the deferred producer is asynchronous. - -Keep one final overload with this XML documentation: - -```csharp -/// -/// 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. -/// -/// A callback that receives the resolved execution configuration and runtime launch context, and asynchronously -/// produces the complete launch configuration handed to the IDE. -/// -/// The type tag of the launch configuration sent to the IDE. -/// Optional callback to add or modify command-line arguments while this debug support annotation is active. -/// The . -/// -/// Registering debug support is synchronous; Aspire invokes -/// later for each executable creation, restart, or replica. A producer that completes synchronously should -/// return its result with . -/// -[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, - Action? argsCallback = null) - where T : IResource -``` - -- [ ] **Step 2: Update all API documentation references** - -In `SupportsDebuggingAnnotation.cs`, replace the old overload references with: - -```csharp -/// Added by . -``` - -In `DebugSupportExtensions.cs`, describe the explicit context helper and reference the same final overload. Remove all wording about "its asynchronous overload." - -In `ExecutableLaunchConfiguration.cs`, replace both old `WithDebugSupport` cref values with: - -```csharp - -``` - -Update the `Mode` remarks to say that the requested mode is available through `LaunchConfigurationCallbackContext.Mode`. - -- [ ] **Step 3: Migrate every core test producer** - -Apply these exact callback transformations in: - -- `tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs` -- `tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs` -- `tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs` -- `tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs` - -Mode-dependent synchronous producer: - -```csharp -// Before -mode => new ExecutableLaunchConfiguration("test") { Mode = mode } - -// After -context => Task.FromResult( - new ExecutableLaunchConfiguration("test") { Mode = context.Mode }) -``` - -Producer that ignores the context: - -```csharp -// Before -_ => new ExecutableLaunchConfiguration("test") - -// After -static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")) -``` - -Genuinely asynchronous producer: - -```csharp -// Before -async (mode, cancellationToken) => -{ - await Task.Yield(); - return new ExecutableLaunchConfiguration("test") { Mode = mode }; -} - -// After -async context => -{ - await Task.Yield(); - return new ExecutableLaunchConfiguration("test") { Mode = context.Mode }; -} -``` - -Where an existing producer reads its `cancellationToken` parameter, replace that read with `context.CancellationToken`; do not add a new cancellation check to producers that did not previously perform one. - -Custom project producer: - -```csharp -context => Task.FromResult(new ProjectLaunchConfiguration -{ - Mode = context.Mode, - ProjectPath = "ProducerSuppliedPath", - DisableLaunchProfile = true -}) -``` - -Argument-rewriting registrations retain the existing `argsCallback` unchanged: - -```csharp -.WithDebugSupport( - context => Task.FromResult( - new ExecutableLaunchConfiguration("custom") { Mode = context.Mode }), - "custom", - context => context.Args.Add("rewritten-arg")) -``` - -Update the two method-group callbacks in `DcpExecutorTests.cs` to: - -```csharp -static Task CreateProjectLaunchConfiguration( - LaunchConfigurationCallbackContext context) -{ - throw new InvalidOperationException("Project launch configuration failed."); -} -``` - -```csharp -static Task ThrowingLaunchConfiguration( - LaunchConfigurationCallbackContext context) -{ - throw new InvalidOperationException("Launch configuration failed."); -} -``` - -Delete these obsolete tests from `ExecutableResourceBuilderExtensionTests.cs`: - -- `WithDebugSupportAsynchronousProducerProducesTheSameAnnotationAsTheSynchronousOne` -- `WithDebugSupportRejectsATaskReturningSynchronousProducer` -- `WithDebugSupportRejectsAValueTaskReturningSynchronousProducer` - -The replacement coverage is: - -- `CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks` -- `ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate` -- the null-task and null-result diagnostics - -- [ ] **Step 4: Update failure assertions for the resource-specific wrapper** - -Where DCP tests currently assert only the raw producer text, keep that assertion against the logged exception chain and also assert the resource-specific outer diagnostic: - -```csharp -Assert.Contains( - logLines, - line => line.Content.Contains( - "The \"project\" launch configuration producer for resource 'TestDotnetProject' failed.", - StringComparison.Ordinal)); -Assert.Contains( - logLines, - line => line.Content.Contains( - "Project launch configuration failed.", - StringComparison.Ordinal)); -``` - -For non-project fallback tests, continue asserting that: - -- non-rewriting producers fall back to `ExecutionType.Process`; -- argument-rewriting producers fail rather than offering an invalid process fallback; -- project producers fail without a process fallback. - -- [ ] **Step 5: Audit that no legacy producer shape remains** - -Run: - -```bash -rg -n -U '\.WithDebugSupport\(\s*(?:async\s*)?\([^)]*,[^)]*\)\s*=>' src tests --glob '*.cs' -rg -n -U '\.WithDebugSupport\(\s*[A-Za-z_][A-Za-z0-9_]*\s*=>\s*new ' src tests --glob '*.cs' -rg -n 'Func|Func>' src/Aspire.Hosting --glob '*.cs' --glob '!api/*.cs' -``` - -Expected: no matches. Matches in generated `src/*/api/*.cs` files are ignored and must not be edited. - -- [ ] **Step 6: Run the core regression tests** - -Run: - -```bash -dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_*Debug*" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_*LaunchConfiguration*" --filter-method "*.ProjectLaunchConfiguration_*" --filter-method "*.DotnetProjectExecutable_*" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-method "*.AddDotnetProject_*Debug*" --filter-method "*.AddDotnetProject_LaunchConfiguration_ResolvesEffectiveLaunchProfile" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -``` - -Expected: all selected tests PASS. - -- [ ] **Step 7: Commit the final API shape and test migration** - -```bash -git add \ - src/Aspire.Hosting/ResourceBuilderExtensions.cs \ - src/Aspire.Hosting/SupportsDebuggingAnnotation.cs \ - src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs \ - src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs \ - tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs \ - tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs \ - tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs \ - tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs -git commit -m "Finalize debug callback context API" \ - -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" \ - -m "Copilot-Session: 0f29216a-bf33-40a3-9f37-3863b3d1a79e" -``` - -### Task 4: Validate the complete change - -**Consumed by:** nothing - -**Files:** -- Verify only; no source files should change - -- [ ] **Step 1: Build the repository without native AOT** - -Run: - -```bash -./build.sh --build /p:SkipNativeBuild=true -``` - -Expected: build PASS with no new warnings. - -- [ ] **Step 2: Run the affected test projects** - -Run: - -```bash -dotnet test --project tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj --no-launch-profile -- --filter-class "*.DebugSupportExtensionsTests" --filter-class "*.ExecutableResourceBuilderExtensionTests" --filter-method "*.PlainExecutable_*Debug*" --filter-method "*.PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer" --filter-method "*.ProjectExecutable_*LaunchConfiguration*" --filter-method "*.ProjectLaunchConfiguration_*" --filter-method "*.DotnetProjectExecutable_*" --filter-method "*.ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Go.Tests/Aspire.Hosting.Go.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.JavaScript.Tests/Aspire.Hosting.JavaScript.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -dotnet test --project tests/Aspire.Hosting.Maui.Tests/Aspire.Hosting.Maui.Tests.csproj --no-launch-profile -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" -``` - -Expected: all projects PASS. - -- [ ] **Step 3: Verify the diff and generated API boundary** - -Run: - -```bash -git diff --check -git diff --name-only HEAD~3..HEAD -- 'src/*/api/*.cs' -rg -n -U '\.WithDebugSupport\(\s*(?:async\s*)?\([^)]*,[^)]*\)\s*=>' src tests --glob '*.cs' -rg -n 'Func|Func>' src/Aspire.Hosting --glob '*.cs' --glob '!api/*.cs' -git status --short -``` - -Expected: - -- `git diff --check` prints nothing. -- No generated API file is listed. -- Both legacy-shape searches print nothing. -- `git status --short` is clean. - -## Rust PR #18906 follow-up - -After this framework change is available on the Rust PR branch, replace its launch producer with: - -```csharp -builder.WithDebugSupport( - async context => - { - var cargoArgs = builder.Resource.ResolvedCargoArgs - ?? throw new InvalidOperationException( - $"Cargo arguments for resource '{builder.Resource.Name}' have not been resolved."); - var environment = context.ExecutionConfiguration.EnvironmentVariables - .ToDictionary(StringComparer.Ordinal); - - var executablePath = await ResolveDebugExecutablePathAsync( - builder.Resource, - workingDirectory, - context.ExecutionContext, - environment, - context.CancellationToken).ConfigureAwait(false); - - return new RustLaunchConfiguration - { - Mode = context.Mode, - WorkingDirectory = workingDirectory, - Cargo = new RustCargoLaunchTarget - { - Args = ["build", .. cargoArgs], - ExecutablePath = executablePath - } - }; - }, - "rust", - argsCallback); -``` - -Delete Rust's second environment `ExecutionConfigurationBuilder` pass. Keep `ResolvedCargoArgs` until issue #18929 changes how process and IDE arguments are composed. diff --git a/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md b/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md deleted file mode 100644 index 503736f3a72..00000000000 --- a/docs/superpowers/specs/2026-08-05-launch-configuration-callback-context-design.md +++ /dev/null @@ -1,228 +0,0 @@ -# Launch Configuration Callback Context Design - -Issue: [#18956](https://github.com/microsoft/aspire/issues/18956) - -Related work: - -- [#18918](https://github.com/microsoft/aspire/pull/18918) made the debug-support seam public and added asynchronous launch configuration producers. -- [#18929](https://github.com/microsoft/aspire/issues/18929) tracks the separate argument-rewriting design problem. -- [#18906](https://github.com/microsoft/aspire/pull/18906) is the immediate Rust consumer. - -## Problem - -`WithDebugSupport` currently gives a launch configuration producer only the launch mode and, for the asynchronous overload, a cancellation token: - -```csharp -Func -Func> -``` - -By the time most producers run, `ExecutableCreator.CreateObjectAsync` has already built an `IExecutionConfigurationResult` and copied its resolved arguments and environment variables into the DCP executable spec. The producer cannot access that result. - -An integration that needs the resource environment must build another execution configuration. That runs `WithEnvironment` callbacks again and can produce a different result from the one Aspire actually gives the process. Rust needs the environment to resolve `CARGO_TARGET_DIR` and `CARGO_BUILD_TARGET`, so the duplicate pass can point the debugger at a binary that the real cargo invocation will not produce. - -The callback signature also has no room for the other standard runtime callback values Aspire already exposes elsewhere: the resource, application execution context, logger, and cancellation token. - -## Goals - -- Give the producer the exact `IExecutionConfigurationResult` that Aspire used for the DCP executable. -- Use the same callback context for every custom launch configuration type, including `project`. -- Follow the standard Aspire callback shape with resource, execution context, logger, and cancellation. -- Keep `WithDebugSupport` as a synchronous builder operation while allowing asynchronous producers. -- Avoid hidden configuration evaluation in the public inspection helper. -- Preserve existing launch type selection, fallback, restart, and error behavior. - -## Non-goals - -- Fix the order-sensitive `argsCallback` or split process arguments from IDE arguments. That remains [#18929](https://github.com/microsoft/aspire/issues/18929). -- Change the DCP run-session protocol. -- Remove Rust's resolved cargo-argument snapshot. The current debug argument callback has already removed `cargo run ... --` from the final execution arguments before the launch producer runs. -- Remove MAUI's environment re-resolution. MAUI resolves the environment from a command-line argument callback while the execution configuration is still being gathered, before a launch producer context exists. -- Export `WithDebugSupport` or its context to polyglot AppHosts. - -## Public API - -Add an experimental callback context under `Aspire.Hosting.ApplicationModel`: - -```csharp -[Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] -public sealed class LaunchConfigurationCallbackContext -{ - public required string Mode { get; init; } - - public required IResource Resource { get; init; } - - public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } - - public required DistributedApplicationExecutionContext ExecutionContext { get; init; } - - public ILogger Logger { get; init; } = NullLogger.Instance; - - public CancellationToken CancellationToken { get; init; } -} -``` - -`ExecutionConfiguration` exposes the full result rather than copying only arguments and environment variables. The result already models processed and unprocessed values, argument sensitivity, references, and additional gatherer data. Reusing it avoids a second DTO and lets future integrations consume other execution metadata without another callback signature change. - -Replace the two current producer overloads with one asynchronous producer: - -```csharp -[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, - Action? argsCallback = null) - where T : IResource; -``` - -There is no synchronous producer overload. A producer that does no asynchronous work returns `Task.FromResult(...)`. - -The method remains named `WithDebugSupport`, not `WithDebugSupportAsync`. Calling it only registers a callback and returns an `IResourceBuilder` synchronously. This matches `WithEnvironment`, `WithArgs`, `WithUrls`, and other Aspire builder APIs that accept callbacks returning `Task`. - -The existing `argsCallback` remains unchanged for this issue. - -## Runtime flow - -`ExecutableCreator` becomes the single place where every custom launch configuration producer runs: - -1. Prepare the DCP executable shape, execution type, fallback types, project arguments, and initial annotations. -2. Allocate endpoints. -3. Build the resource execution configuration once. -4. Populate the executable arguments and environment from that result. -5. Fail before the producer if `IExecutionConfigurationResult.Exception` is not `null`. -6. Create a fresh `LaunchConfigurationCallbackContext` with: - - the selected launch mode; - - the app model resource; - - the same execution configuration object used for the executable spec; - - the current application execution context; - - the resource logger; - - the current creation or restart cancellation token. -7. Invoke the producer and annotate the DCP executable with its returned launch configuration. - -The context is created per executable creation, restart, and replica. Aspire does not cache it or the execution configuration on the resource or annotation. - -### Project launch configurations - -Custom `project` launch configuration producers currently run from `PrepareProjectExecutablesAsync`, before the execution configuration exists. Move those producer invocations into `CreateObjectAsync` with the other custom launch types. - -Prepare-time code continues to decide whether the resource uses IDE execution and whether process fallback is available. The built-in project launch configuration used when no custom producer is active can remain prepare-time data. - -This move does not remove data needed by dashboard snapshots. `ResourceSnapshotBuilder` now derives project path and launch profile directly from the app model rather than reading the launch configuration annotation. - -### Restart and failure behavior - -Restarts rebuild the execution configuration and create a new callback context. Existing launch configuration annotations are cleared before the new result is applied. - -Configuration resolution errors continue to fail before producer invocation. A `null` task, a `null` launch configuration result, or a producer exception should produce a resource-specific diagnostic. Existing project and process-fallback behavior remains unchanged. - -## Inspection helper - -`DebugSupportExtensions.CreateLaunchConfigurationAsync` must not resolve configuration internally. Change it to accept an explicit callback context: - -```csharp -public static Task CreateLaunchConfigurationAsync( - this IResource resource, - LaunchConfigurationCallbackContext context); -``` - -The helper validates that `context.Resource` is the resource being inspected and that the supplied execution configuration succeeded. It then invokes the registered producer with that context. - -This keeps the helper useful for integration tests while making evaluation explicit. A caller that wants a real execution configuration can build one with `ExecutionConfigurationBuilder`; the helper never runs resource callbacks behind the caller's back. - -Only the producer's returned launch configuration is serialized to DCP. The callback context and execution configuration are not serialized automatically. Processed environment values can contain secrets, so integrations should only copy values into a launch configuration when the IDE requires them. - -## Existing caller migration - -Most in-tree callers only replace `mode` with `context.Mode` and wrap the result: - -```csharp -builder.WithDebugSupport( - context => Task.FromResult( - ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), - KnownLaunchConfigurationTypes.Project); -``` - -Go, Python, JavaScript, Azure Functions, and MAUI can keep their typed resource or metadata closures. Their only required behavior change is returning a task and reading the launch mode from the context. - -Rust uses the additional runtime data: - -```csharp -builder.WithDebugSupport( - async context => - { - var cargoArgs = builder.Resource.ResolvedCargoArgs - ?? throw new InvalidOperationException( - $"Cargo arguments for resource '{builder.Resource.Name}' have not been resolved."); - var environment = context.ExecutionConfiguration.EnvironmentVariables - .ToDictionary(StringComparer.Ordinal); - - var executablePath = await ResolveDebugExecutablePathAsync( - builder.Resource, - workingDirectory, - context.ExecutionContext, - environment, - context.CancellationToken).ConfigureAwait(false); - - return new RustLaunchConfiguration - { - Mode = context.Mode, - WorkingDirectory = workingDirectory, - Cargo = new RustCargoLaunchTarget - { - Args = ["build", .. cargoArgs], - ExecutablePath = executablePath - } - }; - }, - "rust", - argsCallback); -``` - -This removes Rust's second environment-resolution pass. The cargo argument snapshot remains until #18929 changes when and how IDE-specific arguments are composed. - -## Testing - -Add focused coverage for: - -- a non-project executable producer receiving the same `IExecutionConfigurationResult` instance used to populate the DCP executable; -- a custom project producer receiving its context after execution configuration resolution; -- environment callbacks running once per executable creation when the launch producer reads the resolved environment; -- `Resource`, `ExecutionContext`, `Logger`, and `CancellationToken` propagation; -- restart creating a fresh context and configuration instead of reusing cached data; -- the inspection helper invoking the producer with the supplied context without evaluating resource callbacks; -- the inspection helper rejecting a context for a different resource or a failed execution configuration; -- clear failures for a producer that returns a `null` task or `null` launch configuration; -- existing launch type, fallback, and argument-rewrite behavior remaining unchanged. - -Update all current `WithDebugSupport` tests and integration call sites to the task-returning callback shape. The generated `api/*.cs` files are not edited manually. - -## Alternatives considered - -### Expose only arguments and environment variables - -Rejected. It creates another projection over `IExecutionConfigurationResult` and would require more callback properties if a producer later needs references or additional gatherer data. - -### Cache the last resolved configuration - -Rejected. Restarts, retries, replicas, and failed resolutions make cache invalidation part of the public behavior. A stale result is worse than the current duplicate evaluation because it can silently describe a previous launch. - -### Put a lazy configuration resolver on the context - -Rejected. It can still execute resource callbacks twice and does not guarantee that the producer sees the same object used for the DCP executable. - -### Keep separate synchronous and asynchronous producer overloads - -Rejected. With a single context parameter, an async lambda can also bind to the unconstrained synchronous generic overload with `TLaunchConfiguration` inferred as `Task`. The current API needs a second cancellation-token parameter and a runtime guard to avoid serializing the task itself. One task-returning producer removes that trap. - -### Rename the method to `WithDebugSupportAsync` - -Rejected. Registration is synchronous; only the deferred callback is asynchronous. - -## Success criteria - -- Launch configuration producers can consume the exact resolved execution configuration without another build pass. -- Rust no longer evaluates resource environment callbacks a second time to locate its debug executable. -- Every custom producer runs after configuration resolution through one lifecycle. -- Existing debug launch and fallback behavior remains green across hosting core and language integration tests. diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 8af23663375..84e8b71e02b 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -85,12 +85,16 @@ internal static SupportsDebuggingAnnotation Create( Func> launchConfigurationProducer, bool rewritesArgumentsForDebugging = false) { + // 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, 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 context => (await ProduceAsync(context).ConfigureAwait(false))!, rewritesArgumentsForDebugging); From 900a33147db8052fcaef45cb28d1ca24cfca3c94 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 16:01:15 -0400 Subject: [PATCH 12/30] Fix MAUI launch configuration regression and rename PrepareObjects The !HasProjectLaunchArgsOverride guard in CreateObjectAsync conflated two different things: "this resource pinned its own execution type" and "this resource has no launch configuration producer". MAUI platform resources are ProjectResources carrying both a ProjectLaunchArgsOverrideAnnotation and a "maui" SupportsDebuggingAnnotation, so the guard silently suppressed the MAUI producer and left them with the generic "project" launch configuration. Split the predicate: the launch args override only suppresses the reset to ExecutionType.IDE and the "project" producer (which PrepareProjectExecutables already wrote for those resources). Every other launch type still runs its producer. Also rename PrepareObjectsAsync to PrepareObjects, matching ContainerCreator.PrepareObjects. It does no asynchronous work, is not an IObjectCreator member, and its single call site already handles synchronous throws identically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 2 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 37 +++++--- .../Dcp/DcpExecutorTests.cs | 89 ++++++++++++++++++- 3 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index a5f48970a99..c6e644405b1 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 58e441f35ec..96f1c4f069b 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -55,12 +55,11 @@ public ExecutableCreator( _appResources = appResources; } - public Task>> PrepareObjectsAsync(CancellationToken cancellationToken) + public IEnumerable> PrepareObjects(CancellationToken cancellationToken) { PrepareProjectExecutables(cancellationToken); PreparePlainExecutables(); - return Task.FromResult>>( - _appResources.Get().OfType>()); + return _appResources.Get().OfType>(); } public bool IsReadyToCreate(RenderedModelResource resource, EmptyCreationContext context) @@ -91,17 +90,29 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC spec.Args.AddRange(projectArgs); } + // PrepareProjectExecutables() takes a dedicated branch for project resources that carry a launch + // args override: it pins the executable to Process execution and writes the static "project" + // launch configuration itself. Both of those decisions have to be preserved here. + // + // This is NOT the same as "the resource has no launch configuration". MAUI platform resources are + // ProjectResources that carry both a ProjectLaunchArgsOverrideAnnotation and a "maui" + // SupportsDebuggingAnnotation, and their producer must still run below so the IDE receives the MAUI + // launch configuration rather than the generic "project" one. + var preparedFromLaunchArgsOverride = er.ModelResource is ProjectResource && HasProjectLaunchArgsOverride(er.ModelResource); + SupportsDebuggingAnnotation? supportsDebuggingAnnotation = null; - if (!HasProjectLaunchArgsOverride(er.ModelResource) - && !er.ModelResource.HasAnnotationOfType() + if (!er.ModelResource.HasAnnotationOfType() && er.ModelResource.SupportsDebugging(_configuration, out var activeDebuggingAnnotation)) { supportsDebuggingAnnotation = activeDebuggingAnnotation; - // Executable objects are reused for restarts, and a prior producer failure may have changed - // the execution type to Process. Reset it before building arguments because launch-profile - // arguments are executable in Process mode but display-only in IDE mode. - spec.ExecutionType = ExecutionType.IDE; + if (!preparedFromLaunchArgsOverride) + { + // Executable objects are reused for restarts, and a prior producer failure may have changed + // the execution type to Process. Reset it before building arguments because launch-profile + // arguments are executable in Process mode but display-only in IDE mode. + spec.ExecutionType = ExecutionType.IDE; + } } var (configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, cancellationToken).ConfigureAwait(false); @@ -175,7 +186,13 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC // Invoke the active launch configuration producer only after the resource execution configuration // has been resolved. This gives every launch type, including "project", the exact arguments and // environment used for this executable creation. - if (supportsDebuggingAnnotation is not null) + // + // The single exception is the "project" type on a launch-args-override project resource: + // PrepareProjectExecutables() already wrote the launch configuration matching the overridden command + // line, so re-running that producer would describe a launch that never happens. Producers for every + // other launch type still run here, because nothing else supplies their configuration. + if (supportsDebuggingAnnotation is not null + && !(preparedFromLaunchArgsOverride && supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)) { var isProjectLaunchConfiguration = supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index ab77489fba3..fb26302e54e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -5354,6 +5354,93 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); } + [Fact] + public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_StillAppliesMauiLaunchConfiguration() + { + // MAUI platform resources are ProjectResources that carry BOTH a ProjectLaunchArgsOverrideAnnotation + // (MauiPlatformHelper.ConfigurePlatformResource) and a "maui" SupportsDebuggingAnnotation + // (MauiPlatformHelper.WithMauiIdeLaunchConfiguration). The override pins the executable to Process + // execution, but the "maui" producer must still run so the IDE receives the MAUI launch configuration + // instead of the generic "project" one written by PrepareProjectExecutables. + var builder = DistributedApplication.CreateBuilder(); + + var projectBuilder = builder.AddProject("proj", launchProfileName: null); + var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (annotationToRemove is not null) + { + projectBuilder.Resource.Annotations.Remove(annotationToRemove); + } + +#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 + + projectBuilder + .WithDebugSupport( + context => Task.FromResult(new TestMauiLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = "/mauiapp/MauiApp.csproj", + TargetFramework = "net10.0-android", + Platform = "android", + TargetKind = "emulator", + MsBuildProperties = new Dictionary + { + ["AdbTarget"] = "-e" + } + }), + "maui") + .WithArgs("run", "-f", "net10.0-android"); + + var runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }; + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; + var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions); + + await appExecutor.RunApplicationAsync(); + + var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); + + // The launch args override still owns execution: the resource runs 'dotnet build /t:Run' as a process. + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + + 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, exe.Spec.Args); + + Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs)); + var launchConfig = Assert.Single(launchConfigs); + Assert.Equal("maui", launchConfig.Type); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode); + Assert.Equal("/mauiapp/MauiApp.csproj", launchConfig.ProjectPath); + Assert.Equal("net10.0-android", launchConfig.TargetFramework); + Assert.Equal("android", launchConfig.Platform); + Assert.Equal("emulator", launchConfig.TargetKind); + Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); + } + [Fact] public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchProfileArgsStayAfterDotnetRunArgs() { @@ -5882,7 +5969,7 @@ public async Task PlainExecutable_LaunchConfigurationProducerCancellation_DoesNo Assert.NotNull(executableCreator); var renderedExecutable = Assert.Single( - await executableCreator.PrepareObjectsAsync(CancellationToken.None)); + executableCreator.PrepareObjects(CancellationToken.None)); var objectFactory = new RecordingDcpObjectFactory(); await Assert.ThrowsAnyAsync( () => executableCreator.CreateObjectAsync( From 7d4d8feb4a7f3290a1475c90de5b7fab0b86ae1e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 13:08:02 -0400 Subject: [PATCH 13/30] Address launch configuration context review Keep the shipped WithDebugSupport overload as an obsolete forwarder, make launch configuration callback contexts framework-owned, and expose original versus executable configurations for debug argument rewrites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ArgumentsExecutionConfigurationGatherer.cs | 26 +++-- .../DebugSupportExtensions.cs | 12 ++- ...ExecutionConfigurationBuilderExtensions.cs | 8 ++ .../LaunchConfigurationCallbackContext.cs | 60 +++++++++--- .../CompatibilitySuppressions.xml | 7 -- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 91 ++++++++++++++---- .../ResourceBuilderExtensions.cs | 95 ++++++++++++++++--- .../SupportsDebuggingAnnotation.cs | 6 ++ .../Utils/LaunchConfigurationTestHelpers.cs | 18 ++-- .../Dcp/DcpExecutorTests.cs | 67 +++++++++++-- .../DebugSupportExtensionsTests.cs | 26 ++++- ...ExecutableResourceBuilderExtensionTests.cs | 30 ++++++ 12 files changed, 372 insertions(+), 74 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs index 16c901712c3..1bd438350f2 100644 --- a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs @@ -10,21 +10,35 @@ namespace Aspire.Hosting.ApplicationModel; /// internal class ArgumentsExecutionConfigurationGatherer : IExecutionConfigurationGatherer { + private readonly Func _shouldIncludeAnnotation; + + public ArgumentsExecutionConfigurationGatherer(Func? shouldIncludeAnnotation = null) + { + _shouldIncludeAnnotation = shouldIncludeAnnotation ?? (static _ => true); + } + /// public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) { IList args = [.. context.Arguments]; - var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken) - { - Logger = resourceLogger, - ExecutionContext = executionContext - }; foreach (var ann in argumentAnnotations) { - // Each annotation operates on a shared context. + if (!_shouldIncludeAnnotation(ann)) + { + continue; + } + + var callbackContext = new CommandLineArgsCallbackContext([.. args], resource, cancellationToken) + { + Logger = resourceLogger, + ExecutionContext = executionContext + }; + + // Each annotation receives the current arguments. This matters when an earlier + // annotation returns a cached immutable result instead of mutating the prior list. args = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); } diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index 6bcca221479..a78304cb227 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -88,9 +88,8 @@ public static bool SupportsDebugging(this IResource resource, IConfiguration con /// which owns the complete configuration; Aspire serializes the result as-is. /// /// - /// This method never resolves arguments or environment variables. Callers that need a real execution - /// configuration must build it explicitly with and place it - /// on . + /// This method never resolves arguments or environment variables. Aspire creates + /// when the active debug-support annotation is producing a launch configuration for an executable creation. /// /// [AspireExportIgnore(Reason = "Debug support inspection is a local .NET helper and is not part of the ATS surface.")] @@ -109,11 +108,16 @@ public static Task CreateLaunchConfigurationAsync( nameof(context)); } - if (context.ExecutionConfiguration.Exception is { } configurationException) + if (context.OriginalExecutionConfiguration.Exception is { } configurationException) { ExceptionDispatchInfo.Throw(configurationException); } + if (context.ExecutableExecutionConfiguration.Exception is { } executableConfigurationException) + { + ExceptionDispatchInfo.Throw(executableConfigurationException); + } + if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) { throw new InvalidOperationException( diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs index 6a871bc0a95..f8cc4ef2427 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs @@ -22,6 +22,14 @@ public static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecution return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer()); } + internal static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecutionConfigurationBuilder builder, Func shouldIncludeAnnotation) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(shouldIncludeAnnotation); + + return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer(shouldIncludeAnnotation)); + } + /// /// Adds an environment variables configuration gatherer to the builder. /// diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs index 1ecbc229d8a..fddf308ea50 100644 --- a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -11,45 +11,83 @@ namespace Aspire.Hosting.ApplicationModel; /// Provides the runtime data used to create a launch configuration for a resource. /// /// -/// Aspire creates a new context for each executable creation, including restarts and replicas. -/// is the same resolved configuration used to populate the -/// underlying executable's arguments and environment variables. Only the launch configuration returned -/// by the producer is serialized for the IDE. Processed environment values can contain secrets. +/// Aspire creates a new context when the resource's active debug-support annotation produces a launch +/// configuration for an executable creation, including restarts and replicas. The producer is not invoked +/// when the annotation is inactive, unsupported by the current debug session, or skipped because a +/// already supplied a +/// launch configuration. +/// contains the resolved resource configuration before an active +/// debug-support argument rewrite runs. contains the copy used to +/// populate the underlying executable after that rewrite. Only the launch configuration returned by the producer +/// is serialized for the IDE. Processed environment values can contain secrets. /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class LaunchConfigurationCallbackContext { + internal LaunchConfigurationCallbackContext( + string mode, + IResource resource, + IExecutionConfigurationResult originalExecutionConfiguration, + IExecutionConfigurationResult executableExecutionConfiguration, + DistributedApplicationExecutionContext executionContext, + ILogger? logger = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mode); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(originalExecutionConfiguration); + ArgumentNullException.ThrowIfNull(executableExecutionConfiguration); + ArgumentNullException.ThrowIfNull(executionContext); + + Mode = mode; + Resource = resource; + OriginalExecutionConfiguration = originalExecutionConfiguration; + ExecutableExecutionConfiguration = executableExecutionConfiguration; + ExecutionContext = executionContext; + Logger = logger ?? NullLogger.Instance; + CancellationToken = cancellationToken; + } + /// /// Gets the requested launch mode, one of the values on . /// - public required string Mode { get; init; } + public string Mode { get; } /// /// Gets the resource being launched. /// - public required IResource Resource { get; init; } + public IResource Resource { get; } /// - /// Gets the resolved execution configuration used for the executable. + /// Gets the resolved execution configuration before the active debug-support argument rewrite runs. /// /// /// Processed environment values can contain secrets. Aspire serializes only the launch configuration /// returned by the producer; integrations should copy values from this result only when the IDE requires them. /// - public required IExecutionConfigurationResult ExecutionConfiguration { get; init; } + public IExecutionConfigurationResult OriginalExecutionConfiguration { get; } + + /// + /// Gets the resolved execution configuration used to populate the executable after the active debug-support argument rewrite runs. + /// + /// + /// This is a copy of with the active argsCallback applied. + /// When debug support does not rewrite arguments, this is the same instance as . + /// + public IExecutionConfigurationResult ExecutableExecutionConfiguration { get; } /// /// Gets the execution context for the current AppHost invocation. /// - public required DistributedApplicationExecutionContext ExecutionContext { get; init; } + public DistributedApplicationExecutionContext ExecutionContext { get; } /// /// Gets the resource logger for this executable creation. /// - public ILogger Logger { get; init; } = NullLogger.Instance; + public ILogger Logger { get; } /// /// Gets the cancellation token for this executable creation. /// - public CancellationToken CancellationToken { get; init; } + public CancellationToken CancellationToken { get; } } diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index a604bf888db..bd08efb4a92 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -1,13 +1,6 @@  - - CP0002 - M:Aspire.Hosting.ResourceBuilderExtensions.WithDebugSupport``2(Aspire.Hosting.ApplicationModel.IResourceBuilder{``0},System.Func{System.String,``1},System.String,System.Action{Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext}) - lib/net8.0/Aspire.Hosting.dll - lib/net8.0/Aspire.Hosting.dll - true - CP0006 M:Aspire.Hosting.IInteractionService.PromptProgressAsync(System.String,System.String,Aspire.Hosting.ProgressInteractionOptions,System.Threading.CancellationToken) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 96f1c4f069b..8a0e2915804 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -16,7 +16,7 @@ namespace Aspire.Hosting.Dcp; -using ExecutableConfiguration = (IExecutionConfigurationResult Configuration, ExecutablePemCertificates? PemCertificates); +using ExecutableConfiguration = (IExecutionConfigurationResult OriginalConfiguration, IExecutionConfigurationResult ExecutableConfiguration, ExecutablePemCertificates? PemCertificates); /// /// Handles preparation and creation of Executable DCP resources (project executables and plain executables). @@ -115,7 +115,7 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } } - var (configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, cancellationToken).ConfigureAwait(false); + var (originalConfiguration, configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false); spec.PemCertificates = pemCertificates; @@ -207,15 +207,14 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC var mode = isProjectLaunchConfiguration ? GetProjectLaunchConfigurationMode() : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; - var callbackContext = new LaunchConfigurationCallbackContext - { - Mode = mode, - Resource = er.ModelResource, - ExecutionConfiguration = configuration, - ExecutionContext = _executionContext, - Logger = resourceLogger, - CancellationToken = cancellationToken - }; + var callbackContext = new LaunchConfigurationCallbackContext( + mode, + er.ModelResource, + originalConfiguration, + configuration, + _executionContext, + resourceLogger, + cancellationToken); try { @@ -509,7 +508,11 @@ private static void ApplyMonitorProcess(IResource resource, ExecutableSpec spec) } } - private async Task BuildExecutableConfiguration(RenderedModelResource er, ILogger resourceLogger, CancellationToken cancellationToken) + private async Task BuildExecutableConfiguration( + RenderedModelResource er, + ILogger resourceLogger, + SupportsDebuggingAnnotation? supportsDebuggingAnnotation, + CancellationToken cancellationToken) { var exe = (Executable)er.DcpResource; @@ -519,8 +522,18 @@ private async Task BuildExecutableConfiguration(Rendere var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); - var configuration = await ExecutionConfigurationBuilder.Create(er.ModelResource) - .WithArgumentsConfig() + var activeDebugArgsAnnotation = supportsDebuggingAnnotation?.DebugCommandLineArgsCallbackAnnotation; + var configurationBuilder = ExecutionConfigurationBuilder.Create(er.ModelResource); + if (activeDebugArgsAnnotation is null) + { + configurationBuilder.WithArgumentsConfig(); + } + else + { + configurationBuilder.WithArgumentsConfig(annotation => !ReferenceEquals(annotation, activeDebugArgsAnnotation)); + } + + var originalConfiguration = await configurationBuilder .WithEnvironmentVariablesConfig() .WithCertificateTrustConfig(scope => { @@ -559,9 +572,18 @@ private async Task BuildExecutableConfiguration(Rendere .BuildAsync(_executionContext, resourceLogger, cancellationToken) .ConfigureAwait(false); + var executableConfiguration = activeDebugArgsAnnotation is null + ? originalConfiguration + : await BuildExecutableConfigurationWithDebugArgumentsAsync( + originalConfiguration, + er.ModelResource, + resourceLogger, + cancellationToken) + .ConfigureAwait(false); + // Add the certificates to the executable spec so they'll be placed in the DCP config ExecutablePemCertificates? pemCertificates = null; - if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) + if (originalConfiguration.TryGetAdditionalData(out var certificateTrustConfiguration) && certificateTrustConfiguration.Scope != CertificateTrustScope.None && certificateTrustConfiguration.Certificates.Count > 0) { @@ -585,7 +607,7 @@ private async Task BuildExecutableConfiguration(Rendere } } - if (configuration.TryGetAdditionalData(out var tlsCertificateConfiguration)) + if (originalConfiguration.TryGetAdditionalData(out var tlsCertificateConfiguration)) { var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; var publicCertificatePem = tlsCertificateConfiguration.Certificate.ExportCertificatePem(); @@ -630,7 +652,42 @@ private async Task BuildExecutableConfiguration(Rendere } } - return (configuration, pemCertificates); + return (originalConfiguration, executableConfiguration, pemCertificates); + } + + private async Task BuildExecutableConfigurationWithDebugArgumentsAsync( + IExecutionConfigurationResult originalConfiguration, + IResource resource, + ILogger resourceLogger, + CancellationToken cancellationToken) + { + var rewrittenArgsConfiguration = await ExecutionConfigurationBuilder.Create(resource) + .WithArgumentsConfig() + .BuildAsync(_executionContext, resourceLogger, cancellationToken) + .ConfigureAwait(false); + + return new ExecutionConfigurationResult + { + References = originalConfiguration.References.Concat(rewrittenArgsConfiguration.References).ToHashSet(), + ArgumentsWithUnprocessed = rewrittenArgsConfiguration.ArgumentsWithUnprocessed, + EnvironmentVariablesWithUnprocessed = originalConfiguration.EnvironmentVariablesWithUnprocessed, + AdditionalConfigurationData = originalConfiguration.AdditionalConfigurationData, + Exception = CombineExecutionConfigurationExceptions(originalConfiguration.Exception, rewrittenArgsConfiguration.Exception) + }; + } + + private static Exception? CombineExecutionConfigurationExceptions(Exception? originalException, Exception? rewrittenArgsException) + { + return (originalException, rewrittenArgsException) switch + { + (null, null) => null, + ({ } exception, null) => exception, + (null, { } exception) => exception, + ({ } original, { } rewritten) => new AggregateException( + "One or more errors occurred while resolving resource configuration.", + original, + rewritten) + }; } private string GetCertificatesRootDirectory(RenderedModelResource er, Executable exe) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 77bcff3ba2f..a2b634b679c 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4760,6 +4760,52 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde return builder; } + /// + /// 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. + /// + /// 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. + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// The . + /// + /// Registering debug support is synchronous. Aspire invokes + /// later only for executable creations where this debug-support annotation is active for the current debug + /// session, including restarts and replicas. + /// + /// A that already supplies a + /// launch configuration skips the producer for that + /// specific . Producers for other launch configuration types still + /// run when their annotation is active. + /// + /// + [Obsolete("Use the overload that accepts LaunchConfigurationCallbackContext and returns a Task.")] + [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, + Action? argsCallback = null) + where T : IResource + { + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + +#pragma warning disable ASPIREEXTENSION001 // Forwarding to the replacement experimental API. + var result = builder.WithDebugSupport( + context => Task.FromResult(launchConfigurationProducer(context.Mode)), + launchConfigurationType, + argsCallback); +#pragma warning restore ASPIREEXTENSION001 + + return result; + } + /// /// Adds support for debugging the resource in an IDE or extension host. /// @@ -4771,12 +4817,23 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// produces the complete launch configuration handed to the IDE. /// /// The type tag of the launch configuration sent to the IDE. - /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// The callback rewrites only the executable configuration; + /// preserves the resolved arguments before this callback runs. + /// /// The . /// - /// Registering debug support is synchronous; Aspire invokes - /// later for each executable creation, restart, or replica. A producer that completes synchronously should - /// return its result with . + /// Registering debug support is synchronous. Aspire invokes + /// later only for executable creations where this debug-support annotation is active for the current debug + /// session, including restarts and replicas. A producer that completes synchronously should return its result + /// with . + /// + /// A that already supplies a + /// launch configuration skips the producer for that + /// specific . Producers for other launch configuration types still + /// run when their annotation is active. + /// /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] @@ -4795,26 +4852,38 @@ public static IResourceBuilder WithDebugSupport( return builder; } - var supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( - builder.Resource.Name, - launchConfigurationType, - launchConfigurationProducer, - rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder); - - if (argsCallback is not null && builder is IResourceBuilder resourceWithArgs) + SupportsDebuggingAnnotation? supportsDebuggingAnnotation = null; + var argsResourceBuilder = builder as IResourceBuilder; + CommandLineArgsCallbackAnnotation? debugCommandLineArgsAnnotation = null; + if (argsCallback is not null && argsResourceBuilder is not null) { - resourceWithArgs.WithArgs(ctx => + debugCommandLineArgsAnnotation = new CommandLineArgsCallbackAnnotation(ctx => { // Make sure that we do not call the callback if we aren't the active (last) SupportsDebuggingAnnotation, // because the callback may be specific to the launch configuration type. - if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) + if (supportsDebuggingAnnotation is not null + && argsResourceBuilder.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) { argsCallback(ctx); } + + return Task.CompletedTask; }); } + supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( + builder.Resource.Name, + launchConfigurationType, + launchConfigurationProducer, + debugCommandLineArgsAnnotation, + rewritesArgumentsForDebugging: debugCommandLineArgsAnnotation is not null); + + if (debugCommandLineArgsAnnotation is not null && argsResourceBuilder is not null) + { + argsResourceBuilder.WithAnnotation(debugCommandLineArgsAnnotation); + } + return builder.WithAnnotation(supportsDebuggingAnnotation); } diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 84e8b71e02b..26a314c31cc 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -26,11 +26,13 @@ private SupportsDebuggingAnnotation( string launchConfigurationType, Func launchConfigurationAnnotator, Func> launchConfigurationProducer, + CommandLineArgsCallbackAnnotation? debugCommandLineArgsCallbackAnnotation, bool rewritesArgumentsForDebugging) { LaunchConfigurationType = launchConfigurationType; LaunchConfigurationAnnotator = launchConfigurationAnnotator; LaunchConfigurationProducer = launchConfigurationProducer; + DebugCommandLineArgsCallbackAnnotation = debugCommandLineArgsCallbackAnnotation; RewritesArgumentsForDebugging = rewritesArgumentsForDebugging; } @@ -57,6 +59,8 @@ private SupportsDebuggingAnnotation( // the supported way to reach it. internal Func> LaunchConfigurationProducer { get; } + internal CommandLineArgsCallbackAnnotation? DebugCommandLineArgsCallbackAnnotation { get; } + /// /// Indicates that the debug support rewrites the resource's command-line arguments while a debug /// session is active (via the argsCallback passed to WithDebugSupport). @@ -83,6 +87,7 @@ internal static SupportsDebuggingAnnotation Create( string resourceName, string launchConfigurationType, Func> launchConfigurationProducer, + CommandLineArgsCallbackAnnotation? debugCommandLineArgsCallbackAnnotation = null, bool rewritesArgumentsForDebugging = false) { // The annotator stays generic over T so the DCP annotation is serialized against the concrete @@ -96,6 +101,7 @@ 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 context => (await ProduceAsync(context).ConfigureAwait(false))!, + debugCommandLineArgsCallbackAnnotation, rewritesArgumentsForDebugging); async Task ProduceAsync(LaunchConfigurationCallbackContext context) diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs index 87937c7b244..fd9a910d058 100644 --- a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -20,15 +20,15 @@ public static LaunchConfigurationCallbackContext CreateCallbackContext( { ArgumentNullException.ThrowIfNull(resource); - return new LaunchConfigurationCallbackContext - { - Mode = mode, - Resource = resource, - ExecutionConfiguration = executionConfiguration ?? CreateExecutionConfigurationResult(), - ExecutionContext = executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), - Logger = logger ?? NullLogger.Instance, - CancellationToken = cancellationToken - }; + executionConfiguration ??= CreateExecutionConfigurationResult(); + return new LaunchConfigurationCallbackContext( + mode, + resource, + executionConfiguration, + executionConfiguration, + executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + logger ?? NullLogger.Instance, + cancellationToken); } public static IExecutionConfigurationResult CreateExecutionConfigurationResult( diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index fb26302e54e..6f8d9a6c9a4 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -499,12 +499,12 @@ public async Task ProjectReplicas_CreateFreshLaunchConfigurationContexts() var contexts = launchContexts.ToArray(); Assert.Equal(2, contexts.Length); Assert.NotSame(contexts[0], contexts[1]); - Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); + Assert.NotSame(contexts[0].OriginalExecutionConfiguration, contexts[1].OriginalExecutionConfiguration); Assert.All(contexts, context => Assert.Same(project.Resource, context.Resource)); Assert.All( contexts, context => Assert.Contains( - context.ExecutionConfiguration.EnvironmentVariables, + context.OriginalExecutionConfiguration.EnvironmentVariables, pair => pair is { Key: "REPLICA_VALUE", Value: "resolved" })); } @@ -612,15 +612,15 @@ public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchConte var contexts = launchContexts.ToArray(); Assert.Equal(2, contexts.Length); Assert.NotSame(contexts[0], contexts[1]); - Assert.NotSame(contexts[0].ExecutionConfiguration, contexts[1].ExecutionConfiguration); + Assert.NotSame(contexts[0].OriginalExecutionConfiguration, contexts[1].OriginalExecutionConfiguration); Assert.Equal( "1", - contexts[0].ExecutionConfiguration.EnvironmentVariables + contexts[0].OriginalExecutionConfiguration.EnvironmentVariables .Single(pair => pair.Key == "CALL_COUNT") .Value); Assert.Equal( "2", - contexts[1].ExecutionConfiguration.EnvironmentVariables + contexts[1].OriginalExecutionConfiguration.EnvironmentVariables .Single(pair => pair.Key == "CALL_COUNT") .Value); } @@ -3830,7 +3830,7 @@ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExe context => { launchContext = context; - var debugValue = context.ExecutionConfiguration.EnvironmentVariables + var debugValue = context.OriginalExecutionConfiguration.EnvironmentVariables .Single(pair => pair.Key == "DEBUG_VALUE") .Value; @@ -6523,6 +6523,61 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OmitsP Assert.Null(exe.Spec.FallbackExecutionTypes); } + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_ProducerSeesOriginalArgsAndDcpUsesRewrittenArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("run", "./cmd/api", "user-arg") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static ctx => + { + ctx.Args.RemoveAt(0); + ctx.Args.RemoveAt(0); + }); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.NotSame(originalConfiguration, executableConfiguration); + Assert.Equal(["run", "./cmd/api", "user-arg"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["user-arg"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["user-arg"], exe.Spec.Args); + } + [Fact] public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_LaunchConfigFailure_FailsWithoutProcessFallback() { diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index 7f8aa00ff1e..f359f7f0866 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -4,6 +4,7 @@ #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; @@ -16,6 +17,29 @@ namespace Aspire.Hosting.Tests; [Trait("Partition", "2")] public class DebugSupportExtensionsTests { + [Fact] + public void LaunchConfigurationCallbackContextIsFrameworkOwned() + { + var contextType = typeof(LaunchConfigurationCallbackContext); + + Assert.Empty(contextType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); + + foreach (var propertyName in new[] + { + nameof(LaunchConfigurationCallbackContext.Mode), + nameof(LaunchConfigurationCallbackContext.Resource), + nameof(LaunchConfigurationCallbackContext.OriginalExecutionConfiguration), + nameof(LaunchConfigurationCallbackContext.ExecutableExecutionConfiguration), + nameof(LaunchConfigurationCallbackContext.ExecutionContext), + nameof(LaunchConfigurationCallbackContext.Logger), + nameof(LaunchConfigurationCallbackContext.CancellationToken) + }) + { + var property = Assert.Single(contextType.GetProperties(), property => property.Name == propertyName); + Assert.Null(property.SetMethod); + } + } + [Fact] public async Task CreateLaunchConfigurationResolvesTheLaunchProfileForProjectResources() { @@ -206,7 +230,7 @@ public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluati return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode, - Package = context.ExecutionConfiguration.EnvironmentVariables + Package = context.OriginalExecutionConfiguration.EnvironmentVariables .Single(pair => pair.Key == "EXPECTED") .Value }); diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index b90e0a1d989..47482acd126 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -137,6 +137,36 @@ await executable.Resource.CreateLaunchConfigurationAsync( Assert.Equal("go", launchConfiguration.Type); } + [Fact] +#pragma warning disable CS0618 // Verify the shipped overload remains source-compatible while forwarding to the context overload. + public async Task WithDebugSupportLegacyModeProducerOverloadForwardsToContextProducer() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + string? observedMode = null; + var executable = builder.AddExecutable("legacy", "command", "workingdirectory") + .WithDebugSupport( + (string mode) => + { + observedMode = mode; + return new ExecutableLaunchConfiguration("go") + { + Mode = mode + }; + }, + "go"); + + var launchConfiguration = Assert.IsType( + await executable.Resource.CreateLaunchConfigurationAsync( + LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + ExecutableLaunchMode.Debug))); + + Assert.Equal(ExecutableLaunchMode.Debug, observedMode); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); + Assert.Equal("go", launchConfiguration.Type); + } +#pragma warning restore CS0618 + [Fact] public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() { From 0ee07efe6a06feca5f8d8269e035cdc6c925163f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 13:56:05 -0400 Subject: [PATCH 14/30] Install Azure Functions Core Tools from release archive Avoid the broken npm postinstall URL for azure-functions-core-tools@4 in Linux test jobs by using the same pinned release archive path as extension E2E. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-tests.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8e4c4f3ebe3..ab070af0ba2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -258,7 +258,21 @@ 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 + set -euo pipefail + + core_tools_version='4.12.1' + core_tools_directory="$RUNNER_TEMP/azure-functions-core-tools" + core_tools_archive="$RUNNER_TEMP/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip" + curl --fail --location --retry 3 --retry-all-errors \ + --output "$core_tools_archive" \ + "https://github.com/Azure/azure-functions-core-tools/releases/download/${core_tools_version}/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip" + echo 'faf8fb8d50b5293df338bec70594b12f45730e9fe251805298859b2238cf627e '"$core_tools_archive" | sha256sum --check - + mkdir -p "$core_tools_directory" + unzip -q "$core_tools_archive" -d "$core_tools_directory" + chmod +x "$core_tools_directory/func" + echo "$core_tools_directory" >> "$GITHUB_PATH" + export PATH="$core_tools_directory:$PATH" + func --version - name: Compute test project path id: compute_project_path From 2db433e8b406a05b655da4d0dda559fc7c60eb76 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 14:03:02 -0400 Subject: [PATCH 15/30] Recompute debug argument callbacks after original snapshot Clear argument callback caches before building the executable debug snapshot so WithArgs annotations after WithDebugSupport are evaluated against rewritten arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 12 ++++++++++++ tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 8a0e2915804..3fcae5e7eb9 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -661,6 +661,18 @@ private async Task BuildExecutableConfigurationWi ILogger resourceLogger, CancellationToken cancellationToken) { + if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) + { + foreach (var annotation in argumentAnnotations) + { + // The original snapshot intentionally skips the active debug annotation, but that can cache + // downstream WithArgs annotations against the pre-rewrite list. Clear the per-annotation + // caches before the executable snapshot so annotations after WithDebugSupport are evaluated + // against the debug-rewritten arguments. + annotation.AsCallbackAnnotation().ForgetCachedResult(); + } + } + var rewrittenArgsConfiguration = await ExecutionConfigurationBuilder.Create(resource) .WithArgumentsConfig() .BuildAsync(_executionContext, resourceLogger, cancellationToken) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 6f8d9a6c9a4..0654a76112f 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -6537,7 +6537,7 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Produc IExecutionConfigurationResult? executableConfiguration = null; var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable) - .WithArgs("run", "./cmd/api", "user-arg") + .WithArgs("run", "./cmd/api") .WithDebugSupport( context => { @@ -6550,7 +6550,8 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Produc { ctx.Args.RemoveAt(0); ctx.Args.RemoveAt(0); - }); + }) + .WithArgs("user-arg"); var configDict = new Dictionary { From 6cb79d348db6d0aefa7c4c43f26ac50456957329 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 14:07:43 -0400 Subject: [PATCH 16/30] Validate legacy debug producer return type Keep the restored WithDebugSupport compatibility overload from accepting Task or ValueTask producers that would be serialized as launch configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResourceBuilderExtensions.cs | 9 ++++++++ ...ExecutableResourceBuilderExtensionTests.cs | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index a2b634b679c..4b6755a09c1 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4795,6 +4795,15 @@ public static IResourceBuilder WithDebugSupport( where T : IResource { ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + var producerReturnType = typeof(TLaunchConfiguration); + if (typeof(Task).IsAssignableFrom(producerReturnType) + || producerReturnType == typeof(ValueTask) + || producerReturnType.IsGenericType && producerReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + throw new InvalidOperationException( + $"The legacy {nameof(WithDebugSupport)} overload requires a synchronous launch configuration producer. " + + "Use the overload that accepts LaunchConfigurationCallbackContext for Task or ValueTask returning producers."); + } #pragma warning disable ASPIREEXTENSION001 // Forwarding to the replacement experimental API. var result = builder.WithDebugSupport( diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 47482acd126..71d6eb3c4e6 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -167,6 +167,28 @@ await executable.Resource.CreateLaunchConfigurationAsync( } #pragma warning restore CS0618 + [Fact] +#pragma warning disable CS0618 // Verify the shipped overload preserves its task-return validation. + public void WithDebugSupportLegacyModeProducerOverloadRejectsTaskReturningProducer() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var taskException = Assert.Throws(() => + builder.AddExecutable("task", "command", "workingdirectory") + .WithDebugSupport( + (string mode) => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), + "go")); + Assert.Contains("Task", taskException.Message); + + var valueTaskException = Assert.Throws(() => + builder.AddExecutable("value-task", "command", "workingdirectory") + .WithDebugSupport( + (string mode) => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), + "go")); + Assert.Contains("ValueTask", valueTaskException.Message); + } +#pragma warning restore CS0618 + [Fact] public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() { From 1481f78d03d7d7dc9b264c1a8a0e8b28860d05e8 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 14:11:14 -0400 Subject: [PATCH 17/30] Revert unrelated Azure Functions tool install change This reverts commit 0ee07efe6a59b501e625e08871e388b63cc4181c. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/run-tests.yml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ab070af0ba2..8e4c4f3ebe3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -258,21 +258,7 @@ jobs: - name: Install Azure Functions Core Tools if: runner.os == 'Linux' && (inputs.testShortName == 'Playground' || inputs.testShortName == 'Azure') run: | - set -euo pipefail - - core_tools_version='4.12.1' - core_tools_directory="$RUNNER_TEMP/azure-functions-core-tools" - core_tools_archive="$RUNNER_TEMP/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip" - curl --fail --location --retry 3 --retry-all-errors \ - --output "$core_tools_archive" \ - "https://github.com/Azure/azure-functions-core-tools/releases/download/${core_tools_version}/Azure.Functions.Cli.linux-x64.${core_tools_version}.zip" - echo 'faf8fb8d50b5293df338bec70594b12f45730e9fe251805298859b2238cf627e '"$core_tools_archive" | sha256sum --check - - mkdir -p "$core_tools_directory" - unzip -q "$core_tools_archive" -d "$core_tools_directory" - chmod +x "$core_tools_directory/func" - echo "$core_tools_directory" >> "$GITHUB_PATH" - export PATH="$core_tools_directory:$PATH" - func --version + npm i -g azure-functions-core-tools@4 --unsafe-perm true - name: Compute test project path id: compute_project_path From dcf4b098421305f1b95b833eb67fcf9e21a81a51 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 8 Aug 2026 15:05:12 -0400 Subject: [PATCH 18/30] Preserve launch override process args Suppress debug argument rewrites for project launch-args overrides that remain in process mode while still allowing custom launch configuration producers to run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LaunchConfigurationCallbackContext.cs | 9 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 22 ++++- .../ResourceBuilderExtensions.cs | 16 +++- .../Dcp/DcpExecutorTests.cs | 94 ++++++++++++++++++- 4 files changed, 128 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs index fddf308ea50..b55b7bde069 100644 --- a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -18,8 +18,10 @@ namespace Aspire.Hosting.ApplicationModel; /// launch configuration. /// contains the resolved resource configuration before an active /// debug-support argument rewrite runs. contains the copy used to -/// populate the underlying executable after that rewrite. Only the launch configuration returned by the producer -/// is serialized for the IDE. Processed environment values can contain secrets. +/// populate the underlying executable after that rewrite. When a +/// pins a project executable to process execution, the debug argument rewrite is suppressed so the process command +/// line remains runnable. Only the launch configuration returned by the producer is serialized for the IDE. +/// Processed environment values can contain secrets. /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class LaunchConfigurationCallbackContext @@ -72,7 +74,8 @@ internal LaunchConfigurationCallbackContext( /// /// /// This is a copy of with the active argsCallback applied. - /// When debug support does not rewrite arguments, this is the same instance as . + /// When debug support does not rewrite arguments, or a project launch-args override keeps the executable in + /// process mode, this is the same instance as . /// public IExecutionConfigurationResult ExecutableExecutionConfiguration { get; } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 3fcae5e7eb9..e91f29cba96 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -115,7 +115,17 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } } - var (originalConfiguration, configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, supportsDebuggingAnnotation, cancellationToken).ConfigureAwait(false); + // A launch-args override pins the executable to Process mode, so the process command line must stay + // runnable even when a custom launch configuration producer also has an IDE-only args callback. + // The producer still runs below for non-"project" launch types, but its debug argument rewrite is + // suppressed for the executable snapshot. + var (originalConfiguration, configuration, pemCertificates) = await BuildExecutableConfiguration( + er, + resourceLogger, + supportsDebuggingAnnotation, + applyDebugArgumentRewrite: !preparedFromLaunchArgsOverride, + cancellationToken) + .ConfigureAwait(false); spec.PemCertificates = pemCertificates; @@ -512,6 +522,7 @@ private async Task BuildExecutableConfiguration( RenderedModelResource er, ILogger resourceLogger, SupportsDebuggingAnnotation? supportsDebuggingAnnotation, + bool applyDebugArgumentRewrite, CancellationToken cancellationToken) { var exe = (Executable)er.DcpResource; @@ -523,6 +534,7 @@ private async Task BuildExecutableConfiguration( var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); var activeDebugArgsAnnotation = supportsDebuggingAnnotation?.DebugCommandLineArgsCallbackAnnotation; + var shouldBuildDebugArguments = activeDebugArgsAnnotation is not null && applyDebugArgumentRewrite; var configurationBuilder = ExecutionConfigurationBuilder.Create(er.ModelResource); if (activeDebugArgsAnnotation is null) { @@ -572,14 +584,14 @@ private async Task BuildExecutableConfiguration( .BuildAsync(_executionContext, resourceLogger, cancellationToken) .ConfigureAwait(false); - var executableConfiguration = activeDebugArgsAnnotation is null - ? originalConfiguration - : await BuildExecutableConfigurationWithDebugArgumentsAsync( + var executableConfiguration = shouldBuildDebugArguments + ? await BuildExecutableConfigurationWithDebugArgumentsAsync( originalConfiguration, er.ModelResource, resourceLogger, cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false) + : originalConfiguration; // Add the certificates to the executable spec so they'll be placed in the DCP config ExecutablePemCertificates? pemCertificates = null; diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 4b6755a09c1..426b8e92f90 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4770,7 +4770,11 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// 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. - /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// When a keeps a project executable in process mode, + /// Aspire suppresses this rewrite for the process command line so the override remains runnable. + /// /// The . /// /// Registering debug support is synchronous. Aspire invokes @@ -4780,7 +4784,8 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// A that already supplies a /// launch configuration skips the producer for that /// specific . Producers for other launch configuration types still - /// run when their annotation is active. + /// run when their annotation is active, but their does not rewrite the + /// process command line owned by the launch-args override. /// /// [Obsolete("Use the overload that accepts LaunchConfigurationCallbackContext and returns a Task.")] @@ -4829,7 +4834,9 @@ public static IResourceBuilder WithDebugSupport( /// /// Optional callback to add or modify command-line arguments while this debug support annotation is active. /// The callback rewrites only the executable configuration; - /// preserves the resolved arguments before this callback runs. + /// preserves the resolved arguments before this callback runs. When a + /// keeps a project executable in process mode, Aspire + /// suppresses this rewrite for the process command line so the override remains runnable. /// /// The . /// @@ -4841,7 +4848,8 @@ public static IResourceBuilder WithDebugSupport( /// A that already supplies a /// launch configuration skips the producer for that /// specific . Producers for other launch configuration types still - /// run when their annotation is active. + /// run when their annotation is active, but their does not rewrite the + /// process command line owned by the launch-args override. /// /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 0654a76112f..42990fa34df 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -5308,7 +5308,6 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["maui"] }; - var configDict = new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", @@ -5441,6 +5440,99 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); } + [Fact] + public async Task ProjectWithLaunchArgsOverrideAndRewritingNonProjectDebugSupport_DoesNotRewriteProcessArgs() + { + var builder = DistributedApplication.CreateBuilder(); + + var projectBuilder = builder.AddProject("proj", launchProfileName: null); + var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (annotationToRemove is not null) + { + projectBuilder.Resource.Annotations.Remove(annotationToRemove); + } + +#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 runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }; + var debugSessionInfoJson = JsonSerializer.Serialize(runSessionInfo); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + projectBuilder + .WithArgs("run", "-f", "net10.0-android") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new TestMauiLaunchConfiguration + { + Mode = context.Mode, + ProjectPath = "/mauiapp/MauiApp.csproj", + TargetFramework = "net10.0-android", + Platform = "android", + TargetKind = "emulator" + }); + }, + "maui", + argsCallback: static context => context.Args.Clear()); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + Assert.True(projectBuilder.Resource.SupportsDebugging(configuration, out var supportsDebuggingAnnotation)); + Assert.NotNull(supportsDebuggingAnnotation.DebugCommandLineArgsCallbackAnnotation); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; + var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions); + + await appExecutor.RunApplicationAsync(); + + var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); + + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["run", "-f", "net10.0-android"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["run", "-f", "net10.0-android"], executableConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Same(originalConfiguration, executableConfiguration); + + 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, exe.Spec.Args); + + Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs)); + var launchConfig = Assert.Single(launchConfigs); + Assert.Equal("maui", launchConfig.Type); + Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode); + } + [Fact] public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchProfileArgsStayAfterDotnetRunArgs() { From 024577a8e4f3ac083da5a272be7f7bc3dc723870 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 15:53:16 -0400 Subject: [PATCH 19/30] Fix debug executable configuration snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 38 ++++--- .../Dcp/DcpExecutorTests.cs | 99 +++++++++++++++++++ 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index e91f29cba96..8a9fd8182c2 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -587,6 +587,7 @@ private async Task BuildExecutableConfiguration( var executableConfiguration = shouldBuildDebugArguments ? await BuildExecutableConfigurationWithDebugArgumentsAsync( originalConfiguration, + activeDebugArgsAnnotation!, er.ModelResource, resourceLogger, cancellationToken) @@ -669,30 +670,37 @@ private async Task BuildExecutableConfiguration( private async Task BuildExecutableConfigurationWithDebugArgumentsAsync( IExecutionConfigurationResult originalConfiguration, + CommandLineArgsCallbackAnnotation activeDebugArgsAnnotation, IResource resource, ILogger resourceLogger, CancellationToken cancellationToken) { - if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) - { - foreach (var annotation in argumentAnnotations) - { - // The original snapshot intentionally skips the active debug annotation, but that can cache - // downstream WithArgs annotations against the pre-rewrite list. Clear the per-annotation - // caches before the executable snapshot so annotations after WithDebugSupport are evaluated - // against the debug-rewritten arguments. - annotation.AsCallbackAnnotation().ForgetCachedResult(); - } - } + // The executable snapshot is the original resolved argument list with only the active debug rewrite + // applied. Do not replay ordinary WithArgs annotations here: they may have side effects, and the + // original snapshot already evaluated them for this executable creation. + activeDebugArgsAnnotation.AsCallbackAnnotation().ForgetCachedResult(); + var callbackContext = new CommandLineArgsCallbackContext( + [.. originalConfiguration.ArgumentsWithUnprocessed.Select(argument => argument.Unprocessed)], + resource, + cancellationToken) + { + Logger = resourceLogger, + ExecutionContext = _executionContext + }; + var rewrittenArgs = await activeDebugArgsAnnotation.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); - var rewrittenArgsConfiguration = await ExecutionConfigurationBuilder.Create(resource) - .WithArgumentsConfig() - .BuildAsync(_executionContext, resourceLogger, cancellationToken) + var rewrittenArgsGathererContext = new ExecutionConfigurationGathererContext(); + rewrittenArgsGathererContext.Arguments.AddRange(rewrittenArgs); + var rewrittenArgsConfiguration = await rewrittenArgsGathererContext + .ResolveAsync(resource, resourceLogger, _executionContext, cancellationToken) .ConfigureAwait(false); + var environmentReferences = originalConfiguration.EnvironmentVariablesWithUnprocessed + .Select(static kvp => kvp.Value.Unprocessed) + .Where(static value => value is IValueProvider or IManifestExpressionProvider); return new ExecutionConfigurationResult { - References = originalConfiguration.References.Concat(rewrittenArgsConfiguration.References).ToHashSet(), + References = environmentReferences.Concat(rewrittenArgsConfiguration.References).ToHashSet(), ArgumentsWithUnprocessed = rewrittenArgsConfiguration.ArgumentsWithUnprocessed, EnvironmentVariablesWithUnprocessed = originalConfiguration.EnvironmentVariablesWithUnprocessed, AdditionalConfigurationData = originalConfiguration.AdditionalConfigurationData, diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 42990fa34df..3082dfcc4d5 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -6671,6 +6671,105 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Produc Assert.Equal(["user-arg"], exe.Spec.Args); } + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OrdinaryArgsCallbacksRunOnce() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + var ordinaryArgsCallbackCalls = 0; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(context => + { + ordinaryArgsCallbackCalls++; + context.Args.Add("run"); + context.Args.Add("app-arg"); + }) + .WithDebugSupport( + context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test", + argsCallback: static ctx => ctx.Args.RemoveAt(0)); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.Equal(1, ordinaryArgsCallbackCalls); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["app-arg"], exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_ExecutableConfigurationReferencesExcludeDroppedOriginalArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + var droppedArgument = builder.AddParameter("dropped-argument", "dropped-value"); + var keptEnvironmentValue = builder.AddParameter("kept-environment", "kept-value"); + + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(droppedArgument.Resource) + .WithEnvironment("KEPT_ENVIRONMENT", keptEnvironmentValue.Resource) + .WithDebugSupport( + context => + { + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static ctx => ctx.Args.Clear()); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(executableConfiguration); + Assert.Collection( + executableConfiguration.References, + reference => Assert.Same(keptEnvironmentValue.Resource, reference)); + Assert.Empty(executableConfiguration.Arguments); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Null(exe.Spec.Args); + } + [Fact] public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_LaunchConfigFailure_FailsWithoutProcessFallback() { From 24588ab64406209f4eebbb666ce52b76230389af Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 16:25:41 -0400 Subject: [PATCH 20/30] Reuse original argument resolutions across the debug rewrite The debug argument rewrite ran the callback's output through a second ExecutionConfigurationGathererContext, so every argument the callback kept was resolved twice for a single executable creation. IValueProvider carries no idempotence guarantee, so the second resolution can return a different value or repeat a side effect - the same hazard that already stops ordinary WithArgs callbacks from being replayed on this path. Carry the original resolutions forward by reference identity and send only the arguments the callback introduced through the gatherer. Arguments reused this way still contribute their references, since ResolveAsync never sees them. Also correct the legacy WithDebugSupport migration message: the replacement overload takes Task, so a ValueTask-returning producer does not bind to it directly and has to be adapted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 48 +++++++++++++- .../ResourceBuilderExtensions.cs | 4 +- .../Dcp/DcpExecutorTests.cs | 65 +++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 8a9fd8182c2..46bdd3ede42 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -689,19 +689,61 @@ [.. originalConfiguration.ArgumentsWithUnprocessed.Select(argument => argument.U }; var rewrittenArgs = await activeDebugArgsAnnotation.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); + // Arguments the debug rewrite kept were already resolved into originalConfiguration for this same + // executable creation. IValueProvider carries no idempotence guarantee, so resolving them a second + // time can produce a different value or repeat a side effect - the same hazard that stopped ordinary + // WithArgs callbacks from being replayed above. Reuse those resolutions by reference identity and + // send only what the callback introduced or replaced through the gatherer. + var previouslyResolved = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var argument in originalConfiguration.ArgumentsWithUnprocessed) + { + previouslyResolved.TryAdd(argument.Unprocessed, (argument.Processed, argument.IsSensitive)); + } + var rewrittenArgsGathererContext = new ExecutionConfigurationGathererContext(); - rewrittenArgsGathererContext.Arguments.AddRange(rewrittenArgs); + rewrittenArgsGathererContext.Arguments.AddRange(rewrittenArgs.Where(argument => !previouslyResolved.ContainsKey(argument))); var rewrittenArgsConfiguration = await rewrittenArgsGathererContext .ResolveAsync(resource, resourceLogger, _executionContext, cancellationToken) .ConfigureAwait(false); + + // ResolveAsync drops arguments that resolve to null, so the newly resolved values are matched back by + // reference identity rather than by position, and an argument missing from both maps is dropped here + // for the same reason it would have been dropped there. + var newlyResolved = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var argument in rewrittenArgsConfiguration.ArgumentsWithUnprocessed) + { + newlyResolved.TryAdd(argument.Unprocessed, (argument.Processed, argument.IsSensitive)); + } + + var reusedReferences = new List(); + var argumentsWithUnprocessed = new List<(object Unprocessed, string Processed, bool IsSensitive)>(rewrittenArgs.Count); + foreach (var argument in rewrittenArgs) + { + if (newlyResolved.TryGetValue(argument, out var resolved)) + { + argumentsWithUnprocessed.Add((argument, resolved.Value, resolved.IsSensitive)); + } + else if (previouslyResolved.TryGetValue(argument, out var reused)) + { + argumentsWithUnprocessed.Add((argument, reused.Value, reused.IsSensitive)); + + // ResolveAsync never saw this argument, so its reference has to be contributed here or the + // executable would lose the dependency edge that the original resolution recorded. + if (argument is IValueProvider or IManifestExpressionProvider) + { + reusedReferences.Add(argument); + } + } + } + var environmentReferences = originalConfiguration.EnvironmentVariablesWithUnprocessed .Select(static kvp => kvp.Value.Unprocessed) .Where(static value => value is IValueProvider or IManifestExpressionProvider); return new ExecutionConfigurationResult { - References = environmentReferences.Concat(rewrittenArgsConfiguration.References).ToHashSet(), - ArgumentsWithUnprocessed = rewrittenArgsConfiguration.ArgumentsWithUnprocessed, + References = environmentReferences.Concat(rewrittenArgsConfiguration.References).Concat(reusedReferences).ToHashSet(), + ArgumentsWithUnprocessed = argumentsWithUnprocessed, EnvironmentVariablesWithUnprocessed = originalConfiguration.EnvironmentVariablesWithUnprocessed, AdditionalConfigurationData = originalConfiguration.AdditionalConfigurationData, Exception = CombineExecutionConfigurationExceptions(originalConfiguration.Exception, rewrittenArgsConfiguration.Exception) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 426b8e92f90..6b8d084be0d 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4807,7 +4807,9 @@ public static IResourceBuilder WithDebugSupport( { throw new InvalidOperationException( $"The legacy {nameof(WithDebugSupport)} overload requires a synchronous launch configuration producer. " + - "Use the overload that accepts LaunchConfigurationCallbackContext for Task or ValueTask returning producers."); + "Use the overload that accepts LaunchConfigurationCallbackContext and returns Task. " + + "A producer that returns ValueTask or ValueTask does not bind to that overload directly; " + + "adapt it with AsTask() or wrap it in an async lambda."); } #pragma warning disable ASPIREEXTENSION001 // Forwarding to the replacement experimental API. diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 3082dfcc4d5..69b61307af1 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -4738,6 +4738,56 @@ public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProces Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); } + [Fact] + public async Task DebugArgumentRewriting_ReusesOriginalResolutionForArgumentsTheCallbackKept() + { + // The debug rewrite runs after the executable's arguments have already been resolved once. Arguments + // the callback keeps are the very same IValueProvider instances, and IValueProvider carries no + // idempotence guarantee - a second GetValueAsync may return a different value or repeat a side + // effect. Carrying the original resolution forward by reference identity is what keeps the two + // resolutions from diverging. + var builder = DistributedApplication.CreateBuilder(); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + // The debug args callback re-checks SupportsDebugging against the application builder's own + // configuration, so the debug session has to be visible there and not only to the executor. + builder.Configuration.AddInMemoryCollection(configDict); + + var countingArgument = new CountingValueProvider("resolved-once"); + + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(context => context.Args.Add(countingArgument)) + .WithDebugSupport( + static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test", + argsCallback: context => context.Args.Insert(0, "--debug")); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + + Assert.Equal(1, countingArgument.ResolutionCount); + Assert.Equal(["--debug", "resolved-once"], exe.Spec.Args); + } + [Fact] public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() { @@ -8375,6 +8425,21 @@ private static X509Certificate2 CreateTestCertificate() } private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory); + + // Counts resolutions so a test can prove an argument was resolved exactly once across the original + // resolution and the debug-argument rewrite that follows it. + private sealed class CountingValueProvider(string value) : IValueProvider + { + private int _resolutionCount; + + public int ResolutionCount => Volatile.Read(ref _resolutionCount); + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _resolutionCount); + return new ValueTask(value); + } + } private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory); // Models a DotnetProjectResource: a plain ExecutableResource (launches `dotnet`) that carries From 5b8d2f3f310afe0212b2e319189cb73e5be28f73 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 18:16:23 -0400 Subject: [PATCH 21/30] Address review findings on debug launch configuration Restore the Process fallback for launch-args-override resources. The fallback runs the executable spec's command "as is", so it is unsafe only when the debug rewrite actually replaced it. A launch-args override suppresses that rewrite, leaving the spec holding the user's real command line, so keying the guard on RewritesArgumentsForDebugging alone denied the fallback to exactly the resources that could still use it: a MAUI resource whose producer threw failed to start instead of running. The other two RewritesArgumentsForDebugging sites are unreachable with an override, so they are correct as written. Restore ArgumentException with nameof(launchConfigurationProducer) on the legacy overload's task-returning guard. The base branch throws ArgumentException there; narrowing it to InvalidOperationException dropped the parameter name for what is an argument problem. The existing test encoded the regression and is updated, now also asserting ParamName. Make the CreateLaunchConfigurationAsync(IResource, LaunchConfigurationCallbackContext) overload internal. The only legal call re-enters the resource's own producer, and a ReferenceEquals guard rejects another resource's context, so the public overload promised a flow no external caller could reach. Aspire.Hosting.Maui.Tests reaches it through a new wrapper on LaunchConfigurationTestHelpers rather than its own InternalsVisibleTo grant: granting IVT directly makes the internal types that integration links from Aspire.Hosting visible from two assemblies and breaks the build with CS0433. Document that the secrets remark covers processed arguments as well as environment values, and add an example to the context-taking overload showing OriginalExecutionConfiguration, argsCallback, and migration from the obsolete one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../DebugSupportExtensions.cs | 11 ++- .../LaunchConfigurationCallbackContext.cs | 4 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 11 ++- .../ResourceBuilderExtensions.cs | 35 +++++++- .../MauiPlatformExtensionsTests.cs | 3 +- .../Utils/LaunchConfigurationTestHelpers.cs | 22 +++++ .../Dcp/DcpExecutorTests.cs | 81 +++++++++++++++++++ ...ExecutableResourceBuilderExtensionTests.cs | 8 +- 8 files changed, 165 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index a78304cb227..338fb200e6e 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -91,9 +91,16 @@ public static bool SupportsDebugging(this IResource resource, IConfiguration con /// This method never resolves arguments or environment variables. Aspire creates /// when the active debug-support annotation is producing a launch configuration for an executable creation. /// + /// + /// Deliberately internal. has no public constructor, and + /// AppHost code only ever receives one while this resource's own producer is running - where calling this + /// would re-enter that producer. Passing another resource's context is rejected below, and holding a context + /// past the callback describes a launch that already happened. A public overload would therefore promise an + /// inspection flow that no caller outside this assembly can reach; exposing it needs a supported way to build + /// a context first. + /// /// - [AspireExportIgnore(Reason = "Debug support inspection is a local .NET helper and is not part of the ATS surface.")] - public static Task CreateLaunchConfigurationAsync( + internal static Task CreateLaunchConfigurationAsync( this IResource resource, LaunchConfigurationCallbackContext context) { diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs index b55b7bde069..ecc45889233 100644 --- a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -21,7 +21,9 @@ namespace Aspire.Hosting.ApplicationModel; /// populate the underlying executable after that rewrite. When a /// pins a project executable to process execution, the debug argument rewrite is suppressed so the process command /// line remains runnable. Only the launch configuration returned by the producer is serialized for the IDE. -/// Processed environment values can contain secrets. +/// Processed arguments and environment values can both contain secrets: +/// carries an IsSensitive flag for exactly this reason, so a resolved parameter can arrive as an argument as +/// readily as an environment value. Anything a producer copies into the launch configuration is written to the IDE. /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public sealed class LaunchConfigurationCallbackContext diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 46bdd3ede42..0a1961baa05 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -217,6 +217,15 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC var mode = isProjectLaunchConfiguration ? GetProjectLaunchConfigurationMode() : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; + + // The fallback below runs the executable spec's command and args "as is", so it is only unsafe + // when the debug rewrite actually replaced them. A launch-args override suppresses that rewrite + // (applyDebugArgumentRewrite above), which leaves the spec holding the user's real command line + // -- so keying the guard on the annotation alone denied the fallback to exactly the resources + // that could still use it, and a MAUI or custom project resource whose producer threw failed + // outright instead of running. + var rewroteArgumentsForDebugging = supportsDebuggingAnnotation.RewritesArgumentsForDebugging + && !preparedFromLaunchArgsOverride; var callbackContext = new LaunchConfigurationCallbackContext( mode, er.ModelResource, @@ -238,7 +247,7 @@ await supportsDebuggingAnnotation catch (Exception exception) when ( (exception is not OperationCanceledException || !callbackContext.CancellationToken.IsCancellationRequested) && !isProjectLaunchConfiguration - && !supportsDebuggingAnnotation.RewritesArgumentsForDebugging) + && !rewroteArgumentsForDebugging) { _logger.LogWarning( exception, diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 6b8d084be0d..8a8aac43eb2 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4805,11 +4805,16 @@ public static IResourceBuilder WithDebugSupport( || producerReturnType == typeof(ValueTask) || producerReturnType.IsGenericType && producerReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) { - throw new InvalidOperationException( - $"The legacy {nameof(WithDebugSupport)} overload requires a synchronous launch configuration producer. " + + // Keep this an ArgumentException naming the parameter: main already threw that for this exact + // input, and it is the right shape for a validation failure about an argument. Only the message + // changes, because the replacement overload now takes a context and returns Task, so adding a + // CancellationToken parameter is no longer what makes an async producer bind. + throw new ArgumentException( + $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. The legacy {nameof(WithDebugSupport)} overload requires a synchronous producer. " + "Use the overload that accepts LaunchConfigurationCallbackContext and returns Task. " + "A producer that returns ValueTask or ValueTask does not bind to that overload directly; " + - "adapt it with AsTask() or wrap it in an async lambda."); + "adapt it with AsTask() or wrap it in an async lambda.", + nameof(launchConfigurationProducer)); } #pragma warning disable ASPIREEXTENSION001 // Forwarding to the replacement experimental API. @@ -4854,6 +4859,30 @@ public static IResourceBuilder WithDebugSupport( /// process command line owned by the launch-args override. /// /// + /// + /// Produce a launch configuration for a resource, reading the arguments and environment Aspire resolved for + /// this launch. A synchronous producer returns through : + /// + /// builder.AddExecutable("tool", "mytool", ".") + /// .WithDebugSupport( + /// context => Task.FromResult(new ExecutableLaunchConfiguration("mytool") + /// { + /// // OriginalExecutionConfiguration is the resolution before the argsCallback below runs, + /// // so the IDE launches the arguments the user asked for rather than the debug rewrite. + /// Args = [.. context.OriginalExecutionConfiguration.Arguments.Select(argument => argument.Processed)], + /// Env = context.OriginalExecutionConfiguration.EnvironmentVariables + /// .ToDictionary(variable => variable.Key, variable => variable.Value.Processed) + /// }), + /// launchConfigurationType: "mytool", + /// argsCallback: argsContext => + /// { + /// // Applies only to the process command line, never to the configuration above. + /// argsContext.Args.Insert(0, "--wait-for-debugger"); + /// }); + /// + /// Migrating from the obsolete overload: it received only the launch mode, so replace mode with + /// context.Mode and wrap the returned value in . + /// [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( diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs index 43d02644bc7..d7e2325fbf0 100644 --- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -893,7 +893,8 @@ private static async Task DeserializeLaunchCo var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( resource, ExecutableLaunchMode.Debug); - var json = JsonSerializer.Serialize(await resource.CreateLaunchConfigurationAsync(callbackContext)); + var json = JsonSerializer.Serialize( + await LaunchConfigurationTestHelpers.InvokeLaunchConfigurationProducerAsync(resource, callbackContext)); var launchConfiguration = JsonSerializer.Deserialize(json); Assert.NotNull(launchConfiguration); diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs index fd9a910d058..507b84b16d9 100644 --- a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -31,6 +31,28 @@ public static LaunchConfigurationCallbackContext CreateCallbackContext( cancellationToken); } + /// + /// Invokes 's launch configuration producer. + /// + /// + /// The underlying CreateLaunchConfigurationAsync overload is internal because the only legal caller is + /// the resource's own producer, so it exists for tests and for hosting integrations that ship inside this + /// repository. This wrapper lives in Aspire.Hosting.TestUtilities -- which already has + /// InternalsVisibleTo from Aspire.Hosting -- so test projects can reach it without each one + /// taking its own InternalsVisibleTo grant. Granting it directly to an integration's test project makes + /// the internal types that integration links from Aspire.Hosting (for example KnownResourceNames) + /// visible from two assemblies at once and breaks the build with CS0433. + /// + public static Task InvokeLaunchConfigurationProducerAsync( + IResource resource, + LaunchConfigurationCallbackContext callbackContext) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(callbackContext); + + return resource.CreateLaunchConfigurationAsync(callbackContext); + } + public static IExecutionConfigurationResult CreateExecutionConfigurationResult( IEnumerable? arguments = null, IEnumerable>? environmentVariables = null, diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 69b61307af1..24c554c09ea 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -5490,6 +5490,87 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); } + [Fact] + public async Task ProjectWithLaunchArgsOverrideAndRewritingNonProjectDebugSupport_LaunchConfigFailure_FallsBackToProcess() + { + // The Process fallback is denied when the debug rewrite replaced the spec's command line, because running + // the rewritten command "as is" would launch something broken. A launch-args override suppresses that + // rewrite, so the spec still holds the user's real command line and the fallback is safe -- keying the + // guard on the annotation alone denied the fallback to exactly the resources that could still use it, and + // a MAUI resource whose producer threw failed to start instead of running as a process. + var builder = DistributedApplication.CreateBuilder(); + + var projectBuilder = builder.AddProject("proj", launchProfileName: null); + var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (annotationToRemove is not null) + { + projectBuilder.Resource.Annotations.Remove(annotationToRemove); + } + +#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 + + projectBuilder + .WithArgs("run", "-f", "net10.0-android") + .WithDebugSupport( + ThrowingLaunchConfiguration, + "maui", + argsCallback: static context => context.Args.Clear()); + + var runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }; + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Add(context.Resource); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; + var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions, events: events); + + await appExecutor.RunApplicationAsync(); + + Assert.Empty(failedResources); + + var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + + // The override's command line survived the failed producer, so the fallback launches the real command. + 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, exe.Spec.Args); + + static Task ThrowingLaunchConfiguration(LaunchConfigurationCallbackContext context) + { + throw new InvalidOperationException("Launch configuration failed."); + } + } + [Fact] public async Task ProjectWithLaunchArgsOverrideAndRewritingNonProjectDebugSupport_DoesNotRewriteProcessArgs() { diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 71d6eb3c4e6..35b685672f3 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -173,19 +173,23 @@ public void WithDebugSupportLegacyModeProducerOverloadRejectsTaskReturningProduc { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var taskException = Assert.Throws(() => + // ArgumentException, not InvalidOperationException: the producer's return type is what is wrong, so the + // exception carries the parameter name and matches the shipped behaviour on the base branch. + var taskException = Assert.Throws(() => builder.AddExecutable("task", "command", "workingdirectory") .WithDebugSupport( (string mode) => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); Assert.Contains("Task", taskException.Message); + Assert.Equal("launchConfigurationProducer", taskException.ParamName); - var valueTaskException = Assert.Throws(() => + var valueTaskException = Assert.Throws(() => builder.AddExecutable("value-task", "command", "workingdirectory") .WithDebugSupport( (string mode) => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); Assert.Contains("ValueTask", valueTaskException.Message); + Assert.Equal("launchConfigurationProducer", valueTaskException.ParamName); } #pragma warning restore CS0618 From 92901b8a5b7a3b3816e38ff38ec39f206f675d3a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 20:14:29 -0400 Subject: [PATCH 22/30] Fix debug argument rewrite reuse and legacy overload validation The debug argument rewrite reused the original resolutions through a reference-keyed dictionary, so a provider instance that appeared at more than one position on the command line replayed its first position's value everywhere. Resolutions are now tracked per occurrence. The rewrite also only saw the arguments that resolved successfully, so an argument whose resolution failed was invisible to a callback that would have removed it, and its failure was retained unconditionally. The callback now receives every gathered argument, and a failure is retained only when the argument it belongs to survives into the executable. Restore the entry-point null check on builder in the obsolete WithDebugSupport overload so a null builder is reported as such rather than as a producer validation failure, fix the XML doc example so it compiles against the real public shapes, and drop the branch-relative narration from two comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../ExecutionConfigurationGathererContext.cs | 18 +- .../ExecutionConfigurationResult.cs | 70 +++++ src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 119 +++++++-- .../ResourceBuilderExtensions.cs | 29 ++- .../Dcp/DcpExecutorTests.cs | 243 ++++++++++++++++++ ...ExecutableResourceBuilderExtensionTests.cs | 27 +- 6 files changed, 469 insertions(+), 37 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs index 22cc40f4363..b49928836f3 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs @@ -41,9 +41,10 @@ internal async Task ResolveAsync( CancellationToken cancellationToken = default) { HashSet references = new(); + List argumentResolutions = new(Arguments.Count); List<(object Unprocessed, string Value, bool IsSensitive)> resolvedArguments = new(Arguments.Count); Dictionary resolvedEnvironmentVariables = new(EnvironmentVariables.Count); - List exceptions = new(); + List environmentVariableExceptions = new(); foreach (var argument in Arguments) { @@ -52,17 +53,24 @@ internal async Task ResolveAsync( var resolvedValue = await resource.ResolveValueAsync(executionContext, resourceLogger, argument, null, cancellationToken).ConfigureAwait(false); if (resolvedValue?.Value != null) { + argumentResolutions.Add(new ArgumentResolution(argument, resolvedValue.Value, resolvedValue.IsSensitive, Exception: null)); resolvedArguments.Add((argument, resolvedValue.Value, resolvedValue.IsSensitive)); if (argument is IValueProvider or IManifestExpressionProvider) { references.Add(argument); } } + else + { + // Recorded even though it contributes nothing to the command line: consumers that replay + // this resolution need one entry per gathered argument to stay aligned by occurrence. + argumentResolutions.Add(new ArgumentResolution(argument, Processed: null, IsSensitive: false, Exception: null)); + } } catch (Exception ex) { resourceLogger.LogError(ex, "Failed to resolve argument for resource '{ResourceName}'. A dependency may have failed to start.", resource.Name); - exceptions.Add(ex); + argumentResolutions.Add(new ArgumentResolution(argument, Processed: null, IsSensitive: false, ex)); } } @@ -83,7 +91,7 @@ internal async Task ResolveAsync( catch (Exception ex) { resourceLogger.LogError(ex, "Failed to resolve environment variable '{EnvironmentVariable}' for resource '{ResourceName}'. A dependency may have failed to start.", kvp.Key, resource.Name); - exceptions.Add(ex); + environmentVariableExceptions.Add(ex); } } @@ -91,9 +99,11 @@ internal async Task ResolveAsync( { References = references, ArgumentsWithUnprocessed = resolvedArguments, + ArgumentResolutions = argumentResolutions, EnvironmentVariablesWithUnprocessed = resolvedEnvironmentVariables, + EnvironmentVariableExceptions = environmentVariableExceptions, AdditionalConfigurationData = AdditionalConfigurationData, - Exception = exceptions.Count == 0 ? null : new AggregateException("One or more errors occurred while resolving resource configuration.", exceptions) + Exception = ExecutionConfigurationResult.CombineResolutionExceptions(argumentResolutions, environmentVariableExceptions) }; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs index 5b2b3c8b540..d7fe11c53e4 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs @@ -3,6 +3,16 @@ namespace Aspire.Hosting.ApplicationModel; +/// +/// The outcome of resolving a single gathered argument, recorded one entry per occurrence so callers can +/// replay a resolution instead of repeating it. +/// +/// The gathered argument, before resolution. +/// The resolved value, or when the argument resolved to null or failed. +/// Whether the resolved value is sensitive. +/// The failure that occurred while resolving, or when resolution succeeded. +internal readonly record struct ArgumentResolution(object Unprocessed, string? Processed, bool IsSensitive, Exception? Exception); + /// /// Represents the configuration (arguments and environment variables) to apply to a specific resource. /// @@ -14,12 +24,24 @@ internal sealed class ExecutionConfigurationResult : IExecutionConfigurationResu /// public required IEnumerable<(object Unprocessed, string Processed, bool IsSensitive)> ArgumentsWithUnprocessed { get; init; } + /// + /// Gets the outcome of every gathered argument, including the ones that resolved to null or failed and are + /// therefore absent from . + /// + internal IReadOnlyList ArgumentResolutions { get; init; } = []; + /// public IEnumerable<(string Value, bool IsSensitive)> Arguments => ArgumentsWithUnprocessed.Select(arg => (arg.Processed, arg.IsSensitive)); /// public required IEnumerable> EnvironmentVariablesWithUnprocessed { get; init; } + /// + /// Gets the failures that occurred while resolving environment variables, kept separate from argument + /// failures so a caller that rewrites the argument list can decide which failures still apply. + /// + internal IReadOnlyList EnvironmentVariableExceptions { get; init; } = []; + /// public IEnumerable> EnvironmentVariables => EnvironmentVariablesWithUnprocessed.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Processed)); @@ -28,4 +50,52 @@ internal sealed class ExecutionConfigurationResult : IExecutionConfigurationResu /// public Exception? Exception { get; init; } + + /// + /// Builds the aggregate failure for a resolution, ordering argument failures before environment variable + /// failures so the aggregate matches the order the values were resolved in. + /// + internal static Exception? CombineResolutionExceptions(IEnumerable argumentResolutions, IEnumerable environmentVariableExceptions) + { + List exceptions = [ + .. argumentResolutions.Select(resolution => resolution.Exception).OfType(), + .. environmentVariableExceptions]; + + return exceptions.Count == 0 + ? null + : new AggregateException("One or more errors occurred while resolving resource configuration.", exceptions); + } + + /// + /// Reads the per-occurrence argument resolutions from a result. + /// + /// + /// is public, so a result can come from an implementation that + /// records only the arguments that resolved successfully. Reconstructing the resolutions from that public + /// surface keeps such a result usable, at the cost of not knowing which arguments failed. + /// + internal static IReadOnlyList GetArgumentResolutions(IExecutionConfigurationResult result) + { + return result is ExecutionConfigurationResult { ArgumentResolutions: var resolutions } + ? resolutions + : [.. result.ArgumentsWithUnprocessed.Select(argument => new ArgumentResolution(argument.Unprocessed, argument.Processed, argument.IsSensitive, Exception: null))]; + } + + /// + /// Reads the environment variable failures from a result. + /// + /// + /// For an implementation that does not separate them, the whole failure is reported as an environment + /// variable failure. That is the conservative choice: it keeps a failure that cannot be attributed to a + /// specific argument rather than discarding it. + /// + internal static IReadOnlyList GetEnvironmentVariableExceptions(IExecutionConfigurationResult result) + { + return result switch + { + ExecutionConfigurationResult concrete => concrete.EnvironmentVariableExceptions, + { Exception: { } exception } => [exception], + _ => [] + }; + } } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index 0a1961baa05..c524632116e 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -687,9 +687,16 @@ private async Task BuildExecutableConfigurationWi // The executable snapshot is the original resolved argument list with only the active debug rewrite // applied. Do not replay ordinary WithArgs annotations here: they may have side effects, and the // original snapshot already evaluated them for this executable creation. + // + // The rewrite decides the executable's final command line, so it is handed every gathered argument, + // including any that resolved to null or threw. Those are absent from ArgumentsWithUnprocessed but + // still belong to the command line being rewritten: a callback that drops a failing argument (for + // example one that replaces the whole command line for the debugger) has to be able to see it, or + // that failure becomes unrecoverable even though nothing depends on it anymore. + var originalResolutions = ExecutionConfigurationResult.GetArgumentResolutions(originalConfiguration); activeDebugArgsAnnotation.AsCallbackAnnotation().ForgetCachedResult(); var callbackContext = new CommandLineArgsCallbackContext( - [.. originalConfiguration.ArgumentsWithUnprocessed.Select(argument => argument.Unprocessed)], + [.. originalResolutions.Select(resolution => resolution.Unprocessed)], resource, cancellationToken) { @@ -701,50 +708,114 @@ [.. originalConfiguration.ArgumentsWithUnprocessed.Select(argument => argument.U // Arguments the debug rewrite kept were already resolved into originalConfiguration for this same // executable creation. IValueProvider carries no idempotence guarantee, so resolving them a second // time can produce a different value or repeat a side effect - the same hazard that stopped ordinary - // WithArgs callbacks from being replayed above. Reuse those resolutions by reference identity and - // send only what the callback introduced or replaced through the gatherer. - var previouslyResolved = new Dictionary(ReferenceEqualityComparer.Instance); - foreach (var argument in originalConfiguration.ArgumentsWithUnprocessed) + // WithArgs callbacks from being replayed above. Reuse those resolutions and send only what the + // callback introduced through the gatherer. + // + // The lookup is keyed by reference identity but tracked per occurrence: one provider instance can sit + // at several positions on the command line, and because it is resolved once per position those + // positions can hold different values. Collapsing them to a single entry would replay the first + // position's value everywhere the instance appears. + var previousResolutions = new Dictionary>(ReferenceEqualityComparer.Instance); + foreach (var resolution in originalResolutions) { - previouslyResolved.TryAdd(argument.Unprocessed, (argument.Processed, argument.IsSensitive)); + if (!previousResolutions.TryGetValue(resolution.Unprocessed, out var resolutions)) + { + resolutions = []; + previousResolutions[resolution.Unprocessed] = resolutions; + } + + resolutions.Add(resolution); } + var reuseCounts = new Dictionary(ReferenceEqualityComparer.Instance); + var plannedArguments = new List<(object Argument, ArgumentResolution? Reused)>(rewrittenArgs.Count); var rewrittenArgsGathererContext = new ExecutionConfigurationGathererContext(); - rewrittenArgsGathererContext.Arguments.AddRange(rewrittenArgs.Where(argument => !previouslyResolved.ContainsKey(argument))); + foreach (var argument in rewrittenArgs) + { + if (previousResolutions.TryGetValue(argument, out var resolutions)) + { + var occurrence = reuseCounts.TryGetValue(argument, out var count) ? count : 0; + reuseCounts[argument] = occurrence + 1; + + // A callback that duplicates an argument produces more occurrences than were resolved, and the + // extra ones repeat the last recorded resolution rather than being resolved again. Resolving + // again is what this whole path exists to avoid. + plannedArguments.Add((argument, resolutions[Math.Min(occurrence, resolutions.Count - 1)])); + } + else + { + plannedArguments.Add((argument, null)); + rewrittenArgsGathererContext.Arguments.Add(argument); + } + } + var rewrittenArgsConfiguration = await rewrittenArgsGathererContext .ResolveAsync(resource, resourceLogger, _executionContext, cancellationToken) .ConfigureAwait(false); - // ResolveAsync drops arguments that resolve to null, so the newly resolved values are matched back by - // reference identity rather than by position, and an argument missing from both maps is dropped here - // for the same reason it would have been dropped there. - var newlyResolved = new Dictionary(ReferenceEqualityComparer.Instance); - foreach (var argument in rewrittenArgsConfiguration.ArgumentsWithUnprocessed) + // Newly resolved values are matched back by reference identity and occurrence order rather than by + // position, because the gatherer only received the arguments that had no prior resolution. + var newResolutions = new Dictionary>(ReferenceEqualityComparer.Instance); + foreach (var resolution in ExecutionConfigurationResult.GetArgumentResolutions(rewrittenArgsConfiguration)) { - newlyResolved.TryAdd(argument.Unprocessed, (argument.Processed, argument.IsSensitive)); + if (!newResolutions.TryGetValue(resolution.Unprocessed, out var queue)) + { + queue = new Queue(); + newResolutions[resolution.Unprocessed] = queue; + } + + queue.Enqueue(resolution); } var reusedReferences = new List(); + var reusedResolutions = new List(); + var retainedArgumentResolutions = new List(rewrittenArgs.Count); var argumentsWithUnprocessed = new List<(object Unprocessed, string Processed, bool IsSensitive)>(rewrittenArgs.Count); - foreach (var argument in rewrittenArgs) + foreach (var (argument, reused) in plannedArguments) { - if (newlyResolved.TryGetValue(argument, out var resolved)) + var resolution = reused; + if (resolution is null && newResolutions.TryGetValue(argument, out var queue) && queue.Count > 0) + { + resolution = queue.Dequeue(); + } + + if (resolution is not { } argumentResolution) + { + continue; + } + + retainedArgumentResolutions.Add(argumentResolution); + if (reused is not null) { - argumentsWithUnprocessed.Add((argument, resolved.Value, resolved.IsSensitive)); + reusedResolutions.Add(argumentResolution); } - else if (previouslyResolved.TryGetValue(argument, out var reused)) + + // An argument that resolved to null, or whose resolution threw, contributes nothing to the command + // line - exactly as it would have if the gatherer had resolved it here. + if (argumentResolution.Processed is not { } processed) { - argumentsWithUnprocessed.Add((argument, reused.Value, reused.IsSensitive)); + continue; + } + + argumentsWithUnprocessed.Add((argument, processed, argumentResolution.IsSensitive)); + if (reused is not null && argument is IValueProvider or IManifestExpressionProvider) + { // ResolveAsync never saw this argument, so its reference has to be contributed here or the // executable would lose the dependency edge that the original resolution recorded. - if (argument is IValueProvider or IManifestExpressionProvider) - { - reusedReferences.Add(argument); - } + reusedReferences.Add(argument); } } + // Only failures that still apply to this executable are retained. A reused argument that failed to + // resolve and that the rewrite dropped is no longer part of the command line, so keeping its failure + // would fail an executable that has nothing left to fail on. Only reused resolutions are considered + // here because failures from the gatherer above are already carried by its own result, and they always + // apply: it only ever saw arguments that survived the rewrite. Environment variables are copied from + // the original configuration untouched, so their failures always apply too. + var environmentVariableExceptions = ExecutionConfigurationResult.GetEnvironmentVariableExceptions(originalConfiguration); + var retainedOriginalException = ExecutionConfigurationResult.CombineResolutionExceptions(reusedResolutions, environmentVariableExceptions); + var environmentReferences = originalConfiguration.EnvironmentVariablesWithUnprocessed .Select(static kvp => kvp.Value.Unprocessed) .Where(static value => value is IValueProvider or IManifestExpressionProvider); @@ -753,9 +824,11 @@ [.. originalConfiguration.ArgumentsWithUnprocessed.Select(argument => argument.U { References = environmentReferences.Concat(rewrittenArgsConfiguration.References).Concat(reusedReferences).ToHashSet(), ArgumentsWithUnprocessed = argumentsWithUnprocessed, + ArgumentResolutions = retainedArgumentResolutions, EnvironmentVariablesWithUnprocessed = originalConfiguration.EnvironmentVariablesWithUnprocessed, + EnvironmentVariableExceptions = environmentVariableExceptions, AdditionalConfigurationData = originalConfiguration.AdditionalConfigurationData, - Exception = CombineExecutionConfigurationExceptions(originalConfiguration.Exception, rewrittenArgsConfiguration.Exception) + Exception = CombineExecutionConfigurationExceptions(retainedOriginalException, rewrittenArgsConfiguration.Exception) }; } diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 8a8aac43eb2..593191eb723 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4799,16 +4799,16 @@ public static IResourceBuilder WithDebugSupport( Action? argsCallback = null) where T : IResource { + ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(launchConfigurationProducer); var producerReturnType = typeof(TLaunchConfiguration); if (typeof(Task).IsAssignableFrom(producerReturnType) || producerReturnType == typeof(ValueTask) || producerReturnType.IsGenericType && producerReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) { - // Keep this an ArgumentException naming the parameter: main already threw that for this exact - // input, and it is the right shape for a validation failure about an argument. Only the message - // changes, because the replacement overload now takes a context and returns Task, so adding a - // CancellationToken parameter is no longer what makes an async producer bind. + // This overload binds an asynchronous producer as if its task were the launch configuration, so it + // rejects one instead. ArgumentException naming the parameter is the compatible shape: the invalid + // input is the producer's return type, and callers that already handle this rejection keep working. throw new ArgumentException( $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. The legacy {nameof(WithDebugSupport)} overload requires a synchronous producer. " + "Use the overload that accepts LaunchConfigurationCallbackContext and returns Task. " + @@ -4861,17 +4861,30 @@ public static IResourceBuilder WithDebugSupport( /// /// /// Produce a launch configuration for a resource, reading the arguments and environment Aspire resolved for - /// this launch. A synchronous producer returns through : + /// this launch. The launch configuration type declares whatever the IDE launcher expects, because + /// itself carries only type and mode: + /// + /// internal sealed class MyToolLaunchConfiguration() : ExecutableLaunchConfiguration("mytool") + /// { + /// [JsonPropertyName("args")] + /// public List<string> Args { get; set; } = []; + /// + /// [JsonPropertyName("env")] + /// public Dictionary<string, string> Env { get; set; } = []; + /// } + /// + /// A synchronous producer returns through : /// /// builder.AddExecutable("tool", "mytool", ".") /// .WithDebugSupport( - /// context => Task.FromResult(new ExecutableLaunchConfiguration("mytool") + /// context => Task.FromResult(new MyToolLaunchConfiguration /// { + /// Mode = context.Mode, /// // OriginalExecutionConfiguration is the resolution before the argsCallback below runs, /// // so the IDE launches the arguments the user asked for rather than the debug rewrite. - /// Args = [.. context.OriginalExecutionConfiguration.Arguments.Select(argument => argument.Processed)], + /// Args = [.. context.OriginalExecutionConfiguration.Arguments.Select(argument => argument.Value)], /// Env = context.OriginalExecutionConfiguration.EnvironmentVariables - /// .ToDictionary(variable => variable.Key, variable => variable.Value.Processed) + /// .ToDictionary(variable => variable.Key, variable => variable.Value) /// }), /// launchConfigurationType: "mytool", /// argsCallback: argsContext => diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 24c554c09ea..fa30994afd7 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -4788,6 +4788,227 @@ public async Task DebugArgumentRewriting_ReusesOriginalResolutionForArgumentsThe Assert.Equal(["--debug", "resolved-once"], exe.Spec.Args); } + [Fact] + public async Task DebugArgumentRewriting_KeepsPerOccurrenceResolutionsWhenOneProviderInstanceIsRepeated() + { + // The same IValueProvider instance can legitimately appear more than once in a command line, and + // IValueProvider carries no idempotence guarantee, so each occurrence has its own resolved value. + // Carrying the original resolutions forward has to be per occurrence: collapsing them by reference + // would replay the first occurrence's value at every position. + var builder = DistributedApplication.CreateBuilder(); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + builder.Configuration.AddInMemoryCollection(configDict); + + var repeatedArgument = new SequentialValueProvider("resolved"); + + IExecutionConfigurationResult? originalConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(context => + { + context.Args.Add(repeatedArgument); + context.Args.Add(repeatedArgument); + }) + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Insert(0, "--debug")); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + + Assert.Equal(2, repeatedArgument.ResolutionCount); + Assert.NotNull(originalConfiguration); + Assert.Equal(["resolved-1", "resolved-2"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["--debug", "resolved-1", "resolved-2"], exe.Spec.Args); + } + + [Fact] + public async Task DebugArgumentRewriting_CallbackSeesArgumentsThatFailedToResolve() + { + // The rewrite decides which arguments reach the executable, so it has to see every gathered argument - + // including one whose resolution failed. Removing that argument is what lets the executable start, so + // hiding it from the callback would make the failure unrecoverable. + var builder = DistributedApplication.CreateBuilder(); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + builder.Configuration.AddInMemoryCollection(configDict); + + var failingArgument = new ThrowingValueProvider("Argument resolution failed."); + List? observedCallbackArgs = null; + + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(context => + { + context.Args.Add("keep"); + context.Args.Add(failingArgument); + }) + .WithDebugSupport( + static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test", + argsCallback: context => + { + observedCallbackArgs = [.. context.Args]; + context.Args.Remove(failingArgument); + }); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(observedCallbackArgs); + Assert.Equal(["keep", failingArgument], observedCallbackArgs); + + var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + Assert.Equal(["keep"], exe.Spec.Args); + } + + [Fact] + public async Task DebugArgumentRewriting_RetainsResolutionFailuresForArgumentsTheCallbackKept() + { + // The mirror of the test above: an argument whose resolution failed and that the rewrite kept still + // belongs to the executable, so its failure must still stop the executable from being created. + var builder = DistributedApplication.CreateBuilder(); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + builder.Configuration.AddInMemoryCollection(configDict); + + var failingArgument = new ThrowingValueProvider("Argument resolution failed."); + + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs(context => + { + context.Args.Add("keep"); + context.Args.Add(failingArgument); + }) + .WithDebugSupport( + static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test", + argsCallback: static context => context.Args.Insert(0, "--debug")); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Add(context.Resource); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events); + + await appExecutor.RunApplicationAsync(); + + Assert.Empty(kubernetesService.CreatedResources.OfType()); + Assert.Same(debuggableExecutable, Assert.Single(failedResources)); + } + + [Fact] + public async Task DebugArgumentRewriting_RetainsResolutionFailuresForArgumentsTheCallbackIntroduced() + { + // An argument the rewrite adds has no earlier resolution, so it is resolved here for the first time. + // A failure at that point belongs to the executable just as much as one carried over from the original + // resolution does. + var builder = DistributedApplication.CreateBuilder(); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + + builder.Configuration.AddInMemoryCollection(configDict); + + var failingArgument = new ThrowingValueProvider("Argument resolution failed."); + + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("keep") + .WithDebugSupport( + static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + "test", + argsCallback: context => context.Args.Add(failingArgument)); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Add(context.Resource); + return Task.CompletedTask; + }); + + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events); + + await appExecutor.RunApplicationAsync(); + + Assert.Empty(kubernetesService.CreatedResources.OfType()); + Assert.Same(debuggableExecutable, Assert.Single(failedResources)); + } + [Fact] public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() { @@ -8523,6 +8744,28 @@ private sealed class CountingValueProvider(string value) : IValueProvider } private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory); + // Resolves to a different value on every call so a test can tell which occurrence of a repeated + // argument each entry in the final command line came from. + private sealed class SequentialValueProvider(string prefix) : IValueProvider + { + private int _resolutionCount; + + public int ResolutionCount => Volatile.Read(ref _resolutionCount); + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + var resolution = Interlocked.Increment(ref _resolutionCount); + return new ValueTask($"{prefix}-{resolution.ToString(CultureInfo.InvariantCulture)}"); + } + } + + // Models an argument whose dependency failed to start, which surfaces as a throwing resolution. + private sealed class ThrowingValueProvider(string message) : IValueProvider + { + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException(message); + } + // Models a DotnetProjectResource: a plain ExecutableResource (launches `dotnet`) that carries // IProjectMetadata and a "project" SupportsDebuggingAnnotation. Used to verify the DCP project-launch // generalization without taking a dependency on Aspire.Hosting.Dotnet. diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 35b685672f3..7282c0773d3 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -173,8 +173,8 @@ public void WithDebugSupportLegacyModeProducerOverloadRejectsTaskReturningProduc { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - // ArgumentException, not InvalidOperationException: the producer's return type is what is wrong, so the - // exception carries the parameter name and matches the shipped behaviour on the base branch. + // ArgumentException, not InvalidOperationException: the producer's return type is the invalid input, so + // the exception carries the offending parameter's name. var taskException = Assert.Throws(() => builder.AddExecutable("task", "command", "workingdirectory") .WithDebugSupport( @@ -193,6 +193,29 @@ public void WithDebugSupportLegacyModeProducerOverloadRejectsTaskReturningProduc } #pragma warning restore CS0618 + [Fact] +#pragma warning disable CS0618 // Verify the shipped overload preserves its argument validation order. + public void WithDebugSupportLegacyModeProducerOverloadValidatesBuilderFirst() + { + // The null-builder check is the entry point contract for every extension method, so it has to run + // before the producer's return type is examined. Otherwise a null builder passed with a task-returning + // producer reports the producer instead of the builder. + IResourceBuilder nullBuilder = null!; + + var taskProducerException = Assert.Throws(() => + nullBuilder.WithDebugSupport( + (string mode) => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), + "go")); + Assert.Equal("builder", taskProducerException.ParamName); + + var nullProducerException = Assert.Throws(() => + nullBuilder.WithDebugSupport( + (Func)null!, + "go")); + Assert.Equal("builder", nullProducerException.ParamName); + } +#pragma warning restore CS0618 + [Fact] public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() { From 64dd50dd3c3e054c6e84ecaad0f65390439231b8 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 05:03:03 -0400 Subject: [PATCH 23/30] Address debug launch context review feedback Expose resource-bound launch context snapshots and preserve debug argument rewrite ordering for executable launch configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- ...ArgumentsExecutionConfigurationGatherer.cs | 324 +++++++++++++- .../CommandLineArgsCallbackAnnotation.cs | 36 ++ .../DebugSupportExtensions.cs | 5 - ...ExecutionConfigurationBuilderExtensions.cs | 10 + .../ExecutionConfigurationGathererContext.cs | 1 + .../ExecutionConfigurationResult.cs | 5 + .../LaunchConfigurationCallbackContext.cs | 36 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 68 ++- .../ResourceBuilderExtensions.cs | 10 +- .../Utils/LaunchConfigurationTestHelpers.cs | 6 +- .../Dcp/DcpExecutorTests.cs | 404 +++++++++++++++++- .../DebugSupportExtensionsTests.cs | 26 ++ 12 files changed, 887 insertions(+), 44 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs index 1bd438350f2..a4bcbfc7255 100644 --- a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.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; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -11,10 +12,14 @@ namespace Aspire.Hosting.ApplicationModel; internal class ArgumentsExecutionConfigurationGatherer : IExecutionConfigurationGatherer { private readonly Func _shouldIncludeAnnotation; + private readonly DebugCommandLineArgsRewriteCapture? _debugRewriteCapture; - public ArgumentsExecutionConfigurationGatherer(Func? shouldIncludeAnnotation = null) + public ArgumentsExecutionConfigurationGatherer( + Func? shouldIncludeAnnotation = null, + DebugCommandLineArgsRewriteCapture? debugRewriteCapture = null) { _shouldIncludeAnnotation = shouldIncludeAnnotation ?? (static _ => true); + _debugRewriteCapture = debugRewriteCapture; } /// @@ -23,23 +28,68 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) { IList args = [.. context.Arguments]; + List? executableArgs = null; foreach (var ann in argumentAnnotations) { + if (_debugRewriteCapture is not null && ReferenceEquals(ann, _debugRewriteCapture.ActiveDebugArgsAnnotation)) + { + ann.AsCallbackAnnotation().ForgetCachedResult(); + var (rewrittenArgs, _) = await EvaluateAsync(ann, [.. args], resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + executableArgs = [.. rewrittenArgs]; + continue; + } + if (!_shouldIncludeAnnotation(ann)) { continue; } - var callbackContext = new CommandLineArgsCallbackContext([.. args], resource, cancellationToken) + if (executableArgs is null) { - Logger = resourceLogger, - ExecutionContext = executionContext - }; + // Each annotation receives the current arguments. This matters when an earlier + // annotation returns a cached immutable result instead of mutating the prior list. + (args, _) = await EvaluateAsync(ann, [.. args], resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + } + else + { + var mirroredArgs = new MirroredCommandLineArgs([.. args], executableArgs); + var (evaluatedArgs, callbackStarted) = await EvaluateAsync(ann, mirroredArgs, resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + args = evaluatedArgs; + if (callbackStarted) + { + executableArgs = mirroredArgs.SecondaryArguments; + ann.SetCachedMirroredSecondaryOperations(mirroredArgs.SecondaryOperations); + } + else if (ann.TryGetCachedMirroredSecondaryOperations(out var cachedMirroredSecondaryOperations)) + { + // CommandLineArgsCallbackAnnotation caches ordinary WithArgs results across replicas. + // The active debug rewrite above is intentionally fresh for each executable creation, so + // replay the later callback's mutation shape onto this executable's current debug branch + // instead of reusing the prior replica's concrete argument list. + executableArgs = [.. executableArgs]; + foreach (var operation in cachedMirroredSecondaryOperations) + { + operation.Apply(executableArgs); + } + } + else + { + // This can only happen if the annotation was cached by an earlier non-debug path. + // Re-evaluate once with the mirrored list so the executable branch does not silently + // drop the later annotation. + ann.AsCallbackAnnotation().ForgetCachedResult(); + (args, _) = await EvaluateAsync(ann, mirroredArgs, resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + executableArgs = mirroredArgs.SecondaryArguments; + ann.SetCachedMirroredSecondaryOperations(mirroredArgs.SecondaryOperations); + } + } + } - // Each annotation receives the current arguments. This matters when an earlier - // annotation returns a cached immutable result instead of mutating the prior list. - args = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); + if (_debugRewriteCapture is { } debugRewriteCapture) + { + debugRewriteCapture.OriginalArguments = [.. args]; + debugRewriteCapture.ExecutableArguments = executableArgs ?? [.. args]; } // Take the final result and apply to the gatherer context. @@ -47,4 +97,260 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex context.Arguments.AddRange(args); } } -} \ No newline at end of file + + private static async Task<(IList Args, bool CallbackStarted)> EvaluateAsync( + CommandLineArgsCallbackAnnotation annotation, + IList args, + IResource resource, + ILogger resourceLogger, + DistributedApplicationExecutionContext executionContext, + CancellationToken cancellationToken) + { + var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken) + { + Logger = resourceLogger, + ExecutionContext = executionContext + }; + + var result = await annotation.EvaluateOnceAsync(callbackContext, out var callbackStarted).ConfigureAwait(false); + return (result, callbackStarted); + } +} + +internal sealed class DebugCommandLineArgsRewriteCapture(CommandLineArgsCallbackAnnotation activeDebugArgsAnnotation) +{ + public CommandLineArgsCallbackAnnotation ActiveDebugArgsAnnotation { get; } = activeDebugArgsAnnotation; + + public IReadOnlyList OriginalArguments { get; set; } = []; + + public IReadOnlyList ExecutableArguments { get; set; } = []; +} + +internal sealed class MirroredCommandLineArgs : IList +{ + private readonly List _primaryArguments; + private readonly List _secondaryArguments = []; + private int _nextPrimaryId; + + public List SecondaryOperations { get; } = []; + + public MirroredCommandLineArgs(IList primaryArguments, IEnumerable secondaryArguments) + { + _primaryArguments = new List(primaryArguments.Count); + var primaryOccurrences = new Dictionary>(ReferenceEqualityComparer.Instance); + + foreach (var argument in primaryArguments) + { + var primaryArgument = new PrimaryArgument(_nextPrimaryId++, argument); + _primaryArguments.Add(primaryArgument); + + if (!primaryOccurrences.TryGetValue(argument, out var occurrences)) + { + occurrences = new Queue(); + primaryOccurrences[argument] = occurrences; + } + + occurrences.Enqueue(primaryArgument); + } + + foreach (var argument in secondaryArguments) + { + PrimaryArgument? primaryArgument = null; + if (primaryOccurrences.TryGetValue(argument, out var occurrences) && occurrences.Count > 0) + { + primaryArgument = occurrences.Dequeue(); + } + + _secondaryArguments.Add(new SecondaryArgument(argument, primaryArgument?.Id)); + } + } + + public List SecondaryArguments => [.. _secondaryArguments.Select(static argument => argument.Value)]; + + public object this[int index] + { + get => _primaryArguments[index].Value; + set + { + var primaryArgument = _primaryArguments[index]; + primaryArgument.Value = value; + + var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryArgument.Id); + if (secondaryIndex < 0) + { + secondaryIndex = index; + } + + if (secondaryIndex < _secondaryArguments.Count) + { + _secondaryArguments[secondaryIndex].Value = value; + } + + SecondaryOperations.Add(new SetMirroredCommandLineArgsOperation(secondaryIndex, value)); + } + } + + public int Count => _primaryArguments.Count; + + public bool IsReadOnly => false; + + public void Add(object item) + { + var primaryArgument = new PrimaryArgument(_nextPrimaryId++, item); + _primaryArguments.Add(primaryArgument); + _secondaryArguments.Add(new SecondaryArgument(item, primaryArgument.Id)); + SecondaryOperations.Add(new AddMirroredCommandLineArgsOperation(item)); + } + + public void Clear() + { + _primaryArguments.Clear(); + _secondaryArguments.Clear(); + SecondaryOperations.Add(new ClearMirroredCommandLineArgsOperation()); + } + + public bool Contains(object item) => _primaryArguments.Any(argument => EqualityComparer.Default.Equals(argument.Value, item)); + + public void CopyTo(object[] array, int arrayIndex) + { + foreach (var argument in _primaryArguments) + { + array[arrayIndex++] = argument.Value; + } + } + + public IEnumerator GetEnumerator() => _primaryArguments.Select(static argument => argument.Value).GetEnumerator(); + + public int IndexOf(object item) => _primaryArguments.FindIndex(argument => EqualityComparer.Default.Equals(argument.Value, item)); + + public void Insert(int index, object item) + { + var primaryArgument = new PrimaryArgument(_nextPrimaryId++, item); + _primaryArguments.Insert(index, primaryArgument); + + var secondaryIndex = _secondaryArguments.FindIndex(argument => + argument.PrimaryId is { } primaryId && + _primaryArguments.FindIndex(primary => primary.Id == primaryId) > index); + if (secondaryIndex < 0) + { + secondaryIndex = _secondaryArguments.Count; + } + + _secondaryArguments.Insert(secondaryIndex, new SecondaryArgument(item, primaryArgument.Id)); + SecondaryOperations.Add(new InsertMirroredCommandLineArgsOperation(secondaryIndex, item)); + } + + public bool Remove(object item) + { + var index = IndexOf(item); + if (index < 0) + { + return false; + } + + _primaryArguments.RemoveAt(index); + + var secondaryIndex = _secondaryArguments.FindIndex(argument => EqualityComparer.Default.Equals(argument.Value, item)); + if (secondaryIndex >= 0) + { + _secondaryArguments.RemoveAt(secondaryIndex); + } + + SecondaryOperations.Add(new RemoveMirroredCommandLineArgsOperation(item)); + return true; + } + + public void RemoveAt(int index) + { + var primaryId = _primaryArguments[index].Id; + _primaryArguments.RemoveAt(index); + + var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryId); + if (secondaryIndex < 0) + { + secondaryIndex = index; + } + + if (secondaryIndex < _secondaryArguments.Count) + { + _secondaryArguments.RemoveAt(secondaryIndex); + } + + SecondaryOperations.Add(new RemoveAtMirroredCommandLineArgsOperation(secondaryIndex)); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class PrimaryArgument(int id, object value) + { + public int Id { get; } = id; + + public object Value { get; set; } = value; + } + + private sealed class SecondaryArgument(object value, int? primaryId) + { + public object Value { get; set; } = value; + + public int? PrimaryId { get; } = primaryId; + } +} + +internal abstract record MirroredCommandLineArgsOperation +{ + public abstract void Apply(List args); +} + +internal sealed record AddMirroredCommandLineArgsOperation(object Item) : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + args.Add(Item); + } +} + +internal sealed record ClearMirroredCommandLineArgsOperation : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + args.Clear(); + } +} + +internal sealed record InsertMirroredCommandLineArgsOperation(int Index, object Item) : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + args.Insert(Math.Min(Index, args.Count), Item); + } +} + +internal sealed record RemoveAtMirroredCommandLineArgsOperation(int Index) : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + if (Index < args.Count) + { + args.RemoveAt(Index); + } + } +} + +internal sealed record RemoveMirroredCommandLineArgsOperation(object Item) : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + args.Remove(Item); + } +} + +internal sealed record SetMirroredCommandLineArgsOperation(int Index, object Item) : MirroredCommandLineArgsOperation +{ + public override void Apply(List args) + { + if (Index < args.Count) + { + args[Index] = Item; + } + } +} diff --git a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs index cd927e8f944..c5ade34df96 100644 --- a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.ApplicationModel; public class CommandLineArgsCallbackAnnotation : IResourceAnnotation, IArgCallbackAnnotation { private Task>? _callbackTask; + private IReadOnlyList? _mirroredSecondaryOperations; private readonly object _lock = new(); /// @@ -51,13 +52,24 @@ public CommandLineArgsCallbackAnnotation(Action> callback) internal IArgCallbackAnnotation AsCallbackAnnotation() => this; Task> IArgCallbackAnnotation.EvaluateOnceAsync(CommandLineArgsCallbackContext context) + { + return EvaluateOnceAsync(context, out _); + } + + internal Task> EvaluateOnceAsync(CommandLineArgsCallbackContext context, out bool callbackStarted) { lock(_lock) { if (_callbackTask is null) { + callbackStarted = true; _callbackTask = ExecuteCallbackAsync(context); } + else + { + callbackStarted = false; + } + return _callbackTask; } } @@ -67,6 +79,30 @@ void IArgCallbackAnnotation.ForgetCachedResult() lock(_lock) { _callbackTask = null; + _mirroredSecondaryOperations = null; + } + } + + internal void SetCachedMirroredSecondaryOperations(IReadOnlyList operations) + { + lock (_lock) + { + _mirroredSecondaryOperations = operations.ToImmutableList(); + } + } + + internal bool TryGetCachedMirroredSecondaryOperations(out IReadOnlyList operations) + { + lock (_lock) + { + if (_mirroredSecondaryOperations is { } mirroredSecondaryOperations) + { + operations = mirroredSecondaryOperations; + return true; + } + + operations = []; + return false; } } diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index 338fb200e6e..8dde1d3e726 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -115,11 +115,6 @@ internal static Task CreateLaunchConfigurationAsync( nameof(context)); } - if (context.OriginalExecutionConfiguration.Exception is { } configurationException) - { - ExceptionDispatchInfo.Throw(configurationException); - } - if (context.ExecutableExecutionConfiguration.Exception is { } executableConfigurationException) { ExceptionDispatchInfo.Throw(executableConfigurationException); diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs index f8cc4ef2427..da6016cfefb 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs @@ -30,6 +30,16 @@ internal static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecuti return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer(shouldIncludeAnnotation)); } + internal static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecutionConfigurationBuilder builder, DebugCommandLineArgsRewriteCapture debugRewriteCapture) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(debugRewriteCapture); + + return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer( + annotation => !ReferenceEquals(annotation, debugRewriteCapture.ActiveDebugArgsAnnotation), + debugRewriteCapture)); + } + /// /// Adds an environment variables configuration gatherer to the builder. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs index b49928836f3..9928cd46fb7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs @@ -97,6 +97,7 @@ internal async Task ResolveAsync( return new ExecutionConfigurationResult { + Resource = resource, References = references, ArgumentsWithUnprocessed = resolvedArguments, ArgumentResolutions = argumentResolutions, diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs index d7fe11c53e4..e02e73ab4bb 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs @@ -18,6 +18,11 @@ namespace Aspire.Hosting.ApplicationModel; /// internal sealed class ExecutionConfigurationResult : IExecutionConfigurationResult { + /// + /// Gets the resource this configuration was resolved for. + /// + internal required IResource Resource { get; init; } + /// public required IEnumerable References { get; init; } diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs index ecc45889233..d40185cf013 100644 --- a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -11,16 +11,22 @@ namespace Aspire.Hosting.ApplicationModel; /// Provides the runtime data used to create a launch configuration for a resource. /// /// -/// Aspire creates a new context when the resource's active debug-support annotation produces a launch -/// configuration for an executable creation, including restarts and replicas. The producer is not invoked -/// when the annotation is inactive, unsupported by the current debug session, or skipped because a +/// Aspire creates a new context only when the resource's active debug-support annotation produces a launch +/// configuration for a specific executable creation, restart, or replica. This is not a general resource +/// lifecycle callback: the producer is not invoked when the annotation is inactive, unsupported by the +/// current debug session, or skipped because a /// already supplied a /// launch configuration. +/// The context is framework-owned and both execution snapshots are bound to when Aspire +/// constructs it. /// contains the resolved resource configuration before an active /// debug-support argument rewrite runs. contains the copy used to /// populate the underlying executable after that rewrite. When a /// pins a project executable to process execution, the debug argument rewrite is suppressed so the process command /// line remains runnable. Only the launch configuration returned by the producer is serialized for the IDE. +/// on can include +/// argument failures that the debug rewrite removed from ; producers +/// should check it before copying values from the original snapshot. /// Processed arguments and environment values can both contain secrets: /// carries an IsSensitive flag for exactly this reason, so a resolved parameter can arrive as an argument as /// readily as an environment value. Anything a producer copies into the launch configuration is written to the IDE. @@ -43,6 +49,9 @@ internal LaunchConfigurationCallbackContext( ArgumentNullException.ThrowIfNull(executableExecutionConfiguration); ArgumentNullException.ThrowIfNull(executionContext); + ValidateExecutionConfigurationResource(resource, originalExecutionConfiguration, nameof(originalExecutionConfiguration)); + ValidateExecutionConfigurationResource(resource, executableExecutionConfiguration, nameof(executableExecutionConfiguration)); + Mode = mode; Resource = resource; OriginalExecutionConfiguration = originalExecutionConfiguration; @@ -95,4 +104,25 @@ internal LaunchConfigurationCallbackContext( /// Gets the cancellation token for this executable creation. /// public CancellationToken CancellationToken { get; } + + private static void ValidateExecutionConfigurationResource( + IResource resource, + IExecutionConfigurationResult executionConfiguration, + string parameterName) + { + if (executionConfiguration is not ExecutionConfigurationResult { Resource: var configurationResource }) + { + throw new ArgumentException( + $"The launch configuration callback context for resource '{resource.Name}' requires an execution configuration resolved by Aspire for that resource.", + parameterName); + } + + if (!ReferenceEquals(resource, configurationResource)) + { + throw new ArgumentException( + $"The execution configuration belongs to resource '{configurationResource.Name}', " + + $"but the launch configuration callback context is being created for resource '{resource.Name}'.", + parameterName); + } + } } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index c524632116e..f66afef70e0 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -544,11 +544,17 @@ private async Task BuildExecutableConfiguration( var activeDebugArgsAnnotation = supportsDebuggingAnnotation?.DebugCommandLineArgsCallbackAnnotation; var shouldBuildDebugArguments = activeDebugArgsAnnotation is not null && applyDebugArgumentRewrite; + DebugCommandLineArgsRewriteCapture? debugRewriteCapture = null; var configurationBuilder = ExecutionConfigurationBuilder.Create(er.ModelResource); if (activeDebugArgsAnnotation is null) { configurationBuilder.WithArgumentsConfig(); } + else if (shouldBuildDebugArguments) + { + debugRewriteCapture = new DebugCommandLineArgsRewriteCapture(activeDebugArgsAnnotation); + configurationBuilder.WithArgumentsConfig(debugRewriteCapture); + } else { configurationBuilder.WithArgumentsConfig(annotation => !ReferenceEquals(annotation, activeDebugArgsAnnotation)); @@ -596,7 +602,8 @@ private async Task BuildExecutableConfiguration( var executableConfiguration = shouldBuildDebugArguments ? await BuildExecutableConfigurationWithDebugArgumentsAsync( originalConfiguration, - activeDebugArgsAnnotation!, + debugRewriteCapture!.OriginalArguments, + debugRewriteCapture!.ExecutableArguments, er.ModelResource, resourceLogger, cancellationToken) @@ -679,31 +686,18 @@ private async Task BuildExecutableConfiguration( private async Task BuildExecutableConfigurationWithDebugArgumentsAsync( IExecutionConfigurationResult originalConfiguration, - CommandLineArgsCallbackAnnotation activeDebugArgsAnnotation, + IReadOnlyList originalArgsBeforePostArgumentGatherers, + IReadOnlyList rewrittenArgs, IResource resource, ILogger resourceLogger, CancellationToken cancellationToken) { - // The executable snapshot is the original resolved argument list with only the active debug rewrite - // applied. Do not replay ordinary WithArgs annotations here: they may have side effects, and the - // original snapshot already evaluated them for this executable creation. - // - // The rewrite decides the executable's final command line, so it is handed every gathered argument, - // including any that resolved to null or threw. Those are absent from ArgumentsWithUnprocessed but - // still belong to the command line being rewritten: a callback that drops a failing argument (for - // example one that replaces the whole command line for the debugger) has to be able to see it, or - // that failure becomes unrecoverable even though nothing depends on it anymore. + // The executable snapshot is gathered in the same pass as the original configuration so the active + // debug rewrite keeps its registration-order position without replaying ordinary WithArgs callbacks. + // It still contains unprocessed arguments here; reusing the matching resolutions below avoids + // re-resolving value providers that already ran for this executable creation. var originalResolutions = ExecutionConfigurationResult.GetArgumentResolutions(originalConfiguration); - activeDebugArgsAnnotation.AsCallbackAnnotation().ForgetCachedResult(); - var callbackContext = new CommandLineArgsCallbackContext( - [.. originalResolutions.Select(resolution => resolution.Unprocessed)], - resource, - cancellationToken) - { - Logger = resourceLogger, - ExecutionContext = _executionContext - }; - var rewrittenArgs = await activeDebugArgsAnnotation.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); + rewrittenArgs = AppendArgumentsAddedByLaterGatherers(originalArgsBeforePostArgumentGatherers, rewrittenArgs, originalResolutions); // Arguments the debug rewrite kept were already resolved into originalConfiguration for this same // executable creation. IValueProvider carries no idempotence guarantee, so resolving them a second @@ -822,6 +816,7 @@ [.. originalResolutions.Select(resolution => resolution.Unprocessed)], return new ExecutionConfigurationResult { + Resource = resource, References = environmentReferences.Concat(rewrittenArgsConfiguration.References).Concat(reusedReferences).ToHashSet(), ArgumentsWithUnprocessed = argumentsWithUnprocessed, ArgumentResolutions = retainedArgumentResolutions, @@ -832,6 +827,37 @@ [.. originalResolutions.Select(resolution => resolution.Unprocessed)], }; } + private static IReadOnlyList AppendArgumentsAddedByLaterGatherers( + IReadOnlyList originalArgsBeforePostArgumentGatherers, + IReadOnlyList rewrittenArgs, + IReadOnlyList finalOriginalResolutions) + { + var finalOriginalArgs = finalOriginalResolutions.Select(static resolution => resolution.Unprocessed).ToArray(); + if (finalOriginalArgs.Length < originalArgsBeforePostArgumentGatherers.Count) + { + return rewrittenArgs; + } + + for (var i = 0; i < originalArgsBeforePostArgumentGatherers.Count; i++) + { + if (!ReferenceEquals(finalOriginalArgs[i], originalArgsBeforePostArgumentGatherers[i])) + { + // Later execution-configuration gatherers normally append arguments (for example TLS flags + // added by language integrations). If a custom gatherer reordered or removed the ordinary + // argument branch, we cannot safely infer how that mutation should apply to the debug branch + // without re-running callbacks, so keep the debug branch as captured. + return rewrittenArgs; + } + } + + if (finalOriginalArgs.Length == originalArgsBeforePostArgumentGatherers.Count) + { + return rewrittenArgs; + } + + return [.. rewrittenArgs, .. finalOriginalArgs.Skip(originalArgsBeforePostArgumentGatherers.Count)]; + } + private static Exception? CombineExecutionConfigurationExceptions(Exception? originalException, Exception? rewrittenArgsException) { return (originalException, rewrittenArgsException) switch diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 593191eb723..77b4577d030 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4779,7 +4779,9 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// /// Registering debug support is synchronous. Aspire invokes /// later only for executable creations where this debug-support annotation is active for the current debug - /// session, including restarts and replicas. + /// session, including restarts and replicas. This is not a general resource lifecycle callback: it does not + /// run for process launches, unsupported debug sessions, publish mode, or inactive annotations superseded by + /// a later . /// /// A that already supplies a /// launch configuration skips the producer for that @@ -4850,7 +4852,9 @@ public static IResourceBuilder WithDebugSupport( /// Registering debug support is synchronous. Aspire invokes /// later only for executable creations where this debug-support annotation is active for the current debug /// session, including restarts and replicas. A producer that completes synchronously should return its result - /// with . + /// with . This is not a general resource lifecycle callback: + /// it does not run for process launches, unsupported debug sessions, publish mode, or inactive annotations + /// superseded by a later . /// /// A that already supplies a /// launch configuration skips the producer for that @@ -4889,7 +4893,7 @@ public static IResourceBuilder WithDebugSupport( /// launchConfigurationType: "mytool", /// argsCallback: argsContext => /// { - /// // Applies only to the process command line, never to the configuration above. + /// // Applies only to the executable arguments, never to the original configuration above. /// argsContext.Args.Insert(0, "--wait-for-debugger"); /// }); /// diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs index 507b84b16d9..258ba3d2b98 100644 --- a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -20,7 +20,7 @@ public static LaunchConfigurationCallbackContext CreateCallbackContext( { ArgumentNullException.ThrowIfNull(resource); - executionConfiguration ??= CreateExecutionConfigurationResult(); + executionConfiguration ??= CreateExecutionConfigurationResult(resource); return new LaunchConfigurationCallbackContext( mode, resource, @@ -54,12 +54,16 @@ public static Task InvokeLaunchConfigurationProducerAsync( } public static IExecutionConfigurationResult CreateExecutionConfigurationResult( + IResource resource, IEnumerable? arguments = null, IEnumerable>? environmentVariables = null, Exception? exception = null) { + ArgumentNullException.ThrowIfNull(resource); + return new ExecutionConfigurationResult { + Resource = resource, References = [], ArgumentsWithUnprocessed = (arguments ?? []) .Select(value => ((object)value, value, false)) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index fa30994afd7..990fdc85f73 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -4322,7 +4322,7 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() using var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); using var aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store"); - using var certificate = CreateTestCertificate(); + using var certificate = CreateTestCertificateWithPrivateKey(); var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") .WithCertificate(certificate); @@ -4374,7 +4374,7 @@ public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownD Assert.NotEmpty(expectedWellKnownCertificateDirectories); var builder = DistributedApplication.CreateBuilder(); - using var certificate = CreateTestCertificate(); + using var certificate = CreateTestCertificateWithPrivateKey(); var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") .WithCertificate(certificate); @@ -7023,6 +7023,394 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Produc Assert.Equal(["user-arg"], exe.Spec.Args); } + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_PreservesRegistrationOrderForLaterArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var laterArgsCallbackCalls = 0; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Clear()) + .WithArgs(context => + { + laterArgsCallbackCalls++; + context.Args.Add("user"); + }); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.NotSame(originalConfiguration, executableConfiguration); + Assert.Equal(1, laterArgsCallbackCalls); + Assert.Equal(["launcher", "user"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["user"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["user"], exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_AppliesLaterIndexedMutationsToExecutableBranch() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher", "app") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.RemoveAt(0)) + .WithArgs(static context => context.Args.RemoveAt(0)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["app"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Empty(executableConfiguration.Arguments); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Null(exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_MapsLaterIndexedMutationToSurvivingArgument() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher", "app") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Insert(0, "debug")) + .WithArgs(static context => context.Args[1] = "app2"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["launcher", "app2"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["debug", "launcher", "app2"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["debug", "launcher", "app2"], exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_AppendsLaterInsertAtCountAfterDebugPrefix() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher", "app") + .WithDebugSupport( + context => + { + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Insert(0, "debug")) + .WithArgs(static context => context.Args.Insert(context.Args.Count, "tail")); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(executableConfiguration); + Assert.Equal(["debug", "launcher", "app", "tail"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["debug", "launcher", "app", "tail"], exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_PreservesArgumentsAddedByLaterGatherers() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + using var certificate = CreateTestCertificateWithPrivateKey(); + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Clear()) + .WithAnnotation(new HttpsCertificateAnnotation { Certificate = certificate }) + .WithAnnotation(new HttpsCertificateConfigurationCallbackAnnotation(static context => + { + context.Arguments.Add("post-debug"); + return Task.CompletedTask; + })); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["launcher", "post-debug"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["post-debug"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["post-debug"], exe.Spec.Args); + } + + [Fact] + public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_PreservesLaterArgsWhenCallbackResultIsCached() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + var launchContexts = new ConcurrentQueue(); + var projectBuilder = builder.AddProject("ServiceA") + .WithReplicas(2); + var defaultAnnotation = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (defaultAnnotation is not null) + { + projectBuilder.Resource.Annotations.Remove(defaultAnnotation); + } + + projectBuilder + .WithArgs("launcher") + .WithDebugSupport( + context => + { + launchContexts.Enqueue(context); + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => context.Args.Clear()) + .WithArgs("user"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var executables = GetCreatedExecutablesForResource(kubernetesService, projectBuilder.Resource.Name); + Assert.Equal(2, executables.Count); + + var contexts = launchContexts.ToArray(); + Assert.Equal(2, contexts.Length); + Assert.All(contexts, context => Assert.Equal(["user"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value))); + } + + [Fact] + public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_ReplaysCachedLaterArgsAgainstFreshDebugRewrite() + { + var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions + { + AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName + }); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + var launchContexts = new ConcurrentQueue(); + var projectBuilder = builder.AddProject("ServiceA") + .WithReplicas(2); + var defaultAnnotation = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (defaultAnnotation is not null) + { + projectBuilder.Resource.Annotations.Remove(defaultAnnotation); + } + + var debugRewriteCallCount = 0; + projectBuilder + .WithArgs("launcher") + .WithDebugSupport( + context => + { + launchContexts.Enqueue(context); + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: context => context.Args.Add($"debug-{Interlocked.Increment(ref debugRewriteCallCount)}")) + .WithArgs("tail"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var contexts = launchContexts.ToArray(); + Assert.Equal(2, contexts.Length); + Assert.Collection( + contexts, + context => Assert.Equal(["launcher", "debug-1", "tail"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value)), + context => Assert.Equal(["launcher", "debug-2", "tail"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value))); + } + [Fact] public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OrdinaryArgsCallbacksRunOnce() { @@ -8726,6 +9114,18 @@ private static X509Certificate2 CreateTestCertificate() serialNumber); } + private static X509Certificate2 CreateTestCertificateWithPrivateKey() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + new X500DistinguishedName("CN=test"), + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + } + private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory); // Counts resolutions so a test can prove an argument was resolved exactly once across the original diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index f359f7f0866..b72ccfaee47 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -237,6 +237,7 @@ public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluati }, "go"); var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + executable.Resource, environmentVariables: [new("EXPECTED", "./cmd/api")]); var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( executable.Resource, @@ -272,6 +273,30 @@ public async Task CreateLaunchConfigurationRejectsAContextForAnotherResource() Assert.Contains("app", exception.Message); } + [Fact] + public void LaunchConfigurationCallbackContextRejectsExecutionConfigurationForAnotherResource() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var executable = builder.AddExecutable("app", "go", ".") + .WithDebugSupport( + static (LaunchConfigurationCallbackContext context) => + Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), + "go"); + var other = builder.AddExecutable("other", "go", "."); + var otherExecutionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + other.Resource, + arguments: ["secret-from-other"]); + + var exception = Assert.Throws(() => + LaunchConfigurationTestHelpers.CreateCallbackContext( + executable.Resource, + executionConfiguration: otherExecutionConfiguration)); + + Assert.Equal("originalExecutionConfiguration", exception.ParamName); + Assert.Contains("other", exception.Message); + Assert.Contains("app", exception.Message); + } + [Fact] public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration() { @@ -287,6 +312,7 @@ public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration( "go"); var expectedException = new InvalidOperationException("configuration failed"); var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( + executable.Resource, exception: expectedException); var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( executable.Resource, From a904ac7a775e3e4af5d2f8fd1b5d01cf09f98bbf Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 05:21:53 -0400 Subject: [PATCH 24/30] Prevent later arg mutations from stripping debug-injected arguments MirroredCommandLineArgs maps the original and executable argument branches by identity. When the debug rewrite replaced or dropped an argument there is no counterpart, and the indexer/RemoveAt fallback addressed the secondary list by the raw primary index instead. Because the two branches are offset by exactly the entries the rewrite introduced, that fallback could overwrite or delete a debug-injected argument. Example: a rewrite that does Insert(0, "debug") then Args[1] = "launcher2", followed by .WithArgs(c => c.Args[0] = "x"), produced executable args ["x", "launcher2", "app"] - the "debug" token required to launch under the debugger was silently gone, and the recorded operation replayed the same corruption onto every later replica. The positional fallback is kept so ordinary registration-order semantics still apply, but it now refuses to address a secondary entry the rewrite introduced (PrimaryId is null). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- ...ArgumentsExecutionConfigurationGatherer.cs | 27 +++-- .../Dcp/DcpExecutorTests.cs | 114 ++++++++++++++++++ 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs index a4bcbfc7255..44179783faf 100644 --- a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs @@ -175,16 +175,23 @@ public object this[int index] var primaryArgument = _primaryArguments[index]; primaryArgument.Value = value; + // Prefer the identity mapping built in the constructor. When the debug rewrite replaced or + // dropped this argument there is no counterpart, so fall back to the positional slot to keep + // ordinary registration-order semantics. That fallback must never address an entry the debug + // rewrite introduced (PrimaryId is null), because the two branches are offset by exactly those + // entries and writing through would silently strip a debugger token. var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryArgument.Id); if (secondaryIndex < 0) { + if (index >= _secondaryArguments.Count || _secondaryArguments[index].PrimaryId is null) + { + return; + } + secondaryIndex = index; } - if (secondaryIndex < _secondaryArguments.Count) - { - _secondaryArguments[secondaryIndex].Value = value; - } + _secondaryArguments[secondaryIndex].Value = value; SecondaryOperations.Add(new SetMirroredCommandLineArgsOperation(secondaryIndex, value)); } @@ -265,16 +272,20 @@ public void RemoveAt(int index) var primaryId = _primaryArguments[index].Id; _primaryArguments.RemoveAt(index); + // Same identity-then-position rule as the indexer: the positional fallback must not delete an + // argument the debug rewrite introduced. var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryId); if (secondaryIndex < 0) { + if (index >= _secondaryArguments.Count || _secondaryArguments[index].PrimaryId is null) + { + return; + } + secondaryIndex = index; } - if (secondaryIndex < _secondaryArguments.Count) - { - _secondaryArguments.RemoveAt(secondaryIndex); - } + _secondaryArguments.RemoveAt(secondaryIndex); SecondaryOperations.Add(new RemoveAtMirroredCommandLineArgsOperation(secondaryIndex)); } diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 990fdc85f73..33906346f12 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -7186,6 +7186,120 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_MapsLa Assert.Equal(["debug", "launcher", "app2"], exe.Spec.Args); } + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_DoesNotClobberRewrittenArgumentOnLaterIndexedMutation() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher", "app") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + // Replaces "launcher" rather than only inserting around it, so that argument has no + // counterpart in the executable branch for the later callback to address. + argsCallback: static context => + { + context.Args.Insert(0, "debug"); + context.Args[1] = "launcher2"; + }) + .WithArgs(static context => context.Args[0] = "x"); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["x", "app"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["debug", "launcher2", "app"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["debug", "launcher2", "app"], exe.Spec.Args); + } + + [Fact] + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_DoesNotRemoveRewrittenArgumentOnLaterRemoveAt() + { + var builder = DistributedApplication.CreateBuilder(); + var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); + builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; + builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; + builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; + builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + + IExecutionConfigurationResult? originalConfiguration = null; + IExecutionConfigurationResult? executableConfiguration = null; + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable) + .WithArgs("launcher", "app") + .WithDebugSupport( + context => + { + originalConfiguration = context.OriginalExecutionConfiguration; + executableConfiguration = context.ExecutableExecutionConfiguration; + return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); + }, + "test", + argsCallback: static context => + { + context.Args.Insert(0, "debug"); + context.Args[1] = "launcher2"; + }) + .WithArgs(static context => context.Args.RemoveAt(0)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = "Debug" + }) + .Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + Assert.NotNull(originalConfiguration); + Assert.NotNull(executableConfiguration); + Assert.Equal(["app"], originalConfiguration.Arguments.Select(argument => argument.Value)); + Assert.Equal(["debug", "launcher2", "app"], executableConfiguration.Arguments.Select(argument => argument.Value)); + + var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); + Assert.Equal(["debug", "launcher2", "app"], exe.Spec.Args); + } + [Fact] public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_AppendsLaterInsertAtCountAfterDebugPrefix() { From 30d272cb8a3e4ba0d31112f83a79caeb9be91481 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 12:13:12 -0400 Subject: [PATCH 25/30] Narrow debug launch context to resolved environment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- .../AppHost.cs | 6 +- ...AzureFunctionsProjectResourceExtensions.cs | 8 +- src/Aspire.Hosting.Go/GoHostingExtensions.cs | 8 +- .../JavaScriptHostingExtensions.cs | 24 +- src/Aspire.Hosting.Maui/MauiPlatformHelper.cs | 24 +- .../PythonAppResourceBuilderExtensions.cs | 8 +- ...ArgumentsExecutionConfigurationGatherer.cs | 347 +-- .../CommandLineArgsCallbackAnnotation.cs | 36 - .../DebugSupportExtensions.cs | 12 +- .../ExecutableLaunchConfiguration.cs | 6 +- ...ExecutionConfigurationBuilderExtensions.cs | 18 - .../ExecutionConfigurationGathererContext.cs | 19 +- .../ExecutionConfigurationResult.cs | 75 - .../LaunchConfigurationCallbackContext.cs | 92 +- src/Aspire.Hosting/Dcp/ExecutableCreator.cs | 324 +-- .../ProjectResourceBuilderExtensions.cs | 3 +- .../ResourceBuilderExtensions.cs | 160 +- .../SupportsDebuggingAnnotation.cs | 54 +- .../DotnetProjectResourceTests.cs | 9 +- .../Utils/LaunchConfigurationTestHelpers.cs | 50 +- .../Dcp/DcpExecutorTests.cs | 2225 +++-------------- .../Dcp/RecordingDcpObjectFactory.cs | 51 - .../DebugSupportExtensionsTests.cs | 242 +- ...ExecutableResourceBuilderExtensionTests.cs | 158 +- 24 files changed, 541 insertions(+), 3418 deletions(-) delete mode 100644 tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs diff --git a/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs b/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs index 362243130b8..59c3a5fcaca 100644 --- a/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs +++ b/playground/ProjectResourceExtensions/ProjectResourceExtensions.AppHost/AppHost.cs @@ -33,11 +33,7 @@ // exactly as Aspire.Hosting.Azure.Functions does with "azure-functions". builder.AddProject("custom-debug-service") .WithDebugSupport( - context => Task.FromResult(new CustomLaunchConfiguration - { - Mode = context.Mode, - ProjectPath = "CustomDebugService" - }), + mode => new CustomLaunchConfiguration { Mode = mode, ProjectPath = "CustomDebugService" }, "custom-debug-type"); builder.Build().Run(); diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs index 140774779cd..810a5148b94 100644 --- a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs @@ -190,13 +190,7 @@ private static IResourceBuilder AddAzureFunctions .WithIconName("Flash") .WithAnnotation(projectMetadata) .WithAnnotation(new AzureFunctionsAnnotation()) - .WithDebugSupport( - context => Task.FromResult(new AzureFunctionsLaunchConfiguration - { - ProjectPath = projectMetadata.ProjectPath, - Mode = context.Mode - }), - "azure-functions"); + .WithDebugSupport(mode => new AzureFunctionsLaunchConfiguration { ProjectPath = projectMetadata.ProjectPath, Mode = mode }, "azure-functions"); #pragma warning restore ASPIREEXTENSION001 // Only validate Azure Functions Core Tools in run mode (not during publish) diff --git a/src/Aspire.Hosting.Go/GoHostingExtensions.cs b/src/Aspire.Hosting.Go/GoHostingExtensions.cs index 2e9900034c6..e1d298d7f1e 100644 --- a/src/Aspire.Hosting.Go/GoHostingExtensions.cs +++ b/src/Aspire.Hosting.Go/GoHostingExtensions.cs @@ -757,7 +757,7 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder var resource = builder.Resource; return builder.WithDebugSupport( - context => + mode => { // Resolve annotations when DCP creates the launch configuration so later // resource mutations such as WithWorkingDirectory(...) are reflected. @@ -767,13 +767,13 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder : "."; var buildFlags = BuildFlagsString(resource); - return Task.FromResult(new GoLaunchConfiguration + return new GoLaunchConfiguration { Program = Path.GetFullPath(packagePath, workingDirectory), - Mode = context.Mode, + Mode = mode, WorkingDirectory = workingDirectory, BuildFlags = buildFlags.Length > 0 ? buildFlags : null - }); + }; }, "go", static ctx => diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index d8709bfbbe3..b9552eb7ed5 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -2161,21 +2161,21 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder var workingDirectory = Path.GetFullPath(resource.WorkingDirectory); return builder.WithDebugSupport( - context => + mode => { // Compute at run time so the launch config reflects the final annotation state var hasRunScript = resource.TryGetLastAnnotation(out _); var hasPackageManager = resource.TryGetLastAnnotation(out var pmAnnotation); var isPackageManagerScript = hasRunScript && hasPackageManager; - return Task.FromResult(new JavaScriptLaunchConfiguration(launchConfigType) + return new JavaScriptLaunchConfiguration(launchConfigType) { ScriptPath = Path.GetFullPath(scriptPath, workingDirectory), - Mode = context.Mode, + Mode = mode, RuntimeExecutable = isPackageManagerScript ? pmAnnotation!.ExecutableName : launchConfigType, LaunchMethod = isPackageManagerScript ? JavaScriptLaunchConfiguration.LaunchMethodPackageManager : JavaScriptLaunchConfiguration.LaunchMethodDirect, WorkingDirectory = workingDirectory - }); + }; }, launchConfigType); } @@ -2196,7 +2196,7 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder } return builder.WithDebugSupport( - context => + mode => { // Fall back to "npm" (the default for these frameworks) if no package manager annotation is present. var packageManager = "npm"; @@ -2205,14 +2205,14 @@ internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder packageManager = pmAnnotation.ExecutableName; } - return Task.FromResult(new JavaScriptLaunchConfiguration("node") + return new JavaScriptLaunchConfiguration("node") { ScriptPath = string.Empty, - Mode = context.Mode, + Mode = mode, RuntimeExecutable = packageManager, LaunchMethod = JavaScriptLaunchConfiguration.LaunchMethodPackageManager, WorkingDirectory = workingDirectory - }); + }; }, "node"); } @@ -2265,7 +2265,7 @@ public static IResourceBuilder WithBrowserDebugger( .WaitFor(builder) .ExcludeFromManifest() .WithDebugSupport( - context => + mode => { // Resolve endpoint at run time so dynamically added endpoints are reflected EndpointAnnotation? endpointAnnotation = null; @@ -2283,13 +2283,13 @@ public static IResourceBuilder WithBrowserDebugger( var endpointReference = parentResource.GetEndpoint(endpointAnnotation.Name); - return Task.FromResult(new BrowserLaunchConfiguration + return new BrowserLaunchConfiguration { - Mode = context.Mode, + Mode = mode, Url = endpointReference.Url, WebRoot = parentResource.WorkingDirectory, Browser = browser - }); + }; }, BrowserCapability); diff --git a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs index 1172051dfd1..bd4dc588126 100644 --- a/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs +++ b/src/Aspire.Hosting.Maui/MauiPlatformHelper.cs @@ -29,19 +29,17 @@ internal static IResourceBuilder WithMauiIdeLaunchConfiguration( Dictionary? msBuildProperties = null) where T : ProjectResource { #pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental - return resourceBuilder.WithDebugSupport( - context => Task.FromResult(new MauiLaunchConfiguration - { - Mode = context.Mode, - ProjectPath = projectPath, - TargetFramework = targetFramework, - Platform = platform, - TargetKind = targetKind, - Device = device, - RuntimeIdentifier = runtimeIdentifier, - MsBuildProperties = msBuildProperties - }), - MauiLaunchConfigurationType); + return resourceBuilder.WithDebugSupport(mode => new MauiLaunchConfiguration + { + Mode = mode, + ProjectPath = projectPath, + TargetFramework = targetFramework, + Platform = platform, + TargetKind = targetKind, + Device = device, + RuntimeIdentifier = runtimeIdentifier, + MsBuildProperties = msBuildProperties + }, MauiLaunchConfigurationType); #pragma warning restore ASPIREEXTENSION001 } diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index fa839294c8f..949ea8b8e0e 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -939,7 +939,7 @@ public static IResourceBuilder WithDebugging( var entrypoint = entrypointAnnotation.Entrypoint; builder.WithDebugSupport( - context => + mode => { // Compute paths inside the lambda so a later WithWorkingDirectory(...) override is respected. var workingDirectory = builder.Resource.WorkingDirectory; @@ -984,14 +984,14 @@ public static IResourceBuilder WithDebugging( } } - return Task.FromResult(new PythonLaunchConfiguration + return new PythonLaunchConfiguration { ProgramPath = programPath, Module = module, - Mode = context.Mode, + Mode = mode, InterpreterPath = interpreterPath, WorkingDirectory = workingDirectory - }); + }; }, "python", static ctx => diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs index 44179783faf..16c901712c3 100644 --- a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.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; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -11,85 +10,22 @@ namespace Aspire.Hosting.ApplicationModel; /// internal class ArgumentsExecutionConfigurationGatherer : IExecutionConfigurationGatherer { - private readonly Func _shouldIncludeAnnotation; - private readonly DebugCommandLineArgsRewriteCapture? _debugRewriteCapture; - - public ArgumentsExecutionConfigurationGatherer( - Func? shouldIncludeAnnotation = null, - DebugCommandLineArgsRewriteCapture? debugRewriteCapture = null) - { - _shouldIncludeAnnotation = shouldIncludeAnnotation ?? (static _ => true); - _debugRewriteCapture = debugRewriteCapture; - } - /// public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) { if (resource.TryGetAnnotationsOfType(out var argumentAnnotations)) { IList args = [.. context.Arguments]; - List? executableArgs = null; - - foreach (var ann in argumentAnnotations) + var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken) { - if (_debugRewriteCapture is not null && ReferenceEquals(ann, _debugRewriteCapture.ActiveDebugArgsAnnotation)) - { - ann.AsCallbackAnnotation().ForgetCachedResult(); - var (rewrittenArgs, _) = await EvaluateAsync(ann, [.. args], resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); - executableArgs = [.. rewrittenArgs]; - continue; - } - - if (!_shouldIncludeAnnotation(ann)) - { - continue; - } - - if (executableArgs is null) - { - // Each annotation receives the current arguments. This matters when an earlier - // annotation returns a cached immutable result instead of mutating the prior list. - (args, _) = await EvaluateAsync(ann, [.. args], resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); - } - else - { - var mirroredArgs = new MirroredCommandLineArgs([.. args], executableArgs); - var (evaluatedArgs, callbackStarted) = await EvaluateAsync(ann, mirroredArgs, resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); - args = evaluatedArgs; - if (callbackStarted) - { - executableArgs = mirroredArgs.SecondaryArguments; - ann.SetCachedMirroredSecondaryOperations(mirroredArgs.SecondaryOperations); - } - else if (ann.TryGetCachedMirroredSecondaryOperations(out var cachedMirroredSecondaryOperations)) - { - // CommandLineArgsCallbackAnnotation caches ordinary WithArgs results across replicas. - // The active debug rewrite above is intentionally fresh for each executable creation, so - // replay the later callback's mutation shape onto this executable's current debug branch - // instead of reusing the prior replica's concrete argument list. - executableArgs = [.. executableArgs]; - foreach (var operation in cachedMirroredSecondaryOperations) - { - operation.Apply(executableArgs); - } - } - else - { - // This can only happen if the annotation was cached by an earlier non-debug path. - // Re-evaluate once with the mirrored list so the executable branch does not silently - // drop the later annotation. - ann.AsCallbackAnnotation().ForgetCachedResult(); - (args, _) = await EvaluateAsync(ann, mirroredArgs, resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); - executableArgs = mirroredArgs.SecondaryArguments; - ann.SetCachedMirroredSecondaryOperations(mirroredArgs.SecondaryOperations); - } - } - } + Logger = resourceLogger, + ExecutionContext = executionContext + }; - if (_debugRewriteCapture is { } debugRewriteCapture) + foreach (var ann in argumentAnnotations) { - debugRewriteCapture.OriginalArguments = [.. args]; - debugRewriteCapture.ExecutableArguments = executableArgs ?? [.. args]; + // Each annotation operates on a shared context. + args = await ann.AsCallbackAnnotation().EvaluateOnceAsync(callbackContext).ConfigureAwait(false); } // Take the final result and apply to the gatherer context. @@ -97,271 +33,4 @@ public async ValueTask GatherAsync(IExecutionConfigurationGathererContext contex context.Arguments.AddRange(args); } } - - private static async Task<(IList Args, bool CallbackStarted)> EvaluateAsync( - CommandLineArgsCallbackAnnotation annotation, - IList args, - IResource resource, - ILogger resourceLogger, - DistributedApplicationExecutionContext executionContext, - CancellationToken cancellationToken) - { - var callbackContext = new CommandLineArgsCallbackContext(args, resource, cancellationToken) - { - Logger = resourceLogger, - ExecutionContext = executionContext - }; - - var result = await annotation.EvaluateOnceAsync(callbackContext, out var callbackStarted).ConfigureAwait(false); - return (result, callbackStarted); - } -} - -internal sealed class DebugCommandLineArgsRewriteCapture(CommandLineArgsCallbackAnnotation activeDebugArgsAnnotation) -{ - public CommandLineArgsCallbackAnnotation ActiveDebugArgsAnnotation { get; } = activeDebugArgsAnnotation; - - public IReadOnlyList OriginalArguments { get; set; } = []; - - public IReadOnlyList ExecutableArguments { get; set; } = []; -} - -internal sealed class MirroredCommandLineArgs : IList -{ - private readonly List _primaryArguments; - private readonly List _secondaryArguments = []; - private int _nextPrimaryId; - - public List SecondaryOperations { get; } = []; - - public MirroredCommandLineArgs(IList primaryArguments, IEnumerable secondaryArguments) - { - _primaryArguments = new List(primaryArguments.Count); - var primaryOccurrences = new Dictionary>(ReferenceEqualityComparer.Instance); - - foreach (var argument in primaryArguments) - { - var primaryArgument = new PrimaryArgument(_nextPrimaryId++, argument); - _primaryArguments.Add(primaryArgument); - - if (!primaryOccurrences.TryGetValue(argument, out var occurrences)) - { - occurrences = new Queue(); - primaryOccurrences[argument] = occurrences; - } - - occurrences.Enqueue(primaryArgument); - } - - foreach (var argument in secondaryArguments) - { - PrimaryArgument? primaryArgument = null; - if (primaryOccurrences.TryGetValue(argument, out var occurrences) && occurrences.Count > 0) - { - primaryArgument = occurrences.Dequeue(); - } - - _secondaryArguments.Add(new SecondaryArgument(argument, primaryArgument?.Id)); - } - } - - public List SecondaryArguments => [.. _secondaryArguments.Select(static argument => argument.Value)]; - - public object this[int index] - { - get => _primaryArguments[index].Value; - set - { - var primaryArgument = _primaryArguments[index]; - primaryArgument.Value = value; - - // Prefer the identity mapping built in the constructor. When the debug rewrite replaced or - // dropped this argument there is no counterpart, so fall back to the positional slot to keep - // ordinary registration-order semantics. That fallback must never address an entry the debug - // rewrite introduced (PrimaryId is null), because the two branches are offset by exactly those - // entries and writing through would silently strip a debugger token. - var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryArgument.Id); - if (secondaryIndex < 0) - { - if (index >= _secondaryArguments.Count || _secondaryArguments[index].PrimaryId is null) - { - return; - } - - secondaryIndex = index; - } - - _secondaryArguments[secondaryIndex].Value = value; - - SecondaryOperations.Add(new SetMirroredCommandLineArgsOperation(secondaryIndex, value)); - } - } - - public int Count => _primaryArguments.Count; - - public bool IsReadOnly => false; - - public void Add(object item) - { - var primaryArgument = new PrimaryArgument(_nextPrimaryId++, item); - _primaryArguments.Add(primaryArgument); - _secondaryArguments.Add(new SecondaryArgument(item, primaryArgument.Id)); - SecondaryOperations.Add(new AddMirroredCommandLineArgsOperation(item)); - } - - public void Clear() - { - _primaryArguments.Clear(); - _secondaryArguments.Clear(); - SecondaryOperations.Add(new ClearMirroredCommandLineArgsOperation()); - } - - public bool Contains(object item) => _primaryArguments.Any(argument => EqualityComparer.Default.Equals(argument.Value, item)); - - public void CopyTo(object[] array, int arrayIndex) - { - foreach (var argument in _primaryArguments) - { - array[arrayIndex++] = argument.Value; - } - } - - public IEnumerator GetEnumerator() => _primaryArguments.Select(static argument => argument.Value).GetEnumerator(); - - public int IndexOf(object item) => _primaryArguments.FindIndex(argument => EqualityComparer.Default.Equals(argument.Value, item)); - - public void Insert(int index, object item) - { - var primaryArgument = new PrimaryArgument(_nextPrimaryId++, item); - _primaryArguments.Insert(index, primaryArgument); - - var secondaryIndex = _secondaryArguments.FindIndex(argument => - argument.PrimaryId is { } primaryId && - _primaryArguments.FindIndex(primary => primary.Id == primaryId) > index); - if (secondaryIndex < 0) - { - secondaryIndex = _secondaryArguments.Count; - } - - _secondaryArguments.Insert(secondaryIndex, new SecondaryArgument(item, primaryArgument.Id)); - SecondaryOperations.Add(new InsertMirroredCommandLineArgsOperation(secondaryIndex, item)); - } - - public bool Remove(object item) - { - var index = IndexOf(item); - if (index < 0) - { - return false; - } - - _primaryArguments.RemoveAt(index); - - var secondaryIndex = _secondaryArguments.FindIndex(argument => EqualityComparer.Default.Equals(argument.Value, item)); - if (secondaryIndex >= 0) - { - _secondaryArguments.RemoveAt(secondaryIndex); - } - - SecondaryOperations.Add(new RemoveMirroredCommandLineArgsOperation(item)); - return true; - } - - public void RemoveAt(int index) - { - var primaryId = _primaryArguments[index].Id; - _primaryArguments.RemoveAt(index); - - // Same identity-then-position rule as the indexer: the positional fallback must not delete an - // argument the debug rewrite introduced. - var secondaryIndex = _secondaryArguments.FindIndex(argument => argument.PrimaryId == primaryId); - if (secondaryIndex < 0) - { - if (index >= _secondaryArguments.Count || _secondaryArguments[index].PrimaryId is null) - { - return; - } - - secondaryIndex = index; - } - - _secondaryArguments.RemoveAt(secondaryIndex); - - SecondaryOperations.Add(new RemoveAtMirroredCommandLineArgsOperation(secondaryIndex)); - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - private sealed class PrimaryArgument(int id, object value) - { - public int Id { get; } = id; - - public object Value { get; set; } = value; - } - - private sealed class SecondaryArgument(object value, int? primaryId) - { - public object Value { get; set; } = value; - - public int? PrimaryId { get; } = primaryId; - } -} - -internal abstract record MirroredCommandLineArgsOperation -{ - public abstract void Apply(List args); -} - -internal sealed record AddMirroredCommandLineArgsOperation(object Item) : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - args.Add(Item); - } -} - -internal sealed record ClearMirroredCommandLineArgsOperation : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - args.Clear(); - } -} - -internal sealed record InsertMirroredCommandLineArgsOperation(int Index, object Item) : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - args.Insert(Math.Min(Index, args.Count), Item); - } -} - -internal sealed record RemoveAtMirroredCommandLineArgsOperation(int Index) : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - if (Index < args.Count) - { - args.RemoveAt(Index); - } - } -} - -internal sealed record RemoveMirroredCommandLineArgsOperation(object Item) : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - args.Remove(Item); - } -} - -internal sealed record SetMirroredCommandLineArgsOperation(int Index, object Item) : MirroredCommandLineArgsOperation -{ - public override void Apply(List args) - { - if (Index < args.Count) - { - args[Index] = Item; - } - } -} +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs index c5ade34df96..cd927e8f944 100644 --- a/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/CommandLineArgsCallbackAnnotation.cs @@ -15,7 +15,6 @@ namespace Aspire.Hosting.ApplicationModel; public class CommandLineArgsCallbackAnnotation : IResourceAnnotation, IArgCallbackAnnotation { private Task>? _callbackTask; - private IReadOnlyList? _mirroredSecondaryOperations; private readonly object _lock = new(); /// @@ -52,24 +51,13 @@ public CommandLineArgsCallbackAnnotation(Action> callback) internal IArgCallbackAnnotation AsCallbackAnnotation() => this; Task> IArgCallbackAnnotation.EvaluateOnceAsync(CommandLineArgsCallbackContext context) - { - return EvaluateOnceAsync(context, out _); - } - - internal Task> EvaluateOnceAsync(CommandLineArgsCallbackContext context, out bool callbackStarted) { lock(_lock) { if (_callbackTask is null) { - callbackStarted = true; _callbackTask = ExecuteCallbackAsync(context); } - else - { - callbackStarted = false; - } - return _callbackTask; } } @@ -79,30 +67,6 @@ void IArgCallbackAnnotation.ForgetCachedResult() lock(_lock) { _callbackTask = null; - _mirroredSecondaryOperations = null; - } - } - - internal void SetCachedMirroredSecondaryOperations(IReadOnlyList operations) - { - lock (_lock) - { - _mirroredSecondaryOperations = operations.ToImmutableList(); - } - } - - internal bool TryGetCachedMirroredSecondaryOperations(out IReadOnlyList operations) - { - lock (_lock) - { - if (_mirroredSecondaryOperations is { } mirroredSecondaryOperations) - { - operations = mirroredSecondaryOperations; - return true; - } - - operations = []; - return false; } } diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index 8dde1d3e726..8c8d2971c43 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using System.Runtime.ExceptionServices; using System.Text.Json; using Aspire.Hosting.Dcp; using Aspire.Hosting.Dcp.Model; @@ -75,10 +74,10 @@ public static bool SupportsDebugging(this IResource resource, IConfiguration con } /// - /// Creates the launch configuration that this resource sends to the IDE using an explicitly resolved callback 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 execution configuration and launch data. + /// 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. @@ -88,7 +87,7 @@ public static bool SupportsDebugging(this IResource resource, IConfiguration con /// which owns the complete configuration; Aspire serializes the result as-is. /// /// - /// This method never resolves arguments or environment variables. Aspire creates + /// This method never resolves environment variables. Aspire creates /// when the active debug-support annotation is producing a launch configuration for an executable creation. /// /// @@ -115,11 +114,6 @@ internal static Task CreateLaunchConfigurationAsync( nameof(context)); } - if (context.ExecutableExecutionConfiguration.Exception is { } executableConfigurationException) - { - ExceptionDispatchInfo.Throw(executableConfigurationException); - } - if (!resource.TryGetLastAnnotation(out var supportsDebuggingAnnotation)) { throw new InvalidOperationException( diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs index bc8fb299dda..4d8dcd62915 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutableLaunchConfiguration.cs @@ -60,7 +60,7 @@ public static class KnownLaunchConfigurationTypes /// /// /// Integrations create a derived type and supply it through -/// . +/// one of the WithDebugSupport overloads on . /// /// /// The launch configuration type identifier, for example . @@ -89,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 available to the producer callback through - /// . + /// 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/ExecutionConfigurationBuilderExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs index da6016cfefb..6a871bc0a95 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationBuilderExtensions.cs @@ -22,24 +22,6 @@ public static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecution return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer()); } - internal static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecutionConfigurationBuilder builder, Func shouldIncludeAnnotation) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(shouldIncludeAnnotation); - - return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer(shouldIncludeAnnotation)); - } - - internal static IExecutionConfigurationBuilder WithArgumentsConfig(this IExecutionConfigurationBuilder builder, DebugCommandLineArgsRewriteCapture debugRewriteCapture) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(debugRewriteCapture); - - return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer( - annotation => !ReferenceEquals(annotation, debugRewriteCapture.ActiveDebugArgsAnnotation), - debugRewriteCapture)); - } - /// /// Adds an environment variables configuration gatherer to the builder. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs index 9928cd46fb7..22cc40f4363 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationGathererContext.cs @@ -41,10 +41,9 @@ internal async Task ResolveAsync( CancellationToken cancellationToken = default) { HashSet references = new(); - List argumentResolutions = new(Arguments.Count); List<(object Unprocessed, string Value, bool IsSensitive)> resolvedArguments = new(Arguments.Count); Dictionary resolvedEnvironmentVariables = new(EnvironmentVariables.Count); - List environmentVariableExceptions = new(); + List exceptions = new(); foreach (var argument in Arguments) { @@ -53,24 +52,17 @@ internal async Task ResolveAsync( var resolvedValue = await resource.ResolveValueAsync(executionContext, resourceLogger, argument, null, cancellationToken).ConfigureAwait(false); if (resolvedValue?.Value != null) { - argumentResolutions.Add(new ArgumentResolution(argument, resolvedValue.Value, resolvedValue.IsSensitive, Exception: null)); resolvedArguments.Add((argument, resolvedValue.Value, resolvedValue.IsSensitive)); if (argument is IValueProvider or IManifestExpressionProvider) { references.Add(argument); } } - else - { - // Recorded even though it contributes nothing to the command line: consumers that replay - // this resolution need one entry per gathered argument to stay aligned by occurrence. - argumentResolutions.Add(new ArgumentResolution(argument, Processed: null, IsSensitive: false, Exception: null)); - } } catch (Exception ex) { resourceLogger.LogError(ex, "Failed to resolve argument for resource '{ResourceName}'. A dependency may have failed to start.", resource.Name); - argumentResolutions.Add(new ArgumentResolution(argument, Processed: null, IsSensitive: false, ex)); + exceptions.Add(ex); } } @@ -91,20 +83,17 @@ internal async Task ResolveAsync( catch (Exception ex) { resourceLogger.LogError(ex, "Failed to resolve environment variable '{EnvironmentVariable}' for resource '{ResourceName}'. A dependency may have failed to start.", kvp.Key, resource.Name); - environmentVariableExceptions.Add(ex); + exceptions.Add(ex); } } return new ExecutionConfigurationResult { - Resource = resource, References = references, ArgumentsWithUnprocessed = resolvedArguments, - ArgumentResolutions = argumentResolutions, EnvironmentVariablesWithUnprocessed = resolvedEnvironmentVariables, - EnvironmentVariableExceptions = environmentVariableExceptions, AdditionalConfigurationData = AdditionalConfigurationData, - Exception = ExecutionConfigurationResult.CombineResolutionExceptions(argumentResolutions, environmentVariableExceptions) + Exception = exceptions.Count == 0 ? null : new AggregateException("One or more errors occurred while resolving resource configuration.", exceptions) }; } } diff --git a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs index e02e73ab4bb..5b2b3c8b540 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExecutionConfigurationResult.cs @@ -3,50 +3,23 @@ namespace Aspire.Hosting.ApplicationModel; -/// -/// The outcome of resolving a single gathered argument, recorded one entry per occurrence so callers can -/// replay a resolution instead of repeating it. -/// -/// The gathered argument, before resolution. -/// The resolved value, or when the argument resolved to null or failed. -/// Whether the resolved value is sensitive. -/// The failure that occurred while resolving, or when resolution succeeded. -internal readonly record struct ArgumentResolution(object Unprocessed, string? Processed, bool IsSensitive, Exception? Exception); - /// /// Represents the configuration (arguments and environment variables) to apply to a specific resource. /// internal sealed class ExecutionConfigurationResult : IExecutionConfigurationResult { - /// - /// Gets the resource this configuration was resolved for. - /// - internal required IResource Resource { get; init; } - /// public required IEnumerable References { get; init; } /// public required IEnumerable<(object Unprocessed, string Processed, bool IsSensitive)> ArgumentsWithUnprocessed { get; init; } - /// - /// Gets the outcome of every gathered argument, including the ones that resolved to null or failed and are - /// therefore absent from . - /// - internal IReadOnlyList ArgumentResolutions { get; init; } = []; - /// public IEnumerable<(string Value, bool IsSensitive)> Arguments => ArgumentsWithUnprocessed.Select(arg => (arg.Processed, arg.IsSensitive)); /// public required IEnumerable> EnvironmentVariablesWithUnprocessed { get; init; } - /// - /// Gets the failures that occurred while resolving environment variables, kept separate from argument - /// failures so a caller that rewrites the argument list can decide which failures still apply. - /// - internal IReadOnlyList EnvironmentVariableExceptions { get; init; } = []; - /// public IEnumerable> EnvironmentVariables => EnvironmentVariablesWithUnprocessed.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Processed)); @@ -55,52 +28,4 @@ internal sealed class ExecutionConfigurationResult : IExecutionConfigurationResu /// public Exception? Exception { get; init; } - - /// - /// Builds the aggregate failure for a resolution, ordering argument failures before environment variable - /// failures so the aggregate matches the order the values were resolved in. - /// - internal static Exception? CombineResolutionExceptions(IEnumerable argumentResolutions, IEnumerable environmentVariableExceptions) - { - List exceptions = [ - .. argumentResolutions.Select(resolution => resolution.Exception).OfType(), - .. environmentVariableExceptions]; - - return exceptions.Count == 0 - ? null - : new AggregateException("One or more errors occurred while resolving resource configuration.", exceptions); - } - - /// - /// Reads the per-occurrence argument resolutions from a result. - /// - /// - /// is public, so a result can come from an implementation that - /// records only the arguments that resolved successfully. Reconstructing the resolutions from that public - /// surface keeps such a result usable, at the cost of not knowing which arguments failed. - /// - internal static IReadOnlyList GetArgumentResolutions(IExecutionConfigurationResult result) - { - return result is ExecutionConfigurationResult { ArgumentResolutions: var resolutions } - ? resolutions - : [.. result.ArgumentsWithUnprocessed.Select(argument => new ArgumentResolution(argument.Unprocessed, argument.Processed, argument.IsSensitive, Exception: null))]; - } - - /// - /// Reads the environment variable failures from a result. - /// - /// - /// For an implementation that does not separate them, the whole failure is reported as an environment - /// variable failure. That is the conservative choice: it keeps a failure that cannot be attributed to a - /// specific argument rather than discarding it. - /// - internal static IReadOnlyList GetEnvironmentVariableExceptions(IExecutionConfigurationResult result) - { - return result switch - { - ExecutionConfigurationResult concrete => concrete.EnvironmentVariableExceptions, - { Exception: { } exception } => [exception], - _ => [] - }; - } } diff --git a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs index d40185cf013..ad938176572 100644 --- a/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs +++ b/src/Aspire.Hosting/ApplicationModel/LaunchConfigurationCallbackContext.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.ApplicationModel; @@ -11,25 +9,9 @@ namespace Aspire.Hosting.ApplicationModel; /// Provides the runtime data used to create a launch configuration for a resource. /// /// -/// Aspire creates a new context only when the resource's active debug-support annotation produces a launch -/// configuration for a specific executable creation, restart, or replica. This is not a general resource -/// lifecycle callback: the producer is not invoked when the annotation is inactive, unsupported by the -/// current debug session, or skipped because a -/// already supplied a -/// launch configuration. -/// The context is framework-owned and both execution snapshots are bound to when Aspire -/// constructs it. -/// contains the resolved resource configuration before an active -/// debug-support argument rewrite runs. contains the copy used to -/// populate the underlying executable after that rewrite. When a -/// pins a project executable to process execution, the debug argument rewrite is suppressed so the process command -/// line remains runnable. Only the launch configuration returned by the producer is serialized for the IDE. -/// on can include -/// argument failures that the debug rewrite removed from ; producers -/// should check it before copying values from the original snapshot. -/// Processed arguments and environment values can both contain secrets: -/// carries an IsSensitive flag for exactly this reason, so a resolved parameter can arrive as an argument as -/// readily as an environment value. Anything a producer copies into the launch configuration is written to the IDE. +/// 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 @@ -37,27 +19,16 @@ public sealed class LaunchConfigurationCallbackContext internal LaunchConfigurationCallbackContext( string mode, IResource resource, - IExecutionConfigurationResult originalExecutionConfiguration, - IExecutionConfigurationResult executableExecutionConfiguration, - DistributedApplicationExecutionContext executionContext, - ILogger? logger = null, - CancellationToken cancellationToken = default) + IReadOnlyDictionary environmentVariables, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(mode); ArgumentNullException.ThrowIfNull(resource); - ArgumentNullException.ThrowIfNull(originalExecutionConfiguration); - ArgumentNullException.ThrowIfNull(executableExecutionConfiguration); - ArgumentNullException.ThrowIfNull(executionContext); - - ValidateExecutionConfigurationResource(resource, originalExecutionConfiguration, nameof(originalExecutionConfiguration)); - ValidateExecutionConfigurationResource(resource, executableExecutionConfiguration, nameof(executableExecutionConfiguration)); + ArgumentNullException.ThrowIfNull(environmentVariables); Mode = mode; Resource = resource; - OriginalExecutionConfiguration = originalExecutionConfiguration; - ExecutableExecutionConfiguration = executableExecutionConfiguration; - ExecutionContext = executionContext; - Logger = logger ?? NullLogger.Instance; + EnvironmentVariables = environmentVariables; CancellationToken = cancellationToken; } @@ -72,57 +43,16 @@ internal LaunchConfigurationCallbackContext( public IResource Resource { get; } /// - /// Gets the resolved execution configuration before the active debug-support argument rewrite runs. + /// Gets the resolved environment variables used for this executable creation. /// /// - /// Processed environment values can contain secrets. Aspire serializes only the launch configuration - /// returned by the producer; integrations should copy values from this result only when the IDE requires them. + /// Values can contain secrets. Aspire serializes only the launch configuration returned by the + /// producer; integrations should copy only values required by the IDE. /// - public IExecutionConfigurationResult OriginalExecutionConfiguration { get; } - - /// - /// Gets the resolved execution configuration used to populate the executable after the active debug-support argument rewrite runs. - /// - /// - /// This is a copy of with the active argsCallback applied. - /// When debug support does not rewrite arguments, or a project launch-args override keeps the executable in - /// process mode, this is the same instance as . - /// - public IExecutionConfigurationResult ExecutableExecutionConfiguration { get; } - - /// - /// Gets the execution context for the current AppHost invocation. - /// - public DistributedApplicationExecutionContext ExecutionContext { get; } - - /// - /// Gets the resource logger for this executable creation. - /// - public ILogger Logger { get; } + public IReadOnlyDictionary EnvironmentVariables { get; } /// /// Gets the cancellation token for this executable creation. /// public CancellationToken CancellationToken { get; } - - private static void ValidateExecutionConfigurationResource( - IResource resource, - IExecutionConfigurationResult executionConfiguration, - string parameterName) - { - if (executionConfiguration is not ExecutionConfigurationResult { Resource: var configurationResource }) - { - throw new ArgumentException( - $"The launch configuration callback context for resource '{resource.Name}' requires an execution configuration resolved by Aspire for that resource.", - parameterName); - } - - if (!ReferenceEquals(resource, configurationResource)) - { - throw new ArgumentException( - $"The execution configuration belongs to resource '{configurationResource.Name}', " + - $"but the launch configuration callback context is being created for resource '{resource.Name}'.", - parameterName); - } - } } diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index f66afef70e0..5e88113f639 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -16,7 +16,7 @@ namespace Aspire.Hosting.Dcp; -using ExecutableConfiguration = (IExecutionConfigurationResult OriginalConfiguration, IExecutionConfigurationResult ExecutableConfiguration, ExecutablePemCertificates? PemCertificates); +using ExecutableConfiguration = (IExecutionConfigurationResult Configuration, ExecutablePemCertificates? PemCertificates); /// /// Handles preparation and creation of Executable DCP resources (project executables and plain executables). @@ -59,6 +59,7 @@ public IEnumerable> PrepareObjects(Cancellatio { PrepareProjectExecutables(cancellationToken); PreparePlainExecutables(); + return _appResources.Get().OfType>(); } @@ -90,42 +91,7 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC spec.Args.AddRange(projectArgs); } - // PrepareProjectExecutables() takes a dedicated branch for project resources that carry a launch - // args override: it pins the executable to Process execution and writes the static "project" - // launch configuration itself. Both of those decisions have to be preserved here. - // - // This is NOT the same as "the resource has no launch configuration". MAUI platform resources are - // ProjectResources that carry both a ProjectLaunchArgsOverrideAnnotation and a "maui" - // SupportsDebuggingAnnotation, and their producer must still run below so the IDE receives the MAUI - // launch configuration rather than the generic "project" one. - var preparedFromLaunchArgsOverride = er.ModelResource is ProjectResource && HasProjectLaunchArgsOverride(er.ModelResource); - - SupportsDebuggingAnnotation? supportsDebuggingAnnotation = null; - if (!er.ModelResource.HasAnnotationOfType() - && er.ModelResource.SupportsDebugging(_configuration, out var activeDebuggingAnnotation)) - { - supportsDebuggingAnnotation = activeDebuggingAnnotation; - - if (!preparedFromLaunchArgsOverride) - { - // Executable objects are reused for restarts, and a prior producer failure may have changed - // the execution type to Process. Reset it before building arguments because launch-profile - // arguments are executable in Process mode but display-only in IDE mode. - spec.ExecutionType = ExecutionType.IDE; - } - } - - // A launch-args override pins the executable to Process mode, so the process command line must stay - // runnable even when a custom launch configuration producer also has an IDE-only args callback. - // The producer still runs below for non-"project" launch types, but its debug argument rewrite is - // suppressed for the executable snapshot. - var (originalConfiguration, configuration, pemCertificates) = await BuildExecutableConfiguration( - er, - resourceLogger, - supportsDebuggingAnnotation, - applyDebugArgumentRewrite: !preparedFromLaunchArgsOverride, - cancellationToken) - .ConfigureAwait(false); + var (configuration, pemCertificates) = await BuildExecutableConfiguration(er, resourceLogger, cancellationToken).ConfigureAwait(false); spec.PemCertificates = pemCertificates; @@ -194,15 +160,13 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC } // Invoke the active launch configuration producer only after the resource execution configuration - // has been resolved. This gives every launch type, including "project", the exact arguments and - // environment used for this executable creation. - // - // The single exception is the "project" type on a launch-args-override project resource: - // PrepareProjectExecutables() already wrote the launch configuration matching the overridden command - // line, so re-running that producer would describe a launch that never happens. Producers for every - // other launch type still run here, because nothing else supplies their configuration. - if (supportsDebuggingAnnotation is not null - && !(preparedFromLaunchArgsOverride && supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)) + // has been resolved. This lets the producer reuse the exact arguments and environment values applied + // to this executable creation without evaluating resource callbacks a second time. + if (!er.ModelResource.HasAnnotationOfType() + && er.ModelResource.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation) + && !(er.ModelResource is ProjectResource + && HasProjectLaunchArgsOverride(er.ModelResource) + && supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)) { var isProjectLaunchConfiguration = supportsDebuggingAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project; @@ -217,27 +181,18 @@ public async Task CreateObjectAsync(RenderedModelResource er, EmptyC var mode = isProjectLaunchConfiguration ? GetProjectLaunchConfigurationMode() : _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug; - - // The fallback below runs the executable spec's command and args "as is", so it is only unsafe - // when the debug rewrite actually replaced them. A launch-args override suppresses that rewrite - // (applyDebugArgumentRewrite above), which leaves the spec holding the user's real command line - // -- so keying the guard on the annotation alone denied the fallback to exactly the resources - // that could still use it, and a MAUI or custom project resource whose producer threw failed - // outright instead of running. - var rewroteArgumentsForDebugging = supportsDebuggingAnnotation.RewritesArgumentsForDebugging - && !preparedFromLaunchArgsOverride; var callbackContext = new LaunchConfigurationCallbackContext( mode, er.ModelResource, - originalConfiguration, - configuration, - _executionContext, - resourceLogger, + configuration.EnvironmentVariables.ToDictionary( + static variable => variable.Key, + static variable => variable.Value, + StringComparer.Ordinal), cancellationToken); try { - // Executable objects are reused for restarts. Clear the prior launch configuration before + // Executable objects are reused for restarts, so clear the prior launch configuration before // applying the freshly resolved producer result. exe.Annotate(Executable.LaunchConfigurationsAnnotation, string.Empty); await supportsDebuggingAnnotation @@ -245,9 +200,8 @@ await supportsDebuggingAnnotation .ConfigureAwait(false); } catch (Exception exception) when ( - (exception is not OperationCanceledException || !callbackContext.CancellationToken.IsCancellationRequested) - && !isProjectLaunchConfiguration - && !rewroteArgumentsForDebugging) + !isProjectLaunchConfiguration + && !supportsDebuggingAnnotation.RewritesArgumentsForDebugging) { _logger.LogWarning( exception, @@ -267,7 +221,6 @@ private void PrepareProjectExecutables(CancellationToken cancellationToken) foreach (var project in modelProjectResources) { - cancellationToken.ThrowIfCancellationRequested(); if (!project.TryGetProjectMetadata(out var projectMetadata)) { throw new InvalidOperationException($"Project resource '{project.Name}' is missing required metadata."); // Should never happen. @@ -342,6 +295,9 @@ private void PrepareProjectExecutables(CancellationToken cancellationToken) exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; } + // The active launch configuration producer runs later in CreateObjectAsync, after the + // resource's arguments and environment variables have been resolved. + // File-based apps (.cs files) are not supported by all IDEs (e.g. Visual Studio // returns 500 for them). Populate fallback process args so that when the IDE // rejects the launch request and DCP falls back to ExecutionType.Process, the @@ -379,7 +335,7 @@ private void PrepareProjectExecutables(CancellationToken cancellationToken) exe.Spec.ExecutionType = ExecutionType.IDE; exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; - ApplyProjectLaunchConfiguration(exe, project, projectMetadata); + exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata)); } else { @@ -527,12 +483,7 @@ private static void ApplyMonitorProcess(IResource resource, ExecutableSpec spec) } } - private async Task BuildExecutableConfiguration( - RenderedModelResource er, - ILogger resourceLogger, - SupportsDebuggingAnnotation? supportsDebuggingAnnotation, - bool applyDebugArgumentRewrite, - CancellationToken cancellationToken) + private async Task BuildExecutableConfiguration(RenderedModelResource er, ILogger resourceLogger, CancellationToken cancellationToken) { var exe = (Executable)er.DcpResource; @@ -542,25 +493,8 @@ private async Task BuildExecutableConfiguration( var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); - var activeDebugArgsAnnotation = supportsDebuggingAnnotation?.DebugCommandLineArgsCallbackAnnotation; - var shouldBuildDebugArguments = activeDebugArgsAnnotation is not null && applyDebugArgumentRewrite; - DebugCommandLineArgsRewriteCapture? debugRewriteCapture = null; - var configurationBuilder = ExecutionConfigurationBuilder.Create(er.ModelResource); - if (activeDebugArgsAnnotation is null) - { - configurationBuilder.WithArgumentsConfig(); - } - else if (shouldBuildDebugArguments) - { - debugRewriteCapture = new DebugCommandLineArgsRewriteCapture(activeDebugArgsAnnotation); - configurationBuilder.WithArgumentsConfig(debugRewriteCapture); - } - else - { - configurationBuilder.WithArgumentsConfig(annotation => !ReferenceEquals(annotation, activeDebugArgsAnnotation)); - } - - var originalConfiguration = await configurationBuilder + var configuration = await ExecutionConfigurationBuilder.Create(er.ModelResource) + .WithArgumentsConfig() .WithEnvironmentVariablesConfig() .WithCertificateTrustConfig(scope => { @@ -599,20 +533,9 @@ private async Task BuildExecutableConfiguration( .BuildAsync(_executionContext, resourceLogger, cancellationToken) .ConfigureAwait(false); - var executableConfiguration = shouldBuildDebugArguments - ? await BuildExecutableConfigurationWithDebugArgumentsAsync( - originalConfiguration, - debugRewriteCapture!.OriginalArguments, - debugRewriteCapture!.ExecutableArguments, - er.ModelResource, - resourceLogger, - cancellationToken) - .ConfigureAwait(false) - : originalConfiguration; - // Add the certificates to the executable spec so they'll be placed in the DCP config ExecutablePemCertificates? pemCertificates = null; - if (originalConfiguration.TryGetAdditionalData(out var certificateTrustConfiguration) + if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) && certificateTrustConfiguration.Scope != CertificateTrustScope.None && certificateTrustConfiguration.Certificates.Count > 0) { @@ -636,7 +559,7 @@ private async Task BuildExecutableConfiguration( } } - if (originalConfiguration.TryGetAdditionalData(out var tlsCertificateConfiguration)) + if (configuration.TryGetAdditionalData(out var tlsCertificateConfiguration)) { var thumbprint = tlsCertificateConfiguration.Certificate.Thumbprint; var publicCertificatePem = tlsCertificateConfiguration.Certificate.ExportCertificatePem(); @@ -681,195 +604,7 @@ private async Task BuildExecutableConfiguration( } } - return (originalConfiguration, executableConfiguration, pemCertificates); - } - - private async Task BuildExecutableConfigurationWithDebugArgumentsAsync( - IExecutionConfigurationResult originalConfiguration, - IReadOnlyList originalArgsBeforePostArgumentGatherers, - IReadOnlyList rewrittenArgs, - IResource resource, - ILogger resourceLogger, - CancellationToken cancellationToken) - { - // The executable snapshot is gathered in the same pass as the original configuration so the active - // debug rewrite keeps its registration-order position without replaying ordinary WithArgs callbacks. - // It still contains unprocessed arguments here; reusing the matching resolutions below avoids - // re-resolving value providers that already ran for this executable creation. - var originalResolutions = ExecutionConfigurationResult.GetArgumentResolutions(originalConfiguration); - rewrittenArgs = AppendArgumentsAddedByLaterGatherers(originalArgsBeforePostArgumentGatherers, rewrittenArgs, originalResolutions); - - // Arguments the debug rewrite kept were already resolved into originalConfiguration for this same - // executable creation. IValueProvider carries no idempotence guarantee, so resolving them a second - // time can produce a different value or repeat a side effect - the same hazard that stopped ordinary - // WithArgs callbacks from being replayed above. Reuse those resolutions and send only what the - // callback introduced through the gatherer. - // - // The lookup is keyed by reference identity but tracked per occurrence: one provider instance can sit - // at several positions on the command line, and because it is resolved once per position those - // positions can hold different values. Collapsing them to a single entry would replay the first - // position's value everywhere the instance appears. - var previousResolutions = new Dictionary>(ReferenceEqualityComparer.Instance); - foreach (var resolution in originalResolutions) - { - if (!previousResolutions.TryGetValue(resolution.Unprocessed, out var resolutions)) - { - resolutions = []; - previousResolutions[resolution.Unprocessed] = resolutions; - } - - resolutions.Add(resolution); - } - - var reuseCounts = new Dictionary(ReferenceEqualityComparer.Instance); - var plannedArguments = new List<(object Argument, ArgumentResolution? Reused)>(rewrittenArgs.Count); - var rewrittenArgsGathererContext = new ExecutionConfigurationGathererContext(); - foreach (var argument in rewrittenArgs) - { - if (previousResolutions.TryGetValue(argument, out var resolutions)) - { - var occurrence = reuseCounts.TryGetValue(argument, out var count) ? count : 0; - reuseCounts[argument] = occurrence + 1; - - // A callback that duplicates an argument produces more occurrences than were resolved, and the - // extra ones repeat the last recorded resolution rather than being resolved again. Resolving - // again is what this whole path exists to avoid. - plannedArguments.Add((argument, resolutions[Math.Min(occurrence, resolutions.Count - 1)])); - } - else - { - plannedArguments.Add((argument, null)); - rewrittenArgsGathererContext.Arguments.Add(argument); - } - } - - var rewrittenArgsConfiguration = await rewrittenArgsGathererContext - .ResolveAsync(resource, resourceLogger, _executionContext, cancellationToken) - .ConfigureAwait(false); - - // Newly resolved values are matched back by reference identity and occurrence order rather than by - // position, because the gatherer only received the arguments that had no prior resolution. - var newResolutions = new Dictionary>(ReferenceEqualityComparer.Instance); - foreach (var resolution in ExecutionConfigurationResult.GetArgumentResolutions(rewrittenArgsConfiguration)) - { - if (!newResolutions.TryGetValue(resolution.Unprocessed, out var queue)) - { - queue = new Queue(); - newResolutions[resolution.Unprocessed] = queue; - } - - queue.Enqueue(resolution); - } - - var reusedReferences = new List(); - var reusedResolutions = new List(); - var retainedArgumentResolutions = new List(rewrittenArgs.Count); - var argumentsWithUnprocessed = new List<(object Unprocessed, string Processed, bool IsSensitive)>(rewrittenArgs.Count); - foreach (var (argument, reused) in plannedArguments) - { - var resolution = reused; - if (resolution is null && newResolutions.TryGetValue(argument, out var queue) && queue.Count > 0) - { - resolution = queue.Dequeue(); - } - - if (resolution is not { } argumentResolution) - { - continue; - } - - retainedArgumentResolutions.Add(argumentResolution); - if (reused is not null) - { - reusedResolutions.Add(argumentResolution); - } - - // An argument that resolved to null, or whose resolution threw, contributes nothing to the command - // line - exactly as it would have if the gatherer had resolved it here. - if (argumentResolution.Processed is not { } processed) - { - continue; - } - - argumentsWithUnprocessed.Add((argument, processed, argumentResolution.IsSensitive)); - - if (reused is not null && argument is IValueProvider or IManifestExpressionProvider) - { - // ResolveAsync never saw this argument, so its reference has to be contributed here or the - // executable would lose the dependency edge that the original resolution recorded. - reusedReferences.Add(argument); - } - } - - // Only failures that still apply to this executable are retained. A reused argument that failed to - // resolve and that the rewrite dropped is no longer part of the command line, so keeping its failure - // would fail an executable that has nothing left to fail on. Only reused resolutions are considered - // here because failures from the gatherer above are already carried by its own result, and they always - // apply: it only ever saw arguments that survived the rewrite. Environment variables are copied from - // the original configuration untouched, so their failures always apply too. - var environmentVariableExceptions = ExecutionConfigurationResult.GetEnvironmentVariableExceptions(originalConfiguration); - var retainedOriginalException = ExecutionConfigurationResult.CombineResolutionExceptions(reusedResolutions, environmentVariableExceptions); - - var environmentReferences = originalConfiguration.EnvironmentVariablesWithUnprocessed - .Select(static kvp => kvp.Value.Unprocessed) - .Where(static value => value is IValueProvider or IManifestExpressionProvider); - - return new ExecutionConfigurationResult - { - Resource = resource, - References = environmentReferences.Concat(rewrittenArgsConfiguration.References).Concat(reusedReferences).ToHashSet(), - ArgumentsWithUnprocessed = argumentsWithUnprocessed, - ArgumentResolutions = retainedArgumentResolutions, - EnvironmentVariablesWithUnprocessed = originalConfiguration.EnvironmentVariablesWithUnprocessed, - EnvironmentVariableExceptions = environmentVariableExceptions, - AdditionalConfigurationData = originalConfiguration.AdditionalConfigurationData, - Exception = CombineExecutionConfigurationExceptions(retainedOriginalException, rewrittenArgsConfiguration.Exception) - }; - } - - private static IReadOnlyList AppendArgumentsAddedByLaterGatherers( - IReadOnlyList originalArgsBeforePostArgumentGatherers, - IReadOnlyList rewrittenArgs, - IReadOnlyList finalOriginalResolutions) - { - var finalOriginalArgs = finalOriginalResolutions.Select(static resolution => resolution.Unprocessed).ToArray(); - if (finalOriginalArgs.Length < originalArgsBeforePostArgumentGatherers.Count) - { - return rewrittenArgs; - } - - for (var i = 0; i < originalArgsBeforePostArgumentGatherers.Count; i++) - { - if (!ReferenceEquals(finalOriginalArgs[i], originalArgsBeforePostArgumentGatherers[i])) - { - // Later execution-configuration gatherers normally append arguments (for example TLS flags - // added by language integrations). If a custom gatherer reordered or removed the ordinary - // argument branch, we cannot safely infer how that mutation should apply to the debug branch - // without re-running callbacks, so keep the debug branch as captured. - return rewrittenArgs; - } - } - - if (finalOriginalArgs.Length == originalArgsBeforePostArgumentGatherers.Count) - { - return rewrittenArgs; - } - - return [.. rewrittenArgs, .. finalOriginalArgs.Skip(originalArgsBeforePostArgumentGatherers.Count)]; - } - - private static Exception? CombineExecutionConfigurationExceptions(Exception? originalException, Exception? rewrittenArgsException) - { - return (originalException, rewrittenArgsException) switch - { - (null, null) => null, - ({ } exception, null) => exception, - (null, { } exception) => exception, - ({ } original, { } rewritten) => new AggregateException( - "One or more errors occurred while resolving resource configuration.", - original, - rewritten) - }; + return (configuration, pemCertificates); } private string GetCertificatesRootDirectory(RenderedModelResource er, Executable exe) @@ -1076,11 +811,6 @@ private bool ShouldFallBackToIdeExecution(bool isInDebugSession, SupportsDebuggi return true; } - private void ApplyProjectLaunchConfiguration(Executable exe, IResource project, IProjectMetadata projectMetadata) - { - exe.SetProjectLaunchConfiguration(CreateProjectLaunchConfiguration(project, projectMetadata)); - } - private ProjectLaunchConfiguration CreateProjectLaunchConfiguration(IResource project, IProjectMetadata projectMetadata) { return ProjectLaunchConfigurationFactory.Create(project, projectMetadata, GetProjectLaunchConfigurationMode()); diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index a71431b5de2..a7b5bf72705 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -505,8 +505,7 @@ public static IResourceBuilder WithProjectDefaults Task.FromResult( - ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + mode => ProjectLaunchConfigurationFactory.Create(builder.Resource, mode), KnownLaunchConfigurationTypes.Project); // File-based apps (a bare .cs file) are a .NET 10 SDK feature. The check lives here rather than in diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 77b4577d030..7aab330fc4c 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4770,27 +4770,19 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// 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. - /// - /// Optional callback to add or modify command-line arguments while this debug support annotation is active. - /// When a keeps a project executable in process mode, - /// Aspire suppresses this rewrite for the process command line so the override remains runnable. - /// + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. /// The . + /// + /// is a or . Use an + /// asynchronous overload instead so the task result, rather than the task itself, becomes the launch configuration. + /// /// /// Registering debug support is synchronous. Aspire invokes /// later only for executable creations where this debug-support annotation is active for the current debug /// session, including restarts and replicas. This is not a general resource lifecycle callback: it does not /// run for process launches, unsupported debug sessions, publish mode, or inactive annotations superseded by /// a later . - /// - /// A that already supplies a - /// launch configuration skips the producer for that - /// specific . Producers for other launch configuration types still - /// run when their annotation is active, but their does not rewrite the - /// process command line owned by the launch-args override. - /// /// - [Obsolete("Use the overload that accepts LaunchConfigurationCallbackContext and returns a Task.")] [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.")] @@ -4803,30 +4795,50 @@ public static IResourceBuilder WithDebugSupport( { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(launchConfigurationProducer); - var producerReturnType = typeof(TLaunchConfiguration); - if (typeof(Task).IsAssignableFrom(producerReturnType) - || producerReturnType == typeof(ValueTask) - || producerReturnType.IsGenericType && producerReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - // This overload binds an asynchronous producer as if its task were the launch configuration, so it - // rejects one instead. ArgumentException naming the parameter is the compatible shape: the invalid - // input is the producer's return type, and callers that already handle this rejection keep working. + + if (typeof(Task).IsAssignableFrom(typeof(TLaunchConfiguration)) || IsValueTask(typeof(TLaunchConfiguration))) + { throw new ArgumentException( - $"The launch configuration producer returns '{typeof(TLaunchConfiguration)}'. The legacy {nameof(WithDebugSupport)} overload requires a synchronous producer. " + - "Use the overload that accepts LaunchConfigurationCallbackContext and returns Task. " + - "A producer that returns ValueTask or ValueTask does not bind to that overload directly; " + - "adapt it with AsTask() or wrap it in an async lambda.", + $"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.", nameof(launchConfigurationProducer)); } -#pragma warning disable ASPIREEXTENSION001 // Forwarding to the replacement experimental API. - var result = builder.WithDebugSupport( - context => Task.FromResult(launchConfigurationProducer(context.Mode)), + return builder.WithDebugSupport( + (mode, _) => Task.FromResult(launchConfigurationProducer(mode)), launchConfigurationType, argsCallback); -#pragma warning restore ASPIREEXTENSION001 - return result; + static bool IsValueTask(Type type) + => type == typeof(ValueTask) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)); + } + + /// + /// 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. + /// A callback that receives the launch mode and cancellation token. + /// The type tag of the launch configuration sent to the IDE. + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. + /// The . + [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, + Action? argsCallback = null) + where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(launchConfigurationProducer); + + return builder.WithDebugSupport( + context => launchConfigurationProducer(context.Mode, context.CancellationToken), + launchConfigurationType, + argsCallback); } /// @@ -4836,17 +4848,11 @@ public static IResourceBuilder WithDebugSupport( /// The launch configuration type produced for the resource, typically derived from . /// The resource builder. /// - /// A callback that receives the resolved execution configuration and runtime launch context, and asynchronously - /// produces the complete launch configuration handed to the IDE. + /// 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. - /// - /// Optional callback to add or modify command-line arguments while this debug support annotation is active. - /// The callback rewrites only the executable configuration; - /// preserves the resolved arguments before this callback runs. When a - /// keeps a project executable in process mode, Aspire - /// suppresses this rewrite for the process command line so the override remains runnable. - /// + /// Optional callback to add or modify command-line arguments while this debug support annotation is active. /// The . /// /// Registering debug support is synchronous. Aspire invokes @@ -4855,50 +4861,28 @@ public static IResourceBuilder WithDebugSupport( /// with . This is not a general resource lifecycle callback: /// it does not run for process launches, unsupported debug sessions, publish mode, or inactive annotations /// superseded by a later . - /// - /// A that already supplies a - /// launch configuration skips the producer for that - /// specific . Producers for other launch configuration types still - /// run when their annotation is active, but their does not rewrite the - /// process command line owned by the launch-args override. - /// /// /// - /// Produce a launch configuration for a resource, reading the arguments and environment Aspire resolved for - /// this launch. The launch configuration type declares whatever the IDE launcher expects, because - /// itself carries only type and mode: + /// Produce a launch configuration using an environment variable that changes where the tool writes its output: /// /// internal sealed class MyToolLaunchConfiguration() : ExecutableLaunchConfiguration("mytool") /// { - /// [JsonPropertyName("args")] - /// public List<string> Args { get; set; } = []; - /// - /// [JsonPropertyName("env")] - /// public Dictionary<string, string> Env { get; set; } = []; + /// public string? TargetDirectory { get; set; } /// } - /// - /// A synchronous producer returns through : - /// + /// /// builder.AddExecutable("tool", "mytool", ".") /// .WithDebugSupport( - /// context => Task.FromResult(new MyToolLaunchConfiguration - /// { - /// Mode = context.Mode, - /// // OriginalExecutionConfiguration is the resolution before the argsCallback below runs, - /// // so the IDE launches the arguments the user asked for rather than the debug rewrite. - /// Args = [.. context.OriginalExecutionConfiguration.Arguments.Select(argument => argument.Value)], - /// Env = context.OriginalExecutionConfiguration.EnvironmentVariables - /// .ToDictionary(variable => variable.Key, variable => variable.Value) - /// }), - /// launchConfigurationType: "mytool", - /// argsCallback: argsContext => + /// context => /// { - /// // Applies only to the executable arguments, never to the original configuration above. - /// argsContext.Args.Insert(0, "--wait-for-debugger"); - /// }); + /// context.EnvironmentVariables.TryGetValue("MYTOOL_TARGET_DIR", out var targetDirectory); + /// return Task.FromResult(new MyToolLaunchConfiguration + /// { + /// Mode = context.Mode, + /// TargetDirectory = targetDirectory + /// }); + /// }, + /// launchConfigurationType: "mytool"); /// - /// Migrating from the obsolete overload: it received only the launch mode, so replace mode with - /// context.Mode and wrap the returned value in . /// [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] [AspireExportIgnore(Reason = "Generic debug launch configuration support is not part of the ATS surface.")] @@ -4917,38 +4901,26 @@ public static IResourceBuilder WithDebugSupport( return builder; } - SupportsDebuggingAnnotation? supportsDebuggingAnnotation = null; - var argsResourceBuilder = builder as IResourceBuilder; - CommandLineArgsCallbackAnnotation? debugCommandLineArgsAnnotation = null; - if (argsCallback is not null && argsResourceBuilder is not null) + var supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( + builder.Resource.Name, + launchConfigurationType, + launchConfigurationProducer, + rewritesArgumentsForDebugging: argsCallback is not null && builder is IResourceBuilder); + + if (argsCallback is not null && builder is IResourceBuilder resourceWithArgs) { - debugCommandLineArgsAnnotation = new CommandLineArgsCallbackAnnotation(ctx => + resourceWithArgs.WithArgs(ctx => { // Make sure that we do not call the callback if we aren't the active (last) SupportsDebuggingAnnotation, // because the callback may be specific to the launch configuration type. - if (supportsDebuggingAnnotation is not null - && argsResourceBuilder.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) + if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) { argsCallback(ctx); } - - return Task.CompletedTask; }); } - supportsDebuggingAnnotation = SupportsDebuggingAnnotation.Create( - builder.Resource.Name, - launchConfigurationType, - launchConfigurationProducer, - debugCommandLineArgsAnnotation, - rewritesArgumentsForDebugging: debugCommandLineArgsAnnotation is not null); - - if (debugCommandLineArgsAnnotation is not null && argsResourceBuilder is not null) - { - argsResourceBuilder.WithAnnotation(debugCommandLineArgsAnnotation); - } - return builder.WithAnnotation(supportsDebuggingAnnotation); } diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 26a314c31cc..8c62cbeacaf 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -12,11 +12,9 @@ namespace Aspire.Hosting.ApplicationModel; /// instead of being started as a plain process by Aspire. /// /// -/// Added by . +/// Added by a WithDebugSupport overload on . /// 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. +/// to test for that. /// [DebuggerDisplay("Type = {GetType().Name,nq}, RequiredExtensionId = {LaunchConfigurationType,nq}")] [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] @@ -26,13 +24,11 @@ private SupportsDebuggingAnnotation( string launchConfigurationType, Func launchConfigurationAnnotator, Func> launchConfigurationProducer, - CommandLineArgsCallbackAnnotation? debugCommandLineArgsCallbackAnnotation, bool rewritesArgumentsForDebugging) { LaunchConfigurationType = launchConfigurationType; LaunchConfigurationAnnotator = launchConfigurationAnnotator; LaunchConfigurationProducer = launchConfigurationProducer; - DebugCommandLineArgsCallbackAnnotation = debugCommandLineArgsCallbackAnnotation; RewritesArgumentsForDebugging = rewritesArgumentsForDebugging; } @@ -59,8 +55,6 @@ private SupportsDebuggingAnnotation( // the supported way to reach it. internal Func> LaunchConfigurationProducer { get; } - internal CommandLineArgsCallbackAnnotation? DebugCommandLineArgsCallbackAnnotation { get; } - /// /// Indicates that the debug support rewrites the resource's command-line arguments while a debug /// session is active (via the argsCallback passed to WithDebugSupport). @@ -87,52 +81,23 @@ internal static SupportsDebuggingAnnotation Create( string resourceName, string launchConfigurationType, Func> launchConfigurationProducer, - CommandLineArgsCallbackAnnotation? debugCommandLineArgsCallbackAnnotation = null, bool rewritesArgumentsForDebugging = false) { // 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, context) => - exe.AnnotateAsObjectList( - Executable.LaunchConfigurationsAnnotation, - await ProduceAsync(context).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 context => (await ProduceAsync(context).ConfigureAwait(false))!, - debugCommandLineArgsCallbackAnnotation, rewritesArgumentsForDebugging); async Task ProduceAsync(LaunchConfigurationCallbackContext context) { - Task? producerTask; - try - { - producerTask = launchConfigurationProducer(context); - } - catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) - { - throw CreateProducerException(exception); - } - - if (producerTask is null) - { - throw new InvalidOperationException( - $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' returned a null task. " + - "The producer must return a task that produces the complete launch configuration."); - } - - T launchConfiguration; - try - { - launchConfiguration = await producerTask.ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException || !context.CancellationToken.IsCancellationRequested) - { - throw CreateProducerException(exception); - } - + var launchConfiguration = await launchConfigurationProducer(context).ConfigureAwait(false); if (launchConfiguration is null) { throw new InvalidOperationException( @@ -142,12 +107,5 @@ async Task ProduceAsync(LaunchConfigurationCallbackContext context) return launchConfiguration; } - - InvalidOperationException CreateProducerException(Exception innerException) - { - return new InvalidOperationException( - $"The \"{launchConfigurationType}\" launch configuration producer for resource '{resourceName}' failed.", - innerException); - } } } diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index d0d36924058..ae9fed0c060 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs @@ -331,9 +331,7 @@ public async Task AddDotnetProject_InDebugSession_KeepsDotnetRunArgs_WhenActiveC var projectPath = Path.Combine(builder.AppHostDirectory, "MyService", "MyService.csproj"); var app = builder.AddDotnetProject("svc", projectPath, o => o.ExcludeLaunchProfile = true) .WithArgs("--config", "prod.yaml") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("custom")), - "custom"); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("custom"), "custom"); using var application = builder.Build(); var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services); @@ -366,10 +364,7 @@ public async Task AddDotnetProject_InDebugSession_OmitsDotnetRunScaffolding_When var projectPath = Path.Combine(builder.AppHostDirectory, "MyService", "MyService.csproj"); var app = builder.AddDotnetProject("svc", projectPath, o => o.ExcludeLaunchProfile = true) .WithArgs("--config", "prod.yaml") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("custom")), - "custom", - ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("custom"), "custom", ctx => ctx.Args.Add("rewritten-arg")); using var application = builder.Build(); var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource, application.Services); diff --git a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs index 258ba3d2b98..b29779b2cb3 100644 --- a/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs +++ b/tests/Aspire.Hosting.TestUtilities/Utils/LaunchConfigurationTestHelpers.cs @@ -3,9 +3,6 @@ #pragma warning disable ASPIREEXTENSION001 -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - namespace Aspire.Hosting.Tests.Utils; public static class LaunchConfigurationTestHelpers @@ -13,36 +10,18 @@ public static class LaunchConfigurationTestHelpers public static LaunchConfigurationCallbackContext CreateCallbackContext( IResource resource, string mode = ExecutableLaunchMode.Debug, - IExecutionConfigurationResult? executionConfiguration = null, - DistributedApplicationExecutionContext? executionContext = null, - ILogger? logger = null, + IReadOnlyDictionary? environmentVariables = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(resource); - executionConfiguration ??= CreateExecutionConfigurationResult(resource); return new LaunchConfigurationCallbackContext( mode, resource, - executionConfiguration, - executionConfiguration, - executionContext ?? new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), - logger ?? NullLogger.Instance, + environmentVariables ?? new Dictionary(), cancellationToken); } - /// - /// Invokes 's launch configuration producer. - /// - /// - /// The underlying CreateLaunchConfigurationAsync overload is internal because the only legal caller is - /// the resource's own producer, so it exists for tests and for hosting integrations that ship inside this - /// repository. This wrapper lives in Aspire.Hosting.TestUtilities -- which already has - /// InternalsVisibleTo from Aspire.Hosting -- so test projects can reach it without each one - /// taking its own InternalsVisibleTo grant. Granting it directly to an integration's test project makes - /// the internal types that integration links from Aspire.Hosting (for example KnownResourceNames) - /// visible from two assemblies at once and breaks the build with CS0433. - /// public static Task InvokeLaunchConfigurationProducerAsync( IResource resource, LaunchConfigurationCallbackContext callbackContext) @@ -52,29 +31,4 @@ public static Task InvokeLaunchConfigurationProducerAsync( return resource.CreateLaunchConfigurationAsync(callbackContext); } - - public static IExecutionConfigurationResult CreateExecutionConfigurationResult( - IResource resource, - IEnumerable? arguments = null, - IEnumerable>? environmentVariables = null, - Exception? exception = null) - { - ArgumentNullException.ThrowIfNull(resource); - - return new ExecutionConfigurationResult - { - Resource = resource, - References = [], - ArgumentsWithUnprocessed = (arguments ?? []) - .Select(value => ((object)value, value, false)) - .ToArray(), - EnvironmentVariablesWithUnprocessed = (environmentVariables ?? []) - .Select(pair => new KeyValuePair( - pair.Key, - (pair.Value, pair.Value))) - .ToArray(), - AdditionalConfigurationData = [], - Exception = exception - }; - } } diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 33906346f12..452ed5339e6 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -444,72 +444,7 @@ public async Task RunApplicationAsync_AllowsContainerNameMatchingContainerTunnel } [Fact] - public async Task ProjectReplicas_CreateFreshLaunchConfigurationContexts() - { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - - var launchContexts = new ConcurrentQueue(); - var project = builder.AddProject("ServiceA") - .WithReplicas(2) - .WithEnvironment("REPLICA_VALUE", "resolved") - .WithDebugSupport( - context => - { - launchContexts.Enqueue(context); - return Task.FromResult( - ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); - }, - KnownLaunchConfigurationTypes.Project); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] - }), - [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); - - await appExecutor.RunApplicationAsync(); - - var executables = GetCreatedExecutablesForResource(kubernetesService, project.Resource.Name); - Assert.Equal(2, executables.Count); - Assert.All(executables, executable => - { - Assert.Equal(ExecutionType.IDE, executable.Spec.ExecutionType); - Assert.True(executable.TryGetProjectLaunchConfiguration(out var launchConfiguration)); - Assert.NotNull(launchConfiguration); - }); - - var contexts = launchContexts.ToArray(); - Assert.Equal(2, contexts.Length); - Assert.NotSame(contexts[0], contexts[1]); - Assert.NotSame(contexts[0].OriginalExecutionConfiguration, contexts[1].OriginalExecutionConfiguration); - Assert.All(contexts, context => Assert.Same(project.Resource, context.Resource)); - Assert.All( - contexts, - context => Assert.Contains( - context.OriginalExecutionConfiguration.EnvironmentVariables, - pair => pair is { Key: "REPLICA_VALUE", Value: "resolved" })); - } - - [Fact] - public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchContext() + public async Task ResourceRestarted_EnvironmentCallbacksApplied() { var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions { @@ -517,25 +452,16 @@ public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchConte }); var callCount = 0; - var launchContexts = new ConcurrentQueue(); - var project = builder.AddProject("ServiceA") + var resource = builder.AddProject("ServiceA") .WithArgs(c => { c.Args.Add("--test"); }) .WithEnvironment(c => { - var currentCall = Interlocked.Increment(ref callCount); - c.EnvironmentVariables["CALL_COUNT"] = currentCall.ToString(); - }) - .WithDebugSupport( - context => - { - launchContexts.Enqueue(context); - return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); - }, - KnownLaunchConfigurationTypes.Project); - var resource = project.Resource; + Interlocked.Increment(ref callCount); + c.EnvironmentVariables["CALL_COUNT"] = callCount.ToString(); + }).Resource; var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -555,39 +481,23 @@ public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchConte }); var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = [KnownLaunchConfigurationTypes.Project] - }), - [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug - }) - .Build(); - var appExecutor = CreateAppExecutor( - distributedAppModel, - kubernetesService: kubernetesService, - dcpOptions: dcpOptions, - events: events, - configuration: configuration); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events); await appExecutor.RunApplicationAsync(); var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - var firstExecutable = Assert.Single(executables); - var callCount1 = firstExecutable.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); + var exe1 = Assert.Single(executables); + var callCount1 = exe1.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); Assert.Equal("1", callCount1.Value); - Assert.Single(firstExecutable.Spec.Args!, a => a == "--test"); - Assert.True(firstExecutable.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var firstArgumentAnnotations)); - Assert.Single(firstArgumentAnnotations, a => a.Argument == "--test"); - AssertEffectiveArgumentIndexesMatchSpecArgs(firstArgumentAnnotations, firstExecutable.Spec.Args); + Assert.Single(exe1.Spec.Args!, a => a == "--no-build"); + Assert.Single(exe1.Spec.Args!, a => a == "--test"); + Assert.True(exe1.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations1)); + Assert.Single(argAnnotations1, a => a.Argument == "--test"); + AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations1, exe1.Spec.Args); Assert.Equal(1, connectionStringAvailableCount); - var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); + var reference = appExecutor.GetResource(exe1.Metadata.Name); await appExecutor.StopResourceAsync(reference, CancellationToken.None); @@ -596,158 +506,16 @@ public async Task ResourceRestarted_RebuildsExecutionConfigurationAndLaunchConte executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); Assert.Equal(2, executables.Count); - var secondExecutable = executables[1]; - var callCount2 = secondExecutable.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); + var exe2 = executables[1]; + var callCount2 = exe2.Spec.Env!.Single(e => e.Name == "CALL_COUNT"); Assert.Equal("2", callCount2.Value); - Assert.Single(secondExecutable.Spec.Args!, a => a == "--test"); - Assert.True(secondExecutable.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var secondArgumentAnnotations)); - Assert.Single(secondArgumentAnnotations, a => a.Argument == "--test"); - AssertEffectiveArgumentIndexesMatchSpecArgs(secondArgumentAnnotations, secondExecutable.Spec.Args); + Assert.Single(exe2.Spec.Args!, a => a == "--no-build"); + Assert.Single(exe2.Spec.Args!, a => a == "--test"); + Assert.True(exe2.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations2)); + Assert.Single(argAnnotations2, a => a.Argument == "--test"); + AssertEffectiveArgumentIndexesMatchSpecArgs(argAnnotations2, exe2.Spec.Args); Assert.Equal(2, connectionStringAvailableCount); - - Assert.True(secondExecutable.TryGetProjectLaunchConfiguration(out var secondLaunchConfiguration)); - Assert.NotNull(secondLaunchConfiguration); - - var contexts = launchContexts.ToArray(); - Assert.Equal(2, contexts.Length); - Assert.NotSame(contexts[0], contexts[1]); - Assert.NotSame(contexts[0].OriginalExecutionConfiguration, contexts[1].OriginalExecutionConfiguration); - Assert.Equal( - "1", - contexts[0].OriginalExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "CALL_COUNT") - .Value); - Assert.Equal( - "2", - contexts[1].OriginalExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "CALL_COUNT") - .Value); - } - - [Fact] - public async Task ResourceRestarted_RetriesLaunchConfigurationAfterTransientProducerFailure() - { - var builder = DistributedApplication.CreateBuilder(); - var producerCallCount = 0; - var resource = builder.AddExecutable("app", "command", ".") - .WithDebugSupport( - context => - { - if (Interlocked.Increment(ref producerCallCount) == 1) - { - throw new InvalidOperationException("transient producer failure"); - } - - return Task.FromResult( - new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test") - .Resource; - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [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); - - await appExecutor.RunApplicationAsync(); - - var firstExecutable = Assert.Single( - GetCreatedExecutablesForResource(kubernetesService, resource.Name)); - Assert.Equal(ExecutionType.Process, firstExecutable.Spec.ExecutionType); - - var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); - await appExecutor.StopResourceAsync(reference, CancellationToken.None); - await appExecutor.StartResourceAsync(reference, CancellationToken.None); - - var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - Assert.Equal(2, executables.Count); - var secondExecutable = executables[1]; - Assert.Equal(2, producerCallCount); - Assert.Equal(ExecutionType.IDE, secondExecutable.Spec.ExecutionType); - Assert.True(secondExecutable.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigurations)); - Assert.Single(launchConfigurations); - } - - [Fact] - public async Task ProjectResourceRestarted_RebuildsArgumentsForIdeAfterTransientProducerFailure() - { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - var producerCallCount = 0; - var resource = builder.AddProject("ServiceA", launchProfileName: "http") - .WithArgs("--apphost") - .WithDebugSupport( - context => - { - if (Interlocked.Increment(ref producerCallCount) == 1) - { - throw new InvalidOperationException("transient producer failure"); - } - - return Task.FromResult( - new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test") - .Resource; - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [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); - - await appExecutor.RunApplicationAsync(); - - var firstExecutable = Assert.Single( - GetCreatedExecutablesForResource(kubernetesService, resource.Name)); - Assert.Equal(ExecutionType.Process, firstExecutable.Spec.ExecutionType); - - var reference = appExecutor.GetResource(firstExecutable.Metadata.Name); - await appExecutor.StopResourceAsync(reference, CancellationToken.None); - await appExecutor.StartResourceAsync(reference, CancellationToken.None); - - var executables = GetCreatedExecutablesForResource(kubernetesService, resource.Name); - Assert.Equal(2, executables.Count); - var secondExecutable = executables[1]; - Assert.Equal(2, producerCallCount); - Assert.Equal(ExecutionType.IDE, secondExecutable.Spec.ExecutionType); - Assert.Equal(["--apphost"], secondExecutable.Spec.Args); } [Fact] @@ -3550,14 +3318,12 @@ public async Task ProjectLaunchConfiguration_UsesProjectDebugSupportProducer_InD projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - static _ => Task.FromResult(new ProjectLaunchConfiguration - { - Mode = ExecutableLaunchMode.NoDebug, - ProjectPath = "ProducerSuppliedPath", - DisableLaunchProfile = true - }), - "project"); + projectBuilder.WithDebugSupport(_ => new ProjectLaunchConfiguration + { + Mode = ExecutableLaunchMode.NoDebug, + ProjectPath = "ProducerSuppliedPath", + DisableLaunchProfile = true + }, "project"); using var app = builder.Build(); var model = app.Services.GetRequiredService(); @@ -3811,46 +3577,40 @@ public async Task ProjectLaunchConfiguration_FallbackToFirstProfileInsertionOrde } [Fact] - public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExecutionConfiguration() + public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnvironmentVariables() { var builder = DistributedApplication.CreateBuilder(); - var environmentCallbackCount = 0; - EnvironmentCallbackContext? environmentContext = null; LaunchConfigurationCallbackContext? launchContext = null; + 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) - .WithEnvironment(context => - { - Interlocked.Increment(ref environmentCallbackCount); - environmentContext = context; - context.EnvironmentVariables["DEBUG_VALUE"] = "resolved"; - }) + .WithArgs("app-arg") + .WithEnvironment("DEBUG_VALUE", "resolved") .WithDebugSupport( context => { launchContext = context; - var debugValue = context.OriginalExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "DEBUG_VALUE") - .Value; - - return Task.FromResult(new TestExecutionConfigurationLaunchConfiguration + return Task.FromResult(new ExecutableLaunchConfiguration("test") { - Mode = context.Mode, - DebugValue = debugValue + Mode = context.Mode }); }, - "test"); + "test", + argsCallback: context => context.Args.Add("debug-arg")); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), + [KnownConfigNames.DebugSessionInfo] = debugSessionInfo, [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug }) .Build(); @@ -3866,77 +3626,14 @@ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedExe await appExecutor.RunApplicationAsync(cts.Token); - Assert.Equal(1, environmentCallbackCount); - Assert.NotNull(environmentContext); Assert.NotNull(launchContext); + Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode); Assert.Same(resource, launchContext.Resource); - Assert.Same(environmentContext.Resource, launchContext.Resource); - Assert.Same(environmentContext.ExecutionContext, launchContext.ExecutionContext); - Assert.Same(environmentContext.Logger, launchContext.Logger); - Assert.Equal(environmentContext.CancellationToken, launchContext.CancellationToken); Assert.Equal(cts.Token, launchContext.CancellationToken); + Assert.Equal("resolved", launchContext.EnvironmentVariables["DEBUG_VALUE"]); var executable = GetCreatedExecutableForResource(kubernetesService, resource.Name); - Assert.Contains(executable.Spec.Env!, variable => variable is { Name: "DEBUG_VALUE", Value: "resolved" }); - Assert.True(executable.TryGetAnnotationAsObjectList( - Executable.LaunchConfigurationsAnnotation, - out var launchConfigurations)); - var launchConfiguration = Assert.Single(launchConfigurations); - Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); - Assert.Equal("resolved", launchConfiguration.DebugValue); - } - - [Fact] - public async Task PlainExecutable_ExecutionConfigurationFailureDoesNotInvokeLaunchProducer() - { - var builder = DistributedApplication.CreateBuilder(); - var producerCalled = false; - var resource = new TestExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithEnvironment( - (EnvironmentCallbackContext _) => - throw new InvalidOperationException("environment failed")) - .WithDebugSupport( - context => - { - producerCalled = true; - return Task.FromResult( - new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }) - }) - .Build(); - var failedResources = new ConcurrentQueue(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Enqueue(context.Resource); - return Task.CompletedTask; - }); - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor( - distributedAppModel, - kubernetesService: kubernetesService, - configuration: configuration, - events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.False(producerCalled); - Assert.Empty(kubernetesService.CreatedResources.OfType()); - Assert.Same(resource, Assert.Single(failedResources)); + Assert.Equal(["app-arg", "debug-arg"], executable.Spec.Args); } [Fact] @@ -3947,9 +3644,7 @@ public async Task PlainExecutable_ExtensionMode_SupportedDebugMode_RunsInIde() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); // No SupportsDebuggingAnnotation for this one @@ -4008,9 +3703,7 @@ public async Task PersistentPlainExecutable_ExtensionMode_RunsInProcess() var executable = new TestExecutableResource("test-working-directory"); builder.AddResource(executable) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test") + .WithDebugSupport(mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test") .WithPersistentLifetime(); var configDict = new Dictionary @@ -4056,7 +3749,7 @@ public async Task ProjectResource_WithArgumentRewritingDebugSupport_DoesNotOffer } projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), + mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test", argsCallback: _ => { /* rewrites arguments for debugging */ }); @@ -4322,7 +4015,7 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() using var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); using var aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store"); - using var certificate = CreateTestCertificateWithPrivateKey(); + using var certificate = CreateTestCertificate(); var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") .WithCertificate(certificate); @@ -4374,7 +4067,7 @@ public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownD Assert.NotEmpty(expectedWellKnownCertificateDirectories); var builder = DistributedApplication.CreateBuilder(); - using var certificate = CreateTestCertificateWithPrivateKey(); + using var certificate = CreateTestCertificate(); var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") .WithCertificate(certificate); @@ -4500,9 +4193,7 @@ public async Task PlainExecutable_ExtensionMode_UnsupportedDebugMode_RunsInProce // Create executable resources with SupportsDebuggingAnnotation var executable = new TestExecutableResource("test-working-directory"); - builder.AddResource(executable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(executable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); // Simulate debug session port and extension endpoint (extension mode) var configDict = new Dictionary @@ -4538,9 +4229,7 @@ public async Task PlainExecutable_NoExtensionMode_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); var nonDebuggableExecutable = new TestOtherExecutableResource("test-working-directory-2"); builder.AddResource(nonDebuggableExecutable); @@ -4582,9 +4271,7 @@ public async Task CustomExecutable_NoDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); // Simulate no debug session port and no extension endpoint (no debug session info) var configDict = new Dictionary @@ -4620,9 +4307,7 @@ public async Task CustomExecutable_InvalidDebugSessionInfo_RunInProcess() // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); // Simulate debug session port with invalid JSON in DebugSessionInfo var configDict = new Dictionary @@ -4658,9 +4343,7 @@ public async Task CustomExecutable_DebugSessionInfoWithNullSupportedLaunchConfig // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); // Simulate debug session info with null SupportedLaunchConfigurations var runSessionInfo = new RunSessionInfo @@ -4702,9 +4385,7 @@ public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProces // Create executable resources with SupportsDebuggingAnnotation var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); // Simulate debug session info with SupportedLaunchConfigurations that do not match the executable type var runSessionInfo = new RunSessionInfo @@ -4739,40 +4420,29 @@ public async Task CustomExecutable_DebugSessionInfoNotContainingType_RunInProces } [Fact] - public async Task DebugArgumentRewriting_ReusesOriginalResolutionForArgumentsTheCallbackKept() + public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() { - // The debug rewrite runs after the executable's arguments have already been resolved once. Arguments - // the callback keeps are the very same IValueProvider instances, and IValueProvider carries no - // idempotence guarantee - a second GetValueAsync may return a different value or repeat a side - // effect. Carrying the original resolution forward by reference identity is what keeps the two - // resolutions from diverging. + // Arrange var builder = DistributedApplication.CreateBuilder(); + // Create executable resources with SupportsDebuggingAnnotation + var debuggableExecutable = new TestExecutableResource("test-working-directory"); + builder.AddResource(debuggableExecutable).WithDebugSupport(_ => new ExecutableLaunchConfiguration("test"), "test"); + + // Simulate debug session info with SupportedLaunchConfigurations that match the executable type + var runSessionInfo = new RunSessionInfo + { + ProtocolsSupported = ["test"], + SupportedLaunchConfigurations = ["test"] + }; + var configDict = new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" }; - // The debug args callback re-checks SupportsDebugging against the application builder's own - // configuration, so the debug session has to be visible there and not only to the executor. - builder.Configuration.AddInMemoryCollection(configDict); - - var countingArgument = new CountingValueProvider("resolved-once"); - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs(context => context.Args.Add(countingArgument)) - .WithDebugSupport( - static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: context => context.Args.Insert(0, "--debug")); - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); var kubernetesService = new TestKubernetesService(); @@ -4780,281 +4450,19 @@ public async Task DebugArgumentRewriting_ReusesOriginalResolutionForArgumentsThe var distributedAppModel = app.Services.GetRequiredService(); var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + // Act await appExecutor.RunApplicationAsync(); - var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); + // Assert + var dcpExes = kubernetesService.CreatedResources.OfType().ToList(); + Assert.Single(dcpExes); - Assert.Equal(1, countingArgument.ResolutionCount); - Assert.Equal(["--debug", "resolved-once"], exe.Spec.Args); + var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable"); + Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); } [Fact] - public async Task DebugArgumentRewriting_KeepsPerOccurrenceResolutionsWhenOneProviderInstanceIsRepeated() - { - // The same IValueProvider instance can legitimately appear more than once in a command line, and - // IValueProvider carries no idempotence guarantee, so each occurrence has its own resolved value. - // Carrying the original resolutions forward has to be per occurrence: collapsing them by reference - // would replay the first occurrence's value at every position. - var builder = DistributedApplication.CreateBuilder(); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - - builder.Configuration.AddInMemoryCollection(configDict); - - var repeatedArgument = new SequentialValueProvider("resolved"); - - IExecutionConfigurationResult? originalConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs(context => - { - context.Args.Add(repeatedArgument); - context.Args.Add(repeatedArgument); - }) - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Insert(0, "--debug")); - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); - - Assert.Equal(2, repeatedArgument.ResolutionCount); - Assert.NotNull(originalConfiguration); - Assert.Equal(["resolved-1", "resolved-2"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["--debug", "resolved-1", "resolved-2"], exe.Spec.Args); - } - - [Fact] - public async Task DebugArgumentRewriting_CallbackSeesArgumentsThatFailedToResolve() - { - // The rewrite decides which arguments reach the executable, so it has to see every gathered argument - - // including one whose resolution failed. Removing that argument is what lets the executable start, so - // hiding it from the callback would make the failure unrecoverable. - var builder = DistributedApplication.CreateBuilder(); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - - builder.Configuration.AddInMemoryCollection(configDict); - - var failingArgument = new ThrowingValueProvider("Argument resolution failed."); - List? observedCallbackArgs = null; - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs(context => - { - context.Args.Add("keep"); - context.Args.Add(failingArgument); - }) - .WithDebugSupport( - static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: context => - { - observedCallbackArgs = [.. context.Args]; - context.Args.Remove(failingArgument); - }); - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(observedCallbackArgs); - Assert.Equal(["keep", failingArgument], observedCallbackArgs); - - var exe = Assert.Single(kubernetesService.CreatedResources.OfType(), e => e.AppModelResourceName == "TestExecutable"); - Assert.Equal(["keep"], exe.Spec.Args); - } - - [Fact] - public async Task DebugArgumentRewriting_RetainsResolutionFailuresForArgumentsTheCallbackKept() - { - // The mirror of the test above: an argument whose resolution failed and that the rewrite kept still - // belongs to the executable, so its failure must still stop the executable from being created. - var builder = DistributedApplication.CreateBuilder(); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - - builder.Configuration.AddInMemoryCollection(configDict); - - var failingArgument = new ThrowingValueProvider("Argument resolution failed."); - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs(context => - { - context.Args.Add("keep"); - context.Args.Add(failingArgument); - }) - .WithDebugSupport( - static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: static context => context.Args.Insert(0, "--debug")); - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - var failedResources = new List(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Add(context.Resource); - return Task.CompletedTask; - }); - - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.Empty(kubernetesService.CreatedResources.OfType()); - Assert.Same(debuggableExecutable, Assert.Single(failedResources)); - } - - [Fact] - public async Task DebugArgumentRewriting_RetainsResolutionFailuresForArgumentsTheCallbackIntroduced() - { - // An argument the rewrite adds has no earlier resolution, so it is resolved here for the first time. - // A failure at that point belongs to the executable just as much as one carried over from the original - // resolution does. - var builder = DistributedApplication.CreateBuilder(); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - - builder.Configuration.AddInMemoryCollection(configDict); - - var failingArgument = new ThrowingValueProvider("Argument resolution failed."); - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("keep") - .WithDebugSupport( - static context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: context => context.Args.Add(failingArgument)); - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - var failedResources = new List(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Add(context.Resource); - return Task.CompletedTask; - }); - - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.Empty(kubernetesService.CreatedResources.OfType()); - Assert.Same(debuggableExecutable, Assert.Single(failedResources)); - } - - [Fact] - public async Task CustomExecutable_DebugSessionInfoContainsType_RunInIde() - { - // Arrange - var builder = DistributedApplication.CreateBuilder(); - - // Create executable resources with SupportsDebuggingAnnotation - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("test")), - "test"); - - // Simulate debug session info with SupportedLaunchConfigurations that match the executable type - var runSessionInfo = new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }; - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - // Act - await appExecutor.RunApplicationAsync(); - - // Assert - var dcpExes = kubernetesService.CreatedResources.OfType().ToList(); - Assert.Single(dcpExes); - - var exe = Assert.Single(dcpExes, e => e.AppModelResourceName == "TestExecutable"); - Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); - } - - [Fact] - public async Task ProjectExecutable_NoDebugSessionInfo_DefaultsToProjectSupport() + public async Task ProjectExecutable_NoDebugSessionInfo_DefaultsToProjectSupport() { // Arrange var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions @@ -5097,11 +4505,7 @@ public async Task Project_WithTerminal_RunsAsProcess_InDebugSessionWhenDebugSupp var debugArgsCallbackInvoked = false; var resource = builder.AddProject("ServiceA").WithTerminal(); resource.WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - ProjectPath = "/test/path", - Mode = context.Mode - }), + mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode }, "project", argsCallback: _ => debugArgsCallbackInvoked = true); @@ -5296,9 +4700,7 @@ public async Task ProjectWithNonProjectAnnotation_DebugSessionWithoutInfo_FallsB { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var configDict = new Dictionary { @@ -5338,9 +4740,7 @@ public async Task ProjectWithNonProjectAnnotation_VSCodeExplicitlyUnsupported_Ru { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -5396,9 +4796,7 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_VSCodeE Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), - launchConfigurationType) + .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) .WithArgs(resourceArgs); var runSessionInfo = new RunSessionInfo @@ -5458,9 +4856,7 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebug Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), - launchConfigurationType) + .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) .WithArgs(resourceArgs); var configDict = new Dictionary @@ -5512,9 +4908,7 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_NoDebug Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = context.Mode }), - launchConfigurationType) + .WithDebugSupport(mode => new ExecutableLaunchConfiguration(launchConfigurationType) { Mode = mode }, launchConfigurationType) .WithArgs(resourceArgs); var kubernetesService = new TestKubernetesService(); @@ -5558,20 +4952,18 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport( - context => Task.FromResult(new TestMauiLaunchConfiguration + .WithDebugSupport(mode => new TestMauiLaunchConfiguration + { + Mode = mode, + ProjectPath = "/tmp/mauiapp/MauiApp.csproj", + TargetFramework = "net10.0-android", + Platform = "android", + TargetKind = "emulator", + MsBuildProperties = new Dictionary { - Mode = context.Mode, - ProjectPath = "/tmp/mauiapp/MauiApp.csproj", - TargetFramework = "net10.0-android", - Platform = "android", - TargetKind = "emulator", - MsBuildProperties = new Dictionary - { - ["AdbTarget"] = "-e" - } - }), - "maui") + ["AdbTarget"] = "-e" + } + }, "maui") .WithArgs("run", "-f", "net10.0-android", "-p:AdbTarget=-e"); var runSessionInfo = new RunSessionInfo @@ -5579,6 +4971,7 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura ProtocolsSupported = ["coreclr"], SupportedLaunchConfigurations = ["maui"] }; + var configDict = new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", @@ -5627,22 +5020,19 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura [Fact] public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_StillAppliesMauiLaunchConfiguration() { - // MAUI platform resources are ProjectResources that carry BOTH a ProjectLaunchArgsOverrideAnnotation - // (MauiPlatformHelper.ConfigurePlatformResource) and a "maui" SupportsDebuggingAnnotation - // (MauiPlatformHelper.WithMauiIdeLaunchConfiguration). The override pins the executable to Process - // execution, but the "maui" producer must still run so the IDE receives the MAUI launch configuration - // instead of the generic "project" one written by PrepareProjectExecutables. var builder = DistributedApplication.CreateBuilder(); - var projectBuilder = builder.AddProject("proj", launchProfileName: null); - var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); - if (annotationToRemove is not null) + var defaultDebugSupport = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); + if (defaultDebugSupport is not null) { - projectBuilder.Resource.Annotations.Remove(annotationToRemove); + 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")); + projectBuilder.Resource.Annotations.Add( + new ProjectLaunchArgsOverrideAnnotation( + ["build", "--no-restore", "/t:Run", "-p:NoBuild=true"], + leadingResourceArgumentToRemove: "run")); #pragma warning restore ASPIREPROJECTS001 projectBuilder @@ -5653,237 +5043,41 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati ProjectPath = "/mauiapp/MauiApp.csproj", TargetFramework = "net10.0-android", Platform = "android", - TargetKind = "emulator", - MsBuildProperties = new Dictionary - { - ["AdbTarget"] = "-e" - } + TargetKind = "emulator" }), "maui") .WithArgs("run", "-f", "net10.0-android"); - var runSessionInfo = new RunSessionInfo - { - ProtocolsSupported = ["coreclr"], - SupportedLaunchConfigurations = ["maui"] - }; - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }; - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo + { + ProtocolsSupported = ["coreclr"], + SupportedLaunchConfigurations = ["maui"] + }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", + [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug + }) + .Build(); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; - var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions); + var appExecutor = CreateAppExecutor( + app.Services.GetRequiredService(), + kubernetesService: kubernetesService, + configuration: configuration); await appExecutor.RunApplicationAsync(); - var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); - - // The launch args override still owns execution: the resource runs 'dotnet build /t:Run' as a process. - Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); - - 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, exe.Spec.Args); - - Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs)); - var launchConfig = Assert.Single(launchConfigs); - Assert.Equal("maui", launchConfig.Type); - Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode); - Assert.Equal("/mauiapp/MauiApp.csproj", launchConfig.ProjectPath); - Assert.Equal("net10.0-android", launchConfig.TargetFramework); - Assert.Equal("android", launchConfig.Platform); - Assert.Equal("emulator", launchConfig.TargetKind); - Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); - } - - [Fact] - public async Task ProjectWithLaunchArgsOverrideAndRewritingNonProjectDebugSupport_LaunchConfigFailure_FallsBackToProcess() - { - // The Process fallback is denied when the debug rewrite replaced the spec's command line, because running - // the rewritten command "as is" would launch something broken. A launch-args override suppresses that - // rewrite, so the spec still holds the user's real command line and the fallback is safe -- keying the - // guard on the annotation alone denied the fallback to exactly the resources that could still use it, and - // a MAUI resource whose producer threw failed to start instead of running as a process. - var builder = DistributedApplication.CreateBuilder(); - - var projectBuilder = builder.AddProject("proj", launchProfileName: null); - var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); - if (annotationToRemove is not null) - { - projectBuilder.Resource.Annotations.Remove(annotationToRemove); - } - -#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 - - projectBuilder - .WithArgs("run", "-f", "net10.0-android") - .WithDebugSupport( - ThrowingLaunchConfiguration, - "maui", - argsCallback: static context => context.Args.Clear()); - - var runSessionInfo = new RunSessionInfo - { - ProtocolsSupported = ["coreclr"], - SupportedLaunchConfigurations = ["maui"] - }; - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(runSessionInfo), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }; - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - var failedResources = new List(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Add(context.Resource); - return Task.CompletedTask; - }); - - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; - var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions, events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.Empty(failedResources); - - var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); - Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); - - // The override's command line survived the failed producer, so the fallback launches the real command. - 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, exe.Spec.Args); - - static Task ThrowingLaunchConfiguration(LaunchConfigurationCallbackContext context) - { - throw new InvalidOperationException("Launch configuration failed."); - } - } - - [Fact] - public async Task ProjectWithLaunchArgsOverrideAndRewritingNonProjectDebugSupport_DoesNotRewriteProcessArgs() - { - var builder = DistributedApplication.CreateBuilder(); - - var projectBuilder = builder.AddProject("proj", launchProfileName: null); - var annotationToRemove = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); - if (annotationToRemove is not null) - { - projectBuilder.Resource.Annotations.Remove(annotationToRemove); - } - -#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 runSessionInfo = new RunSessionInfo - { - ProtocolsSupported = ["coreclr"], - SupportedLaunchConfigurations = ["maui"] - }; - var debugSessionInfoJson = JsonSerializer.Serialize(runSessionInfo); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - projectBuilder - .WithArgs("run", "-f", "net10.0-android") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new TestMauiLaunchConfiguration - { - Mode = context.Mode, - ProjectPath = "/mauiapp/MauiApp.csproj", - TargetFramework = "net10.0-android", - Platform = "android", - TargetKind = "emulator" - }); - }, - "maui", - argsCallback: static context => context.Args.Clear()); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }; - - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - Assert.True(projectBuilder.Resource.SupportsDebugging(configuration, out var supportsDebuggingAnnotation)); - Assert.NotNull(supportsDebuggingAnnotation.DebugCommandLineArgsCallbackAnnotation); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var distributedApplicationOptions = new DistributedApplicationOptions { AssemblyName = typeof(DcpExecutorTests).Assembly.FullName }; - var expectedConfiguration = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof(DcpExecutorTests).Assembly)?.Configuration; - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, distributedApplicationOptions: distributedApplicationOptions); - - await appExecutor.RunApplicationAsync(); - - var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); - - Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["run", "-f", "net10.0-android"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["run", "-f", "net10.0-android"], executableConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Same(originalConfiguration, executableConfiguration); - - 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, exe.Spec.Args); - - Assert.True(exe.TryGetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, out var launchConfigs)); - var launchConfig = Assert.Single(launchConfigs); - Assert.Equal("maui", launchConfig.Type); - Assert.Equal(ExecutableLaunchMode.Debug, launchConfig.Mode); - } + var executable = GetCreatedExecutableForResource(kubernetesService, "proj"); + Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType); + Assert.True(executable.TryGetAnnotationAsObjectList( + Executable.LaunchConfigurationsAnnotation, + out var launchConfigurations)); + Assert.Equal("/mauiapp/MauiApp.csproj", Assert.Single(launchConfigurations).ProjectPath); + } [Fact] public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchProfileArgsStayAfterDotnetRunArgs() @@ -5902,9 +5096,7 @@ public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchP Command = "dotnet", WorkingDirectory = "/tmp/mauiapp" }) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("maui") { Mode = context.Mode }), - "maui") + .WithDebugSupport(mode => new ExecutableLaunchConfiguration("maui") { Mode = mode }, "maui") .WithArgs("run", "-f", "net10.0-ios", "-p:_DeviceName=:v2:udid=E25BBE37-69BA-4720-B6FD-D54C97791E79"); var runSessionInfo = new RunSessionInfo @@ -5964,9 +5156,7 @@ public async Task ProjectWithNonProjectAnnotation_NoDebugSession_RunsInProcess() { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -5993,9 +5183,7 @@ public async Task ProjectWithNonProjectAnnotation_VSCodeWithMatchingSupport_Runs { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -6042,9 +5230,7 @@ public async Task StandardAndCustomProjects_VSScenario_BothRunInIde() { customProject.Resource.Annotations.Remove(annotationToRemove); } - customProject.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var configDict = new Dictionary { @@ -6094,9 +5280,7 @@ public async Task StandardAndCustomProjects_VSCodeScenario_BothRunInIde() { customProject.Resource.Annotations.Remove(annotationToRemove); } - customProject.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + customProject.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var runSessionInfo = new RunSessionInfo { @@ -6142,9 +5326,7 @@ public async Task ProjectWithNonProjectAnnotation_VSFallback_HasProcessFallbackE { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var configDict = new Dictionary { @@ -6255,8 +5437,8 @@ public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_InDebugSession [Fact] public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate() { - // Regression guard for the async launch configuration producer. All custom producers run from - // CreateObjectAsync after endpoints and execution configuration resolve. + // 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 @@ -6264,15 +5446,11 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null); projectBuilder.WithDebugSupport( - async context => + async (mode, _) => { + // Yield so the producer completes asynchronously rather than returning an already-completed task. await Task.Yield(); - return new ProjectLaunchConfiguration - { - ProjectPath = "AsyncProducerPath", - Mode = context.Mode, - LaunchProfile = "async-profile" - }; + return new ProjectLaunchConfiguration { ProjectPath = "AsyncProducerPath", Mode = mode, LaunchProfile = "async-profile" }; }, KnownLaunchConfigurationTypes.Project); @@ -6303,16 +5481,17 @@ public async Task ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDu [Fact] public async Task PlainExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate() { - // Like ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringCreate, this producer - // runs from CreateObjectAsync after endpoints and execution configuration resolve. + // The companion to ProjectExecutable_AsyncLaunchConfigurationProducer_IsAwaitedDuringPrepare: a + // non-"project" launch configuration is applied when the Executable is created (after endpoints are + // allocated), which is the other producer call site. var builder = DistributedApplication.CreateBuilder(); var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - async context => + async (mode, _) => { await Task.Yield(); - return new ExecutableLaunchConfiguration("test") { Mode = context.Mode }; + return new ExecutableLaunchConfiguration("test") { Mode = mode }; }, "test"); @@ -6349,7 +5528,7 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FallsBa var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - async _ => + async (_, _) => { await Task.Yield(); throw new InvalidOperationException("Test exception from async launch configuration producer"); @@ -6375,56 +5554,6 @@ public async Task PlainExecutable_AsyncLaunchConfigurationProducerFaults_FallsBa Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); } - [Fact] - public async Task PlainExecutable_LaunchConfigurationProducerCancellation_DoesNotFallBackToProcess() - { - var builder = DistributedApplication.CreateBuilder(); - using var cancellationSource = new CancellationTokenSource(); - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable).WithDebugSupport( - context => - { - cancellationSource.Cancel(); - return Task.FromCanceled(context.CancellationToken); - }, - "test"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["test"], - SupportedLaunchConfigurations = ["test"] - }) - }) - .Build(); - - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - ExecutableCreator? executableCreator = null; - _ = CreateAppExecutor( - distributedAppModel, - kubernetesService: new TestKubernetesService(), - configuration: configuration, - executableCreatorCreated: creator => executableCreator = creator); - Assert.NotNull(executableCreator); - - var renderedExecutable = Assert.Single( - executableCreator.PrepareObjects(CancellationToken.None)); - var objectFactory = new RecordingDcpObjectFactory(); - await Assert.ThrowsAnyAsync( - () => executableCreator.CreateObjectAsync( - renderedExecutable, - EmptyCreationContext.s_instance, - NullLogger.Instance, - objectFactory, - cancellationSource.Token)); - Assert.Equal(0, objectFactory.CreateDcpObjectsCallCount); - } - [Fact] public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsInProcessMode() { @@ -6434,14 +5563,6 @@ public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsIn }); var projectBuilder = builder.AddProject("ServiceA", launchProfileName: null); - var launchConfigurationProducerCalled = false; - projectBuilder.WithDebugSupport( - context => - { - launchConfigurationProducerCalled = true; - return Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)); - }, - KnownLaunchConfigurationTypes.Project); #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", "/t:Run"])); #pragma warning restore ASPIREPROJECTS001 @@ -6464,7 +5585,6 @@ public async Task ProjectExecutable_WithLaunchArgsOverride_InDebugSession_RunsIn var exe = GetCreatedExecutableForResource(kubernetesService, "ServiceA"); Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); Assert.Null(exe.Spec.FallbackExecutionTypes); - Assert.False(launchConfigurationProducerCalled); Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceProjectArgsAnnotation, out var projectArgs)); Assert.Collection( @@ -6713,682 +5833,19 @@ public async Task DotnetProjectExecutable_InDebugSession_GetsIdeExecutionWithPro // AddProject: IDE execution with a ProjectLaunchConfiguration (project_path + launch profile) so F5 works. var builder = DistributedApplication.CreateBuilder(); - var resource = new TestDotnetProjectExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithAnnotation(new LaunchProfileAnnotation("http")) - .WithDebugSupport( - context => Task.FromResult(ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), - KnownLaunchConfigurationTypes.Project); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); - Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); - // A "project" launch must NOT advertise a Process fallback: DCP's process runner executes - // Spec.ExecutablePath + Spec.Args and cannot reconstruct `dotnet run --project ` from the launch - // config's project_path, so a fallback would only run a bare `dotnet` and fail. IDEs that cannot launch - // the resource fail fast instead of silently mis-launching. - Assert.Null(exe.Spec.FallbackExecutionTypes); - - Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc)); - Assert.Equal("TestProjectWithLaunchSettings", plc.ProjectPath); - Assert.Equal("http", plc.LaunchProfile); - Assert.Equal(ExecutableLaunchMode.NoDebug, plc.Mode); - } - - [Fact] - public void GetResourceType_DcpExecutable_DelegatesToAppModelClassifier() - { - // Regression guard for the DCP resource-type classifier. A DotnetProjectResource is an ExecutableResource - // that carries IProjectMetadata, so DCP realizes it as an Executable (not a Container). The dashboard - // snapshot classifies it as "Project" (via ResourceExtensions.GetResourceType); DcpExecutor.GetResourceType - // must agree, otherwise the same resource reports "Executable" in DCP create/start/watch events and - // profiling telemetry while showing "Project" everywhere else. A plain ExecutableResource must still - // classify as "Executable". - var dcpExecutable = Executable.Create("test-exe", "dotnet"); - - var dotnetProject = new TestDotnetProjectExecutableResource("test-working-directory"); - dotnetProject.Annotations.Add(new TestProjectWithLaunchSettings()); - Assert.Equal(KnownResourceTypes.Project, DcpExecutor.GetResourceType(dcpExecutable, dotnetProject)); - - var plainExecutable = new TestExecutableResource("test-working-directory"); - Assert.Equal(KnownResourceTypes.Executable, DcpExecutor.GetResourceType(dcpExecutable, plainExecutable)); - - // A DotnetToolResource is also realized as a DCP Executable but the app-model classifier reports "Tool" - // so the dashboard can render it distinctly. ApplicationOrchestrator.OnResourceStarting handles "Tool" like an - // executable so the resource still transitions to the Starting state. -#pragma warning disable ASPIREDOTNETTOOL // DotnetToolResource is experimental. - var dotnetTool = new DotnetToolResource("test-tool", "SomePackage.Id"); -#pragma warning restore ASPIREDOTNETTOOL - Assert.Equal(KnownResourceTypes.Tool, DcpExecutor.GetResourceType(dcpExecutable, dotnetTool)); - } - - [Fact] - public async Task DotnetProjectExecutable_ProjectLaunchUnsupported_RunsInProcess() - { - // When the IDE does not advertise "project" support, the resource should run as a plain process with - // no ProjectLaunchConfiguration applied. - var builder = DistributedApplication.CreateBuilder(); - - var resource = new TestDotnetProjectExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - ProjectPath = "TestProjectWithLaunchSettings", - Mode = context.Mode - }), - "project"); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["python"] }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); - Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); - Assert.False(exe.TryGetProjectLaunchConfiguration(out _)); - } - - [Fact] - public async Task DotnetProjectExecutable_PersistentLifetime_InDebugSession_RunsInProcessWithoutProjectLaunchConfig() - { - var builder = DistributedApplication.CreateBuilder(); - - var resource = new TestDotnetProjectExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - ProjectPath = "TestProjectWithLaunchSettings", - Mode = context.Mode - }), - "project") - .WithPersistentLifetime(); - - var configDict = new Dictionary - { - ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); - Assert.True(exe.Spec.Persistent); - Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); - Assert.False(exe.TryGetProjectLaunchConfiguration(out _)); - } - - [Fact] - public async Task DotnetProjectExecutable_ProjectLaunchConfigurationFailure_FailsWithoutProcessFallback() - { - var builder = DistributedApplication.CreateBuilder(); - - var resource = new TestDotnetProjectExecutableResource("test-working-directory"); - builder.AddResource(resource) - .WithAnnotation(new TestProjectWithLaunchSettings()) - .WithDebugSupport(CreateProjectLaunchConfiguration, "project"); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var resourceLoggerService = new ResourceLoggerService(); - var failedResources = new List(); - var events = new DcpExecutorEvents(); - events.Subscribe(context => - { - failedResources.Add(context.Resource); - return Task.CompletedTask; - }); - - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor( - distributedAppModel, - kubernetesService: kubernetesService, - configuration: configuration, - resourceLoggerService: resourceLoggerService, - events: events); - - await appExecutor.RunApplicationAsync(); - - Assert.Empty(kubernetesService.CreatedResources.OfType()); - Assert.Same(resource, Assert.Single(failedResources)); - - var logLines = new List(); - await foreach (var lines in resourceLoggerService.GetAllAsync(resource).DefaultTimeout()) - { - logLines.AddRange(lines); - } - - Assert.Contains( - logLines, - line => line.Content.Contains( - "The \"project\" launch configuration producer for resource 'TestDotnetProject' failed.", - StringComparison.Ordinal)); - Assert.Contains( - logLines, - line => line.Content.Contains( - "Project launch configuration failed.", - StringComparison.Ordinal)); - - static Task CreateProjectLaunchConfiguration(LaunchConfigurationCallbackContext context) - { - throw new InvalidOperationException("Project launch configuration failed."); - } - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OmitsProcessFallback() - { - // A non-"project" debuggable executable whose WithDebugSupport supplies an argsCallback (e.g. Go/Python, - // which strip the process entrypoint so the IDE debugger owns it) is left with Spec.Args holding only the - // application arguments. A Process fallback would then run `ExecutablePath ` — the wrong command — - // so no Process fallback must be advertised for it. - var builder = DistributedApplication.CreateBuilder(); - - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("run", "app-arg") - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: static ctx => - { - // Mimic Go/Python stripping the process entrypoint token, leaving only the application args. - if (ctx.Args.Count > 0) - { - ctx.Args.RemoveAt(0); - } - }); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }), - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); - // Because the debug support registered an argsCallback (RewritesArgumentsForDebugging), Spec.Args can be - // rewritten to an IDE-only shape, so no Process fallback is advertised even though the launch type is not - // "project". - Assert.Null(exe.Spec.FallbackExecutionTypes); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_ProducerSeesOriginalArgsAndDcpUsesRewrittenArgs() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("run", "./cmd/api") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static ctx => - { - ctx.Args.RemoveAt(0); - ctx.Args.RemoveAt(0); - }) - .WithArgs("user-arg"); - - var configDict = new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.NotSame(originalConfiguration, executableConfiguration); - Assert.Equal(["run", "./cmd/api", "user-arg"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["user-arg"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["user-arg"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_PreservesRegistrationOrderForLaterArgs() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var laterArgsCallbackCalls = 0; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Clear()) - .WithArgs(context => - { - laterArgsCallbackCalls++; - context.Args.Add("user"); - }); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.NotSame(originalConfiguration, executableConfiguration); - Assert.Equal(1, laterArgsCallbackCalls); - Assert.Equal(["launcher", "user"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["user"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["user"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_AppliesLaterIndexedMutationsToExecutableBranch() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher", "app") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.RemoveAt(0)) - .WithArgs(static context => context.Args.RemoveAt(0)); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["app"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Empty(executableConfiguration.Arguments); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Null(exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_MapsLaterIndexedMutationToSurvivingArgument() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher", "app") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Insert(0, "debug")) - .WithArgs(static context => context.Args[1] = "app2"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["launcher", "app2"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["debug", "launcher", "app2"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["debug", "launcher", "app2"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_DoesNotClobberRewrittenArgumentOnLaterIndexedMutation() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher", "app") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - // Replaces "launcher" rather than only inserting around it, so that argument has no - // counterpart in the executable branch for the later callback to address. - argsCallback: static context => - { - context.Args.Insert(0, "debug"); - context.Args[1] = "launcher2"; - }) - .WithArgs(static context => context.Args[0] = "x"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["x", "app"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["debug", "launcher2", "app"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["debug", "launcher2", "app"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_DoesNotRemoveRewrittenArgumentOnLaterRemoveAt() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher", "app") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => - { - context.Args.Insert(0, "debug"); - context.Args[1] = "launcher2"; - }) - .WithArgs(static context => context.Args.RemoveAt(0)); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["app"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["debug", "launcher2", "app"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["debug", "launcher2", "app"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_AppendsLaterInsertAtCountAfterDebugPrefix() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher", "app") - .WithDebugSupport( - context => - { - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Insert(0, "debug")) - .WithArgs(static context => context.Args.Insert(context.Args.Count, "tail")); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); - - var kubernetesService = new TestKubernetesService(); - using var app = builder.Build(); - var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); - - await appExecutor.RunApplicationAsync(); - - Assert.NotNull(executableConfiguration); - Assert.Equal(["debug", "launcher", "app", "tail"], executableConfiguration.Arguments.Select(argument => argument.Value)); - - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["debug", "launcher", "app", "tail"], exe.Spec.Args); - } - - [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_PreservesArgumentsAddedByLaterGatherers() - { - var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - using var certificate = CreateTestCertificateWithPrivateKey(); - IExecutionConfigurationResult? originalConfiguration = null; - IExecutionConfigurationResult? executableConfiguration = null; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs("launcher") - .WithDebugSupport( - context => - { - originalConfiguration = context.OriginalExecutionConfiguration; - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Clear()) - .WithAnnotation(new HttpsCertificateAnnotation { Certificate = certificate }) - .WithAnnotation(new HttpsCertificateConfigurationCallbackAnnotation(static context => - { - context.Arguments.Add("post-debug"); - return Task.CompletedTask; - })); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); + var resource = new TestDotnetProjectExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithAnnotation(new TestProjectWithLaunchSettings()) + .WithAnnotation(new LaunchProfileAnnotation("http")) + .WithDebugSupport(mode => ProjectLaunchConfigurationFactory.Create(resource, mode), KnownLaunchConfigurationTypes.Project); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -7397,58 +5854,66 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Preser await appExecutor.RunApplicationAsync(); - Assert.NotNull(originalConfiguration); - Assert.NotNull(executableConfiguration); - Assert.Equal(["launcher", "post-debug"], originalConfiguration.Arguments.Select(argument => argument.Value)); - Assert.Equal(["post-debug"], executableConfiguration.Arguments.Select(argument => argument.Value)); + var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); + Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); + // A "project" launch must NOT advertise a Process fallback: DCP's process runner executes + // Spec.ExecutablePath + Spec.Args and cannot reconstruct `dotnet run --project ` from the launch + // config's project_path, so a fallback would only run a bare `dotnet` and fail. IDEs that cannot launch + // the resource fail fast instead of silently mis-launching. + Assert.Null(exe.Spec.FallbackExecutionTypes); - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["post-debug"], exe.Spec.Args); + Assert.True(exe.TryGetProjectLaunchConfiguration(out var plc)); + Assert.Equal("TestProjectWithLaunchSettings", plc.ProjectPath); + Assert.Equal("http", plc.LaunchProfile); + Assert.Equal(ExecutableLaunchMode.NoDebug, plc.Mode); } [Fact] - public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_PreservesLaterArgsWhenCallbackResultIsCached() + public void GetResourceType_DcpExecutable_DelegatesToAppModelClassifier() { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; + // Regression guard for the DCP resource-type classifier. A DotnetProjectResource is an ExecutableResource + // that carries IProjectMetadata, so DCP realizes it as an Executable (not a Container). The dashboard + // snapshot classifies it as "Project" (via ResourceExtensions.GetResourceType); DcpExecutor.GetResourceType + // must agree, otherwise the same resource reports "Executable" in DCP create/start/watch events and + // profiling telemetry while showing "Project" everywhere else. A plain ExecutableResource must still + // classify as "Executable". + var dcpExecutable = Executable.Create("test-exe", "dotnet"); - var launchContexts = new ConcurrentQueue(); - var projectBuilder = builder.AddProject("ServiceA") - .WithReplicas(2); - var defaultAnnotation = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); - if (defaultAnnotation is not null) - { - projectBuilder.Resource.Annotations.Remove(defaultAnnotation); - } + var dotnetProject = new TestDotnetProjectExecutableResource("test-working-directory"); + dotnetProject.Annotations.Add(new TestProjectWithLaunchSettings()); + Assert.Equal(KnownResourceTypes.Project, DcpExecutor.GetResourceType(dcpExecutable, dotnetProject)); - projectBuilder - .WithArgs("launcher") - .WithDebugSupport( - context => - { - launchContexts.Enqueue(context); - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: static context => context.Args.Clear()) - .WithArgs("user"); + var plainExecutable = new TestExecutableResource("test-working-directory"); + Assert.Equal(KnownResourceTypes.Executable, DcpExecutor.GetResourceType(dcpExecutable, plainExecutable)); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); + // A DotnetToolResource is also realized as a DCP Executable but the app-model classifier reports "Tool" + // so the dashboard can render it distinctly. ApplicationOrchestrator.OnResourceStarting handles "Tool" like an + // executable so the resource still transitions to the Starting state. +#pragma warning disable ASPIREDOTNETTOOL // DotnetToolResource is experimental. + var dotnetTool = new DotnetToolResource("test-tool", "SomePackage.Id"); +#pragma warning restore ASPIREDOTNETTOOL + Assert.Equal(KnownResourceTypes.Tool, DcpExecutor.GetResourceType(dcpExecutable, dotnetTool)); + } + + [Fact] + public async Task DotnetProjectExecutable_ProjectLaunchUnsupported_RunsInProcess() + { + // When the IDE does not advertise "project" support, the resource should run as a plain process with + // no ProjectLaunchConfiguration applied. + var builder = DistributedApplication.CreateBuilder(); + + var resource = new TestDotnetProjectExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithAnnotation(new TestProjectWithLaunchSettings()) + .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project"); + + var configDict = new Dictionary + { + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["python"] }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -7457,58 +5922,30 @@ public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_Preser await appExecutor.RunApplicationAsync(); - var executables = GetCreatedExecutablesForResource(kubernetesService, projectBuilder.Resource.Name); - Assert.Equal(2, executables.Count); - - var contexts = launchContexts.ToArray(); - Assert.Equal(2, contexts.Length); - Assert.All(contexts, context => Assert.Equal(["user"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value))); + var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + Assert.False(exe.TryGetProjectLaunchConfiguration(out _)); } [Fact] - public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_ReplaysCachedLaterArgsAgainstFreshDebugRewrite() + public async Task DotnetProjectExecutable_PersistentLifetime_InDebugSession_RunsInProcessWithoutProjectLaunchConfig() { - var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions - { - AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName - }); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - var launchContexts = new ConcurrentQueue(); - var projectBuilder = builder.AddProject("ServiceA") - .WithReplicas(2); - var defaultAnnotation = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); - if (defaultAnnotation is not null) - { - projectBuilder.Resource.Annotations.Remove(defaultAnnotation); - } + var builder = DistributedApplication.CreateBuilder(); - var debugRewriteCallCount = 0; - projectBuilder - .WithArgs("launcher") - .WithDebugSupport( - context => - { - launchContexts.Enqueue(context); - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, - "test", - argsCallback: context => context.Args.Add($"debug-{Interlocked.Increment(ref debugRewriteCallCount)}")) - .WithArgs("tail"); + var resource = new TestDotnetProjectExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithAnnotation(new TestProjectWithLaunchSettings()) + .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project") + .WithPersistentLifetime(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" - }) - .Build(); + var configDict = new Dictionary + { + ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + [DcpExecutor.DebugSessionPortVar] = "12345", + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -7517,91 +5954,96 @@ public async Task ProjectReplicas_ExtensionMode_ArgsRewritingDebugSupport_Replay await appExecutor.RunApplicationAsync(); - var contexts = launchContexts.ToArray(); - Assert.Equal(2, contexts.Length); - Assert.Collection( - contexts, - context => Assert.Equal(["launcher", "debug-1", "tail"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value)), - context => Assert.Equal(["launcher", "debug-2", "tail"], context.ExecutableExecutionConfiguration.Arguments.Select(argument => argument.Value))); + var exe = GetCreatedExecutableForResource(kubernetesService, "TestDotnetProject"); + Assert.True(exe.Spec.Persistent); + Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); + Assert.False(exe.TryGetProjectLaunchConfiguration(out _)); } [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OrdinaryArgsCallbacksRunOnce() + public async Task DotnetProjectExecutable_ProjectLaunchConfigurationFailure_FailsWithoutProcessFallback() { var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - var ordinaryArgsCallbackCalls = 0; - var debuggableExecutable = new TestExecutableResource("test-working-directory"); - builder.AddResource(debuggableExecutable) - .WithArgs(context => - { - ordinaryArgsCallbackCalls++; - context.Args.Add("run"); - context.Args.Add("app-arg"); - }) - .WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }), - "test", - argsCallback: static ctx => ctx.Args.RemoveAt(0)); + var resource = new TestDotnetProjectExecutableResource("test-working-directory"); + builder.AddResource(resource) + .WithAnnotation(new TestProjectWithLaunchSettings()) + .WithDebugSupport(CreateProjectLaunchConfiguration, "project"); var configDict = new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, - [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", - [KnownConfigNames.DebugSessionRunMode] = "Debug" + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["project"] }), + [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234" }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); var kubernetesService = new TestKubernetesService(); + using var resourceLoggerService = new ResourceLoggerService(); + var failedResources = new List(); + var events = new DcpExecutorEvents(); + events.Subscribe(context => + { + failedResources.Add(context.Resource); + return Task.CompletedTask; + }); + using var app = builder.Build(); var distributedAppModel = app.Services.GetRequiredService(); - var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + var appExecutor = CreateAppExecutor( + distributedAppModel, + kubernetesService: kubernetesService, + configuration: configuration, + resourceLoggerService: resourceLoggerService, + events: events); await appExecutor.RunApplicationAsync(); - Assert.Equal(1, ordinaryArgsCallbackCalls); + Assert.Empty(kubernetesService.CreatedResources.OfType()); + Assert.Same(resource, Assert.Single(failedResources)); - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Equal(["app-arg"], exe.Spec.Args); + var logLines = new List(); + await foreach (var lines in resourceLoggerService.GetAllAsync(resource).DefaultTimeout()) + { + logLines.AddRange(lines); + } + + Assert.Contains(logLines, line => line.Content.Contains("Project launch configuration failed.", StringComparison.Ordinal)); + + static Task CreateProjectLaunchConfiguration(string mode, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Project launch configuration failed."); + } } [Fact] - public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_ExecutableConfigurationReferencesExcludeDroppedOriginalArgs() + public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_OmitsProcessFallback() { + // A non-"project" debuggable executable whose WithDebugSupport supplies an argsCallback (e.g. Go/Python, + // which strip the process entrypoint so the IDE debugger owns it) is left with Spec.Args holding only the + // application arguments. A Process fallback would then run `ExecutablePath ` — the wrong command — + // so no Process fallback must be advertised for it. var builder = DistributedApplication.CreateBuilder(); - var debugSessionInfoJson = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }); - builder.Configuration[DcpExecutor.DebugSessionPortVar] = "12345"; - builder.Configuration[KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson; - builder.Configuration[KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234"; - builder.Configuration[KnownConfigNames.DebugSessionRunMode] = "Debug"; - - var droppedArgument = builder.AddParameter("dropped-argument", "dropped-value"); - var keptEnvironmentValue = builder.AddParameter("kept-environment", "kept-value"); - IExecutionConfigurationResult? executableConfiguration = null; var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable) - .WithArgs(droppedArgument.Resource) - .WithEnvironment("KEPT_ENVIRONMENT", keptEnvironmentValue.Resource) + .WithArgs("run", "app-arg") .WithDebugSupport( - context => - { - executableConfiguration = context.ExecutableExecutionConfiguration; - return Task.FromResult(new ExecutableLaunchConfiguration("test") { Mode = context.Mode }); - }, + mode => new ExecutableLaunchConfiguration("test") { Mode = mode }, "test", - argsCallback: static ctx => ctx.Args.Clear()); + argsCallback: static ctx => + { + // Mimic Go/Python stripping the process entrypoint token, leaving only the application args. + if (ctx.Args.Count > 0) + { + ctx.Args.RemoveAt(0); + } + }); var configDict = new Dictionary { [DcpExecutor.DebugSessionPortVar] = "12345", - [KnownConfigNames.DebugSessionInfo] = debugSessionInfoJson, + [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], SupportedLaunchConfigurations = ["test"] }), [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", [KnownConfigNames.DebugSessionRunMode] = "Debug" }; @@ -7614,14 +6056,12 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Execut await appExecutor.RunApplicationAsync(); - Assert.NotNull(executableConfiguration); - Assert.Collection( - executableConfiguration.References, - reference => Assert.Same(keptEnvironmentValue.Resource, reference)); - Assert.Empty(executableConfiguration.Arguments); - var exe = GetCreatedExecutableForResource(kubernetesService, "TestExecutable"); - Assert.Null(exe.Spec.Args); + Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); + // Because the debug support registered an argsCallback (RewritesArgumentsForDebugging), Spec.Args can be + // rewritten to an IDE-only shape, so no Process fallback is advertised even though the launch type is not + // "project". + Assert.Null(exe.Spec.FallbackExecutionTypes); } [Fact] @@ -7672,7 +6112,7 @@ public async Task PlainExecutable_ExtensionMode_ArgsRewritingDebugSupport_Launch Assert.Empty(kubernetesService.CreatedResources.OfType()); Assert.Same(resource, Assert.Single(failedResources)); - static Task ThrowingLaunchConfiguration(LaunchConfigurationCallbackContext context) + static Task ThrowingLaunchConfiguration(string mode, CancellationToken cancellationToken) { throw new InvalidOperationException("Launch configuration failed."); } @@ -7690,13 +6130,7 @@ public async Task PlainExecutable_ProjectDebugSupportWithoutProjectMetadata_Fail var resource = new TestExecutableResource("test-working-directory"); builder.AddResource(resource) - .WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - ProjectPath = "/test/path", - Mode = context.Mode - }), - "project"); + .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "/test/path", Mode = mode }, "project"); var configDict = new Dictionary { @@ -7739,13 +6173,7 @@ public async Task DotnetProjectExecutable_RespectsDebugSessionRunMode(string run builder.AddResource(resource) .WithAnnotation(new TestProjectWithLaunchSettings()) .WithAnnotation(new LaunchProfileAnnotation("http")) - .WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - ProjectPath = "TestProjectWithLaunchSettings", - Mode = context.Mode - }), - "project"); + .WithDebugSupport(mode => new ProjectLaunchConfiguration { ProjectPath = "TestProjectWithLaunchSettings", Mode = mode }, "project"); var configDict = new Dictionary { @@ -8583,7 +7011,7 @@ public async Task PlainExecutable_LaunchConfigurationProducerThrows_FallsBackToP var debuggableExecutable = new TestExecutableResource("test-working-directory"); builder.AddResource(debuggableExecutable).WithDebugSupport( - static _ => throw new InvalidOperationException("Test exception from launch configuration producer"), + _ => throw new InvalidOperationException("Test exception from launch configuration producer"), "test"); var runSessionInfo = new RunSessionInfo @@ -8636,9 +7064,7 @@ public async Task Project_NonProjectLaunchConfig_ExtensionMode_RunsInIde() { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); var configDict = new Dictionary { @@ -8684,7 +7110,7 @@ public async Task Project_NonProjectLaunchConfig_AnnotatorThrows_FallsBackToProc projectBuilder.Resource.Annotations.Remove(annotationToRemove); } projectBuilder.WithDebugSupport( - static _ => throw new InvalidOperationException("Test exception from launch configuration producer"), + _ => throw new InvalidOperationException("Test exception from launch configuration producer"), "azure-functions"); var configDict = new Dictionary @@ -8724,9 +7150,7 @@ public async Task Project_NonProjectLaunchConfig_UnsupportedByExtension_RunsInPr { projectBuilder.Resource.Annotations.Remove(annotationToRemove); } - projectBuilder.WithDebugSupport( - context => Task.FromResult(new ExecutableLaunchConfiguration("azure-functions") { Mode = context.Mode }), - "azure-functions"); + projectBuilder.WithDebugSupport(mode => new ExecutableLaunchConfiguration("azure-functions") { Mode = mode }, "azure-functions"); // Extension does NOT list "azure-functions" in SupportedLaunchConfigurations var configDict = new Dictionary @@ -9002,7 +7426,6 @@ private static DcpExecutor CreateAppExecutor( DcpExecutorEvents? events = null, Hosting.Eventing.IDistributedApplicationEventing? distributedApplicationEventing = null, ILogger? containerCreatorLogger = null, - Action? executableCreatorCreated = null, ILogger? logger = null, DistributedApplicationOptions? distributedApplicationOptions = null) { @@ -9060,7 +7483,6 @@ private static DcpExecutor CreateAppExecutor( aspireStore, NullLogger.Instance, appResources); - executableCreatorCreated?.Invoke(executableCreator); var containerCreator = new ContainerCreator( configuration, @@ -9228,58 +7650,9 @@ private static X509Certificate2 CreateTestCertificate() serialNumber); } - private static X509Certificate2 CreateTestCertificateWithPrivateKey() - { - using var rsa = RSA.Create(2048); - var request = new CertificateRequest( - new X500DistinguishedName("CN=test"), - rsa, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - - return request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); - } - private sealed class TestExecutableResource(string directory) : ExecutableResource("TestExecutable", "test", directory); - - // Counts resolutions so a test can prove an argument was resolved exactly once across the original - // resolution and the debug-argument rewrite that follows it. - private sealed class CountingValueProvider(string value) : IValueProvider - { - private int _resolutionCount; - - public int ResolutionCount => Volatile.Read(ref _resolutionCount); - - public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _resolutionCount); - return new ValueTask(value); - } - } private sealed class TestOtherExecutableResource(string directory) : ExecutableResource("TestOtherExecutable", "test-other", directory); - // Resolves to a different value on every call so a test can tell which occurrence of a repeated - // argument each entry in the final command line came from. - private sealed class SequentialValueProvider(string prefix) : IValueProvider - { - private int _resolutionCount; - - public int ResolutionCount => Volatile.Read(ref _resolutionCount); - - public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - { - var resolution = Interlocked.Increment(ref _resolutionCount); - return new ValueTask($"{prefix}-{resolution.ToString(CultureInfo.InvariantCulture)}"); - } - } - - // Models an argument whose dependency failed to start, which surfaces as a throwing resolution. - private sealed class ThrowingValueProvider(string message) : IValueProvider - { - public ValueTask GetValueAsync(CancellationToken cancellationToken = default) - => throw new InvalidOperationException(message); - } - // Models a DotnetProjectResource: a plain ExecutableResource (launches `dotnet`) that carries // IProjectMetadata and a "project" SupportsDebuggingAnnotation. Used to verify the DCP project-launch // generalization without taking a dependency on Aspire.Hosting.Dotnet. @@ -9321,12 +7694,6 @@ public TestMauiLaunchConfiguration() : base("maui") public Dictionary? MsBuildProperties { get; set; } } - private sealed class TestExecutionConfigurationLaunchConfiguration() : ExecutableLaunchConfiguration("test") - { - [JsonPropertyName("debug_value")] - public string DebugValue { get; set; } = string.Empty; - } - private sealed class TestProjectWithLaunchSettings : IProjectMetadata { public string ProjectPath => "TestProjectWithLaunchSettings"; diff --git a/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs b/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs deleted file mode 100644 index c3568ae5041..00000000000 --- a/tests/Aspire.Hosting.Tests/Dcp/RecordingDcpObjectFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.Dcp; -using Aspire.Hosting.Dcp.Model; - -namespace Aspire.Hosting.Tests.Dcp; - -internal sealed class RecordingDcpObjectFactory : IDcpObjectFactory -{ - public int CreateDcpObjectsCallCount { get; private set; } - - public Task CreateDcpObjectsAsync( - IEnumerable objects, - CancellationToken cancellationToken) - where TDcpResource : CustomResource, IKubernetesStaticMetadata - { - CreateDcpObjectsCallCount++; - return Task.CompletedTask; - } - - public Task CreateRenderedResourcesAsync( - IObjectCreator creator, - IEnumerable> resources, - TContext context, - CancellationToken cancellationToken) - where TDcpResource : CustomResource, IKubernetesStaticMetadata - => throw new NotSupportedException(); - - public Task PatchDcpObjectAsync( - TDcpResource obj, - Action change, - CancellationToken cancellationToken) - where TDcpResource : CustomResource, IKubernetesStaticMetadata - => throw new NotSupportedException(); - - public Task UpdateWithEffectiveAddressInfo( - IEnumerable services, - CancellationToken cancellationToken, - TimeSpan? timeout = null) - => throw new NotSupportedException(); - - public Task> WaitForStateAsync( - IEnumerable objects, - Func stateSelector, - IReadOnlyCollection finalStates, - TimeSpan timeout, - CancellationToken cancellationToken) - where TDcpResource : CustomResource, IKubernetesStaticMetadata - => throw new NotSupportedException(); -} diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index b72ccfaee47..7981702a435 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -18,26 +18,20 @@ namespace Aspire.Hosting.Tests; public class DebugSupportExtensionsTests { [Fact] - public void LaunchConfigurationCallbackContextIsFrameworkOwned() + public void LaunchConfigurationCallbackContextExposesOnlyLaunchProducerInputs() { var contextType = typeof(LaunchConfigurationCallbackContext); Assert.Empty(contextType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)); - - foreach (var propertyName in new[] - { - nameof(LaunchConfigurationCallbackContext.Mode), - nameof(LaunchConfigurationCallbackContext.Resource), - nameof(LaunchConfigurationCallbackContext.OriginalExecutionConfiguration), - nameof(LaunchConfigurationCallbackContext.ExecutableExecutionConfiguration), - nameof(LaunchConfigurationCallbackContext.ExecutionContext), - nameof(LaunchConfigurationCallbackContext.Logger), - nameof(LaunchConfigurationCallbackContext.CancellationToken) - }) - { - var property = Assert.Single(contextType.GetProperties(), property => property.Name == propertyName); - Assert.Null(property.SetMethod); - } + 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] @@ -87,14 +81,12 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForACustomPro // owns the whole configuration, so its output is returned (and sent) verbatim. using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: "http") - .WithDebugSupport( - context => Task.FromResult(new ProjectLaunchConfiguration - { - Mode = context.Mode, - ProjectPath = "custom-path", - LaunchProfile = "https" - }), - KnownLaunchConfigurationTypes.Project); + .WithDebugSupport(mode => new ProjectLaunchConfiguration + { + Mode = mode, + ProjectPath = "custom-path", + LaunchProfile = "https" + }, KnownLaunchConfigurationTypes.Project); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.NoDebug)); @@ -108,13 +100,7 @@ public async Task CreateLaunchConfigurationReturnsTheProducerOutputForNonProject { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => Task.FromResult(new TestGoLaunchConfiguration - { - Mode = context.Mode, - Package = "./cmd/api" - }), - "go"); + .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }, "go"); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.NoDebug)); @@ -130,14 +116,10 @@ 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 context => + .WithDebugSupport(async (mode, _) => { await Task.Yield(); - return new TestGoLaunchConfiguration - { - Mode = context.Mode, - Package = "./cmd/api" - }; + return new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }; }, "go"); var launchConfiguration = Assert.IsType(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); @@ -154,10 +136,10 @@ public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToThePr CancellationToken observedToken = default; var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport(context => + .WithDebugSupport((mode, cancellationToken) => { - observedToken = context.CancellationToken; - return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); + observedToken = cancellationToken; + return Task.FromResult(new TestGoLaunchConfiguration { Mode = mode }); }, "go"); await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug, cts.Token); @@ -184,14 +166,12 @@ public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMeta using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "dotnet", "."); executable.WithDebugSupport( - context => Task.FromResult( - ProjectLaunchConfigurationFactory.Create(context.Resource, context.Mode)), + mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode), KnownLaunchConfigurationTypes.Project); var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); - Assert.NotNull(exception.InnerException); - Assert.Contains("has no project metadata", exception.InnerException.Message); + Assert.Contains("has no project metadata", exception.Message); } [Fact] @@ -199,10 +179,7 @@ public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsNull() { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext _) => - Task.FromResult(null!), - "go"); + .WithDebugSupport(_ => (TestGoLaunchConfiguration)null!, "go"); var exception = await Assert.ThrowsAsync(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug)); @@ -211,159 +188,6 @@ public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsNull() Assert.Contains("go", exception.Message); } - [Fact] - public async Task CreateLaunchConfigurationUsesTheSuppliedContextWithoutEvaluatingCallbacks() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var environmentCallbackCount = 0; - LaunchConfigurationCallbackContext? observedContext = null; - - var executable = builder.AddExecutable("app", "go", ".") - .WithEnvironment(context => - { - Interlocked.Increment(ref environmentCallbackCount); - context.EnvironmentVariables["UNEXPECTED"] = "value"; - }) - .WithDebugSupport((LaunchConfigurationCallbackContext context) => - { - observedContext = context; - return Task.FromResult(new TestGoLaunchConfiguration - { - Mode = context.Mode, - Package = context.OriginalExecutionConfiguration.EnvironmentVariables - .Single(pair => pair.Key == "EXPECTED") - .Value - }); - }, "go"); - - var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( - executable.Resource, - environmentVariables: [new("EXPECTED", "./cmd/api")]); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - ExecutableLaunchMode.NoDebug, - executionConfiguration); - - var launchConfiguration = Assert.IsType( - await executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Same(callbackContext, observedContext); - Assert.Equal(0, environmentCallbackCount); - Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode); - Assert.Equal("./cmd/api", launchConfiguration.Package); - } - - [Fact] - public async Task CreateLaunchConfigurationRejectsAContextForAnotherResource() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext context) => - Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); - var other = builder.AddExecutable("other", "go", "."); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(other.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Equal("context", exception.ParamName); - Assert.Contains("other", exception.Message); - Assert.Contains("app", exception.Message); - } - - [Fact] - public void LaunchConfigurationCallbackContextRejectsExecutionConfigurationForAnotherResource() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext context) => - Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); - var other = builder.AddExecutable("other", "go", "."); - var otherExecutionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( - other.Resource, - arguments: ["secret-from-other"]); - - var exception = Assert.Throws(() => - LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - executionConfiguration: otherExecutionConfiguration)); - - Assert.Equal("originalExecutionConfiguration", exception.ParamName); - Assert.Contains("other", exception.Message); - Assert.Contains("app", exception.Message); - } - - [Fact] - public async Task CreateLaunchConfigurationRejectsAFailedExecutionConfiguration() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var producerCalled = false; - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - (LaunchConfigurationCallbackContext context) => - { - producerCalled = true; - return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }); - }, - "go"); - var expectedException = new InvalidOperationException("configuration failed"); - var executionConfiguration = LaunchConfigurationTestHelpers.CreateExecutionConfigurationResult( - executable.Resource, - exception: expectedException); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - executionConfiguration: executionConfiguration); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Same(expectedException, exception); - Assert.False(producerCalled); - } - - [Fact] - public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsANullTask() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - static (LaunchConfigurationCallbackContext _) => - (Task)null!, - "go"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Contains("returned a null task", exception.Message); - Assert.Contains("app", exception.Message); - Assert.Contains("go", exception.Message); - } - - [Fact] - public async Task CreateLaunchConfigurationWrapsAProducerExceptionWithResourceContext() - { - using var builder = TestDistributedApplicationBuilder.Create(); - var producerException = new InvalidOperationException("producer failed"); - var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - (LaunchConfigurationCallbackContext _) => - Task.FromException(producerException), - "go"); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource); - - var exception = await Assert.ThrowsAsync( - () => executable.Resource.CreateLaunchConfigurationAsync(callbackContext)); - - Assert.Contains("app", exception.Message); - Assert.Contains("go", exception.Message); - Assert.Same(producerException, exception.InnerException); - } - [Fact] public void SupportsDebuggingReturnsFalseWhenTheResourceHasNoDebugSupport() { @@ -379,9 +203,7 @@ public void SupportsDebuggingReturnsFalseWhenNoDebugSessionIsActive() { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); + .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(debugSessionPort: null), out _)); } @@ -405,9 +227,7 @@ public void SupportsDebuggingReturnsFalseForANonProjectTypeWhenTheIdeSendsNoCapa // capabilities cannot be assumed to know how to launch a "go" resource. using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); + .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(), out _)); } @@ -421,9 +241,7 @@ public void SupportsDebuggingHonorsTheAdvertisedCapabilityList(string[] supporte { using var builder = TestDistributedApplicationBuilder.Create(); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); + .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); var configuration = CreateConfiguration(debugSessionInfo: CreateDebugSessionInfo(supportedLaunchConfigurations)); @@ -472,9 +290,7 @@ public void SupportsDebuggingFallsBackToTheImplicitProjectRuleWhenDebugSessionIn using var builder = TestDistributedApplicationBuilder.Create(); var project = builder.AddProject("proj", launchProfileName: "http"); var executable = builder.AddExecutable("app", "go", ".") - .WithDebugSupport( - context => Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode }), - "go"); + .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go"); var configuration = CreateConfiguration(debugSessionInfo: "{ not json"); diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 7282c0773d3..41c5b5fc32a 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -92,15 +92,16 @@ public async Task WithDebugSupportAddsAnnotationInRunMode() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var launchConfig = new ExecutableLaunchConfiguration("python"); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport(_ => Task.FromResult(launchConfig), "ms-python.python"); + .WithDebugSupport(_ => launchConfig, "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.NotNull(annotation); var exe = new Executable(new ExecutableSpec()); - var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - ExecutableLaunchMode.NoDebug); - await annotation.LaunchConfigurationAnnotator(exe, callbackContext); + 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)); @@ -113,108 +114,65 @@ public void WithDebugSupportDoesNotAddAnnotationInPublishMode() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("python")), - "ms-python.python"); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("python"), "ms-python.python"); var annotation = executable.Resource.Annotations.OfType().SingleOrDefault(); Assert.Null(annotation); } [Fact] - public async Task WithDebugSupportSupportsAContextIgnoringProducer() + public async Task WithDebugSupportAsynchronousProducerProducesTheSameAnnotationAsTheSynchronousOne() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var executable = builder.AddExecutable("async", "command", "workingdirectory") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go"); - - var launchConfiguration = Assert.IsType( - await executable.Resource.CreateLaunchConfigurationAsync( - LaunchConfigurationTestHelpers.CreateCallbackContext(executable.Resource))); - - Assert.Equal("go", launchConfiguration.Type); + 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, _) => + { + await Task.Yield(); + return new ExecutableLaunchConfiguration("go") { Mode = mode }; + }, "go"); + + 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); } [Fact] -#pragma warning disable CS0618 // Verify the shipped overload remains source-compatible while forwarding to the context overload. - public async Task WithDebugSupportLegacyModeProducerOverloadForwardsToContextProducer() + public void WithDebugSupportRejectsATaskReturningSynchronousProducer() { + // `mode => Task.FromResult(...)` binds to the synchronous overload (overload resolution only + // looks at the lambda's parameter count) with TLaunchConfiguration inferred as Task, so the + // task itself would be serialized as the launch configuration. It must be rejected up front. using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - string? observedMode = null; - var executable = builder.AddExecutable("legacy", "command", "workingdirectory") - .WithDebugSupport( - (string mode) => - { - observedMode = mode; - return new ExecutableLaunchConfiguration("go") - { - Mode = mode - }; - }, - "go"); - - var launchConfiguration = Assert.IsType( - await executable.Resource.CreateLaunchConfigurationAsync( - LaunchConfigurationTestHelpers.CreateCallbackContext( - executable.Resource, - ExecutableLaunchMode.Debug))); - - Assert.Equal(ExecutableLaunchMode.Debug, observedMode); - Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode); - Assert.Equal("go", launchConfiguration.Type); + var executable = builder.AddExecutable("myexe", "command", "workingdirectory"); + + var exception = Assert.Throws( + () => executable.WithDebugSupport(mode => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); + + Assert.Equal("launchConfigurationProducer", exception.ParamName); + Assert.Contains(nameof(CancellationToken), exception.Message); } -#pragma warning restore CS0618 [Fact] -#pragma warning disable CS0618 // Verify the shipped overload preserves its task-return validation. - public void WithDebugSupportLegacyModeProducerOverloadRejectsTaskReturningProducer() + public void WithDebugSupportRejectsAValueTaskReturningSynchronousProducer() { using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + var executable = builder.AddExecutable("myexe", "command", "workingdirectory"); - // ArgumentException, not InvalidOperationException: the producer's return type is the invalid input, so - // the exception carries the offending parameter's name. - var taskException = Assert.Throws(() => - builder.AddExecutable("task", "command", "workingdirectory") - .WithDebugSupport( - (string mode) => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), - "go")); - Assert.Contains("Task", taskException.Message); - Assert.Equal("launchConfigurationProducer", taskException.ParamName); - - var valueTaskException = Assert.Throws(() => - builder.AddExecutable("value-task", "command", "workingdirectory") - .WithDebugSupport( - (string mode) => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), - "go")); - Assert.Contains("ValueTask", valueTaskException.Message); - Assert.Equal("launchConfigurationProducer", valueTaskException.ParamName); - } -#pragma warning restore CS0618 + var exception = Assert.Throws( + () => executable.WithDebugSupport(mode => ValueTask.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), "go")); - [Fact] -#pragma warning disable CS0618 // Verify the shipped overload preserves its argument validation order. - public void WithDebugSupportLegacyModeProducerOverloadValidatesBuilderFirst() - { - // The null-builder check is the entry point contract for every extension method, so it has to run - // before the producer's return type is examined. Otherwise a null builder passed with a task-returning - // producer reports the producer instead of the builder. - IResourceBuilder nullBuilder = null!; - - var taskProducerException = Assert.Throws(() => - nullBuilder.WithDebugSupport( - (string mode) => Task.FromResult(new ExecutableLaunchConfiguration("go") { Mode = mode }), - "go")); - Assert.Equal("builder", taskProducerException.ParamName); - - var nullProducerException = Assert.Throws(() => - nullBuilder.WithDebugSupport( - (Func)null!, - "go")); - Assert.Equal("builder", nullProducerException.ParamName); + Assert.Equal("launchConfigurationProducer", exception.ParamName); } -#pragma warning restore CS0618 [Fact] public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() @@ -233,10 +191,7 @@ public async Task WithDebugSupportArgsCallbackRunsWhenItsAnnotationIsActive() var executable = builder.AddExecutable("myexe", "command", "workingdirectory") .WithArgs("base-arg") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go", - ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); var args = await ArgumentEvaluator.GetArgumentListAsync(executable.Resource); @@ -266,13 +221,8 @@ public async Task WithDebugSupportArgsCallbackDoesNotRunWhenLaterDebugSupportSup var executable = builder.AddExecutable("myexe", "command", "workingdirectory") .WithArgs("base-arg") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go", - ctx => ctx.Args.Add("rewritten-arg")) - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("project")), - "project"); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")) + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("project"), "project"); var args = await ArgumentEvaluator.GetArgumentListAsync(executable.Resource); @@ -288,10 +238,7 @@ public void WithDebugSupportReportsRewritesArgumentsWhenResourceSupportsArgs() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go", - ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); var annotation = executable.Resource.Annotations.OfType().Single(); Assert.True(annotation.RewritesArgumentsForDebugging); @@ -306,10 +253,7 @@ public void WithDebugSupportDoesNotReportRewritesArgumentsWhenResourceHasNoArgs( using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var resource = builder.AddResource(new DebuggableResourceWithoutArgs("noargs")) - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go", - ctx => ctx.Args.Add("rewritten-arg")); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go", ctx => ctx.Args.Add("rewritten-arg")); var annotation = resource.Resource.Annotations.OfType().Single(); Assert.False(annotation.RewritesArgumentsForDebugging); @@ -321,9 +265,7 @@ public void WithDebugSupportDoesNotReportRewritesArgumentsWhenNoArgsCallbackProv using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); var executable = builder.AddExecutable("myexe", "command", "workingdirectory") - .WithDebugSupport( - static _ => Task.FromResult(new ExecutableLaunchConfiguration("go")), - "go"); + .WithDebugSupport(_ => new ExecutableLaunchConfiguration("go"), "go"); var annotation = executable.Resource.Annotations.OfType().Single(); Assert.False(annotation.RewritesArgumentsForDebugging); From 35bea199f3eebd6733c21d6762ce07164feb9ada Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 11 Aug 2026 00:04:38 -0400 Subject: [PATCH 26/30] Clarify process-mode launch producer behavior Document the launch configuration producer lifecycle accurately and cover resolved environment variables for MAUI/native and plain executable launches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3253974d-4f18-486f-863c-281a607656d4 --- .../ResourceBuilderExtensions.cs | 14 ++-- .../Dcp/DcpExecutorTests.cs | 80 ++++++++++++++----- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 7aab330fc4c..2bc2551da2a 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4779,9 +4779,11 @@ public static IResourceBuilder WithComputeEnvironment(this IResourceBuilde /// /// Registering debug support is synchronous. Aspire invokes /// later only for executable creations where this debug-support annotation is active for the current debug - /// session, including restarts and replicas. This is not a general resource lifecycle callback: it does not - /// run for process launches, unsupported debug sessions, publish mode, or inactive annotations superseded by - /// a later . + /// session, including restarts and replicas. This is not a general resource lifecycle callback. Process + /// execution does not generally require a producer, but Aspire can still invoke a configured, supported + /// non-project producer while creating execution configuration for a process executable. 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}")] @@ -4858,8 +4860,10 @@ public static IResourceBuilder WithDebugSupport( /// Registering debug support is synchronous. Aspire invokes /// later only for executable creations where this debug-support annotation is active for the current debug /// session, including restarts and replicas. A producer that completes synchronously should return its result - /// with . This is not a general resource lifecycle callback: - /// it does not run for process launches, unsupported debug sessions, publish mode, or inactive annotations + /// with . This is not a general resource lifecycle callback. + /// Process execution does not generally require a producer, but Aspire can still invoke a configured, + /// supported non-project producer while creating execution configuration for a process executable. + /// The callback does not run for unsupported debug sessions, publish mode, or inactive annotations /// superseded by a later . /// /// diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 452ed5339e6..bf739e7baca 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); @@ -5017,11 +5017,14 @@ public async Task MauiProjectWithExecutableAnnotationAndSupportedLaunchConfigura Assert.Equal("-e", launchConfig.MsBuildProperties!["AdbTarget"]); } - [Fact] - public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfiguration_StillAppliesMauiLaunchConfiguration() + [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) { @@ -5035,18 +5038,32 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati leadingResourceArgumentToRemove: "run")); #pragma warning restore ASPIREPROJECTS001 - projectBuilder - .WithDebugSupport( - context => Task.FromResult(new TestMauiLaunchConfiguration + var producerInvocationCount = 0; + LaunchConfigurationCallbackContext? launchContext = null; + + if (useContextOverload) + { + projectBuilder.WithDebugSupport( + context => { - Mode = context.Mode, - ProjectPath = "/mauiapp/MauiApp.csproj", - TargetFramework = "net10.0-android", - Platform = "android", - TargetKind = "emulator" - }), - "maui") - .WithArgs("run", "-f", "net10.0-android"); + 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 configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -5073,10 +5090,29 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati var executable = GetCreatedExecutableForResource(kubernetesService, "proj"); Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType); + Assert.Equal(1, Volatile.Read(ref producerInvocationCount)); Assert.True(executable.TryGetAnnotationAsObjectList( Executable.LaunchConfigurationsAnnotation, out var launchConfigurations)); - Assert.Equal("/mauiapp/MauiApp.csproj", Assert.Single(launchConfigurations).ProjectPath); + 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" + }; } [Fact] @@ -7449,13 +7485,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); From fa5c96cfa1e9b0d4c56f97077f5ff7ceaedc1f69 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 11 Aug 2026 02:25:38 -0400 Subject: [PATCH 27/30] Fix launch-override producer wiring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3253974d-4f18-486f-863c-281a607656d4 --- .../ResourceBuilderExtensions.cs | 7 +- .../SupportsDebuggingAnnotation.cs | 6 +- .../Dcp/DcpExecutorTests.cs | 64 ++++++++++++++++--- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 2bc2551da2a..b2d7734a423 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4917,8 +4917,13 @@ public static IResourceBuilder WithDebugSupport( { // Make sure that we do not call the callback if we aren't the active (last) SupportsDebuggingAnnotation, // because the callback may be specific to the launch configuration type. + // + // Project launch-args overrides force DCP to start the resource as a Process even when a supported + // non-project launch configuration producer still runs later to populate execution metadata. In that + // mode, debug arg rewriting would strip required process args such as MAUI target-framework switches. if (resourceWithArgs.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out var activeAnnotation) - && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation)) + && ReferenceEquals(activeAnnotation, supportsDebuggingAnnotation) + && !resourceWithArgs.Resource.TryGetLastAnnotation(out _)) { argsCallback(ctx); } diff --git a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs index 8c62cbeacaf..22bf8c739ac 100644 --- a/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs +++ b/src/Aspire.Hosting/SupportsDebuggingAnnotation.cs @@ -50,9 +50,9 @@ private SupportsDebuggingAnnotation( // Takes the internal DCP Executable object, so it stays internal even though the annotation is public. 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. + // 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; } /// diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index bf739e7baca..85b9f832b3b 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -3581,6 +3581,7 @@ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnv { var builder = DistributedApplication.CreateBuilder(); LaunchConfigurationCallbackContext? launchContext = null; + var environmentCallbackInvocationCount = 0; var debugSessionInfo = JsonSerializer.Serialize(new RunSessionInfo { ProtocolsSupported = ["test"], @@ -3593,7 +3594,11 @@ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnv var resource = new TestExecutableResource("test-working-directory"); builder.AddResource(resource) .WithArgs("app-arg") - .WithEnvironment("DEBUG_VALUE", "resolved") + .WithEnvironment(context => + { + var currentInvocation = Interlocked.Increment(ref environmentCallbackInvocationCount); + context.EnvironmentVariables["DEBUG_VALUE"] = $"resolved-{currentInvocation}"; + }) .WithDebugSupport( context => { @@ -3630,9 +3635,12 @@ public async Task PlainExecutable_LaunchConfigurationProducerReceivesResolvedEnv Assert.Equal(ExecutableLaunchMode.Debug, launchContext.Mode); Assert.Same(resource, launchContext.Resource); Assert.Equal(cts.Token, launchContext.CancellationToken); - Assert.Equal("resolved", launchContext.EnvironmentVariables["DEBUG_VALUE"]); 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", "debug-arg"], executable.Spec.Args); } @@ -5039,6 +5047,7 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati #pragma warning restore ASPIREPROJECTS001 var producerInvocationCount = 0; + var debugArgsCallbackInvocationCount = 0; LaunchConfigurationCallbackContext? launchContext = null; if (useContextOverload) @@ -5050,7 +5059,13 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati launchContext = context; return Task.FromResult(CreateMauiLaunchConfiguration(context.Mode)); }, - "maui"); + "maui", + argsCallback: context => + { + Interlocked.Increment(ref debugArgsCallbackInvocationCount); + context.Args.Clear(); + context.Args.Add("debug-only-arg"); + }); } else { @@ -5060,20 +5075,31 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati Interlocked.Increment(ref producerInvocationCount); return CreateMauiLaunchConfiguration(mode); }, - "maui"); + "maui", + argsCallback: context => + { + Interlocked.Increment(ref debugArgsCallbackInvocationCount); + context.Args.Clear(); + context.Args.Add("debug-only-arg"); + }); } 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] = JsonSerializer.Serialize(new RunSessionInfo - { - ProtocolsSupported = ["coreclr"], - SupportedLaunchConfigurations = ["maui"] - }), + [KnownConfigNames.DebugSessionInfo] = debugSessionInfo, [KnownConfigNames.ExtensionEndpoint] = "http://localhost:1234", [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug }) @@ -5081,16 +5107,34 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati 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); + 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)); + Assert.Equal(0, Volatile.Read(ref debugArgsCallbackInvocationCount)); + 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)); From c5855b8d57eedad202b826177d454c8a4c74ff3b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 11 Aug 2026 04:42:17 -0400 Subject: [PATCH 28/30] Clarify asynchronous debug producer guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3253974d-4f18-486f-863c-281a607656d4 --- src/Aspire.Hosting/ResourceBuilderExtensions.cs | 4 ++-- .../ExecutableResourceBuilderExtensionTests.cs | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index b2d7734a423..8d0663ca6a2 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -4801,8 +4801,8 @@ public static IResourceBuilder WithDebugSupport( 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)); } diff --git a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs index 41c5b5fc32a..c53ef09b7d7 100644 --- a/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutableResourceBuilderExtensionTests.cs @@ -159,7 +159,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] @@ -172,6 +172,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] @@ -259,6 +260,13 @@ public void WithDebugSupportDoesNotReportRewritesArgumentsWhenResourceHasNoArgs( Assert.False(annotation.RewritesArgumentsForDebugging); } + 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 WithDebugSupportDoesNotReportRewritesArgumentsWhenNoArgsCallbackProvided() { From 7cdb91a51a9bc7f1ec3366603567da2608b5645e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 11 Aug 2026 16:45:17 -0400 Subject: [PATCH 29/30] Cover the no-fallback rule when a launch configuration producer throws Supplying an argsCallback sets RewritesArgumentsForDebugging, and the exception filter in ExecutableCreator deliberately excludes those resources from the process fallback that plain debuggable executables get. Falling back there could start a resource with half-applied debug arguments, so a producer fault has to fail the resource instead: the exception escapes ExecutableCreator, DcpExecutor reports the resource as failed to start, and no Executable is ever created. Every existing producer-fault test omits argsCallback, so that rule had no coverage. Adds a theory over both producer overloads asserting the producer runs once, the args callback never runs, and no Executable is created. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1bd51d5-cb9f-4370-a07b-c94416baad61 --- .../Dcp/DcpExecutorTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 85b9f832b3b..e35c3a93d6e 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -5159,6 +5159,105 @@ public async Task MauiProjectWithLaunchArgsOverrideAndSupportedLaunchConfigurati }; } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MauiProjectWithLaunchArgsOverride_LaunchConfigurationProducerThrows_DoesNotFallBackToProcessExecution(bool useContextOverload) + { + // Registering an argsCallback sets RewritesArgumentsForDebugging, which deliberately forgoes the + // process fallback that plain debuggable executables get (see SupportsDebuggingAnnotation remarks and + // the exception filter in ExecutableCreator). Falling back here could start the resource with + // debug-rewritten arguments, so a producer fault must fail the resource instead. The exception escapes + // ExecutableCreator, DcpExecutor reports the resource as failed to start, and no Executable is created. + 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; + var debugArgsCallbackInvocationCount = 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", + argsCallback: context => + { + Interlocked.Increment(ref debugArgsCallbackInvocationCount); + context.Args.Clear(); + context.Args.Add("debug-only-arg"); + }); + } + else + { + projectBuilder.WithDebugSupport( + TestMauiLaunchConfiguration (mode) => + { + Interlocked.Increment(ref producerInvocationCount); + throw new InvalidOperationException("Test exception from launch configuration producer"); + }, + "maui", + argsCallback: context => + { + Interlocked.Increment(ref debugArgsCallbackInvocationCount); + context.Args.Clear(); + context.Args.Add("debug-only-arg"); + }); + } + + 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(); + + Assert.Empty(GetCreatedExecutablesForResource(kubernetesService, "proj")); + Assert.Equal(1, Volatile.Read(ref producerInvocationCount)); + Assert.Equal(0, Volatile.Read(ref debugArgsCallbackInvocationCount)); + } + [Fact] public async Task ProjectWithNonProjectAnnotationAndExecutableAnnotation_LaunchProfileArgsStayAfterDotnetRunArgs() { From a506a7a0088c823df84f1fc3d98d977700518103 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 13 Aug 2026 13:26:54 -0400 Subject: [PATCH 30/30] Restore launch configuration inspection API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b9961b8-65d7-4919-82a9-05b90a62a1b7 --- .../DebugSupportExtensions.cs | 53 ++++++++++++++++--- .../DebugSupportExtensionsTests.cs | 32 +++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs index b016beb2373..b8510bd00aa 100644 --- a/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/DebugSupportExtensions.cs @@ -95,6 +95,51 @@ public static bool HasLaunchToolArgsOwnedBy(this IResource resource, SupportsDeb && string.Equals(owner, supportsDebuggingAnnotation.LaunchConfigurationType, StringComparison.Ordinal); } + /// + /// Creates the launch configuration that this resource sends to the IDE for the given launch mode. + /// + /// The resource to inspect. It must carry a . + /// The launch mode, one of the values on . + /// A token to cancel the operation. + /// The launch configuration, typically an . + /// The resource does not declare debug launch support. + /// + /// + /// Launch configuration is created by invoking the producer callback passed to + /// + /// (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 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) + { + 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. /// @@ -114,12 +159,8 @@ public static bool HasLaunchToolArgsOwnedBy(this IResource resource, SupportsDeb /// when the active debug-support annotation is producing a launch configuration for an executable creation. /// /// - /// Deliberately internal. has no public constructor, and - /// AppHost code only ever receives one while this resource's own producer is running - where calling this - /// would re-enter that producer. Passing another resource's context is rejected below, and holding a context - /// past the callback describes a launch that already happened. A public overload would therefore promise an - /// inspection flow that no caller outside this assembly can reach; exposing it needs a supported way to build - /// a context first. + /// 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( diff --git a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs index 7981702a435..b567d0a1a94 100644 --- a/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/DebugSupportExtensionsTests.cs @@ -34,6 +34,38 @@ public void LaunchConfigurationCallbackContextExposesOnlyLaunchProducerInputs() 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() {