Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Aspire.Hosting/DistributedApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ public virtual async Task StopAsync(CancellationToken cancellationToken = defaul
/// in refer to <see cref="DistributedApplicationExecutionContext" />.
/// </para>
/// </remarks>
[AspireExport("run")]
[AspireExport("run", RunSyncOnBackgroundThread = true)]
public virtual async Task RunAsync(CancellationToken cancellationToken = default)
{
ProfilingTelemetry.EnsureInitialized(_host.Services);
Expand Down
156 changes: 156 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/TypeScriptEmptyAppHostTemplateTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
Expand Down Expand Up @@ -54,4 +56,158 @@ public async Task CreateAndRunTypeScriptEmptyAppHostProject()

await pendingRun;
}

[Fact]
[CaptureWorkspaceOnFailure]
public async Task TypeScriptAppHostRunDoesNotDeadlockWhenLazyOptionsInvokeAsyncCallback()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
var testBodyFailed = false;

try
{
await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true);
await auto.InstallAspireCliAsync(strategy, counter);

await auto.AspireNewTypeScriptEmptyAppHostAsync("TsDeadlockRepro", counter);

var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsDeadlockRepro");
WriteDeadlockReproFiles(appDirectory);

await auto.RunCommandFailFastAsync("cd TsDeadlockRepro", counter);
await auto.RunCommandFailFastAsync("aspire restore --non-interactive", counter, TimeSpan.FromMinutes(3));
await auto.RunCommandFailFastAsync("npm run build", counter, TimeSpan.FromMinutes(2));

await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(2));
await auto.AspireStopAsync(counter);
}
catch
{
testBodyFailed = true;
throw;
}
finally
{
try
{
await auto.CaptureAspireDiagnosticsAsync(counter, workspace);
}
catch { } // Best effort

try
{
await auto.TypeAsync("exit");
await auto.EnterAsync();
await pendingRun;
}
catch
{
if (!testBodyFailed)
{
throw;
}
}
}
}

private static void WriteDeadlockReproFiles(string appDirectory)
{
var sdkVersion = GetSdkVersion(appDirectory);
var extensionDirectory = Directory.CreateDirectory(Path.Combine(appDirectory, "DeadlockExtension"));

File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtension.csproj"), $$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);ASPIREATS001</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" Version="{{sdkVersion}}" />
</ItemGroup>
</Project>
""");

File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtensions.cs"), """
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace DeadlockExtension;

public static class DeadlockExtensions
{
[AspireExport(RunSyncOnBackgroundThread = true)]
public static IDistributedApplicationBuilder AddLazyOptionsDeadlockRepro(
this IDistributedApplicationBuilder builder,
Action<DeadlockOptions>? configure = null)
{
builder.Services.AddOptions<DeadlockOptions>()
.Configure(options => configure?.Invoke(options));

builder.Eventing.Subscribe<BeforeStartEvent>((@event, _) =>
{
var options = @event.Services.GetRequiredService<IOptions<DeadlockOptions>>().Value;
if (options.SomeProperty is not "value-from-typescript")
{
throw new InvalidOperationException($"Expected TypeScript callback to set SomeProperty, but got '{options.SomeProperty ?? "<null>"}'.");
}

return Task.CompletedTask;
});

return builder;
}
}

[AspireExport(ExposeProperties = true)]
public sealed class DeadlockOptions
{
public string? SomeProperty { get; set; }
}
""");

File.WriteAllText(Path.Combine(appDirectory, "apphost.mts"), """
import { createBuilder } from './.aspire/modules/aspire.mjs';

const builder = await createBuilder();

await builder.addLazyOptionsDeadlockRepro({
configure: async (options) => {
await options.someProperty.set("value-from-typescript");
}
});

await builder.build().run();
""");

var configPath = Path.Combine(appDirectory, "aspire.config.json");
var config = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject()
?? throw new InvalidOperationException($"Unable to read {configPath}.");
config["packages"] = new JsonObject
{
["DeadlockExtension"] = "DeadlockExtension/DeadlockExtension.csproj"
};
File.WriteAllText(configPath, config.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}

private static string GetSdkVersion(string appDirectory)
{
var configPath = Path.Combine(appDirectory, "aspire.config.json");
using var doc = JsonDocument.Parse(File.ReadAllText(configPath));
return doc.RootElement.GetProperty("sdk").GetProperty("version").GetString()
?? throw new InvalidOperationException("Expected aspire.config.json to contain sdk.version.");
}
}
Loading