Skip to content

TypeScript AppHost: async configure callback deadlocks aspire start when stored inside lazy IOptions.Configure (RunAsync runs on NonConcurrentSynchronizationContext) #17487

@davidfowl

Description

@davidfowl

Summary

When a TypeScript AppHost exports a method that:

  1. is annotated with RunSyncOnBackgroundThread = true, and
  2. accepts an async callback delegate from TypeScript, and
  3. the callback is invoked lazily (e.g. inside IOptions.Configure) during DistributedApplication.RunAsync

aspire start / aspire run silently hangs for 60 seconds then exits non-zero:

Timed out waiting for AppHost server to start after 60 seconds

No stack trace. No error message. The process just stops responding.


Root cause

CapabilityDispatcher.InvokeMethodAsync only offloads to a background thread when both runSyncOnBackgroundThread = true and the method has a synchronous return type:

// src/Aspire.Hosting.RemoteHost/Ats/CapabilityDispatcher.cs ~line 620
if (runSyncOnBackgroundThread && !IsAsyncReturnType(method.ReturnType))
{
    return await Task.Run(() => InvokeMethodCore(method, target, methodArgs))
        .ConfigureAwait(false);
}
return InvokeMethodCore(method, target, methodArgs);  // ← stays on SyncCtx

Because DistributedApplication.RunAsync() returns Task, IsAsyncReturnType is true and Task.Run is skipped. The synchronous startup body of RunAsync — including all BeforeStartEvent subscribers — runs directly on StreamJsonRpc's NonConcurrentSynchronizationContext.

If anything in that synchronous path invokes an ATS proxy for a TypeScript async callback, the proxy calls .GetAwaiter().GetResult() (in AtsCallbackProxyFactory.InvokeSyncVoidWithDtoWriteback). The TypeScript setter responses queue on the same blocked context → classic sync-over-async deadlock.

This is particularly hard to diagnose: RunSyncOnBackgroundThread = true protects the initial method invocation but does not protect code that runs during RunAsync.


Minimal reproduction

Custom extension library (C#)

[AspireExport(RunSyncOnBackgroundThread = true)]
public static IResourceBuilder<SomeResource> AddMyExtension(
    this IDistributedApplicationBuilder builder,
    Action<MyOptions>? configure = null)
{
    // ⚠️ BUG: storing the ATS proxy delegate in a lazy IOptions callback
    builder.Services.AddOptions<MyOptions>()
        .Configure(opts => configure?.Invoke(opts));  // invoked during RunAsync → deadlock

    // ...
}

TypeScript AppHost

await builder.addMyExtension({
  configure: async (opts) => {
    await opts.someProperty.set("value");  // ATS setter → queues on blocked SyncCtx
  },
});

await builder.build().run();  // hangs for 60 s, exits non-zero

Real-world example: This was discovered via AspireC4, a community library that auto-generates LikeC4 architecture diagrams from the Aspire resource graph. The TypeScript AppHost sample passes an async configure callback to AddAspireC4, which stored it inside IOptions.Configure. The options were resolved inside a BeforeStartEvent subscriber, triggering the deadlock.


Dump-based diagnosis

While aspire-managed.exe is hung, collect and analyse a dump:

dotnet-dump collect --process-id <aspire-managed PID>
dotnet-dump analyze <dump-file>
> clrthreads
> clrstack   # on each thread

Look for a thread blocked in Task.InternalWait inside NonConcurrentSynchronizationContext.ProcessQueueAsync. The stack will show the ATS proxy call (e.g. AtsCallbackProxyFactory+DynamicClass.lambda_method) below Task.InternalWait.

Example stack from the real hang:

Thread 10 (BLOCKED on NonConcurrentSynchronizationContext)

  Task.InternalWait
  Task`1.GetResultCore                                   ← .GetAwaiter().GetResult()
  AtsCallbackProxyFactory+DynamicClass.lambda_method1   ← ATS sync proxy for TS async callback
  MyOptions IOptions configure callback                  ← lazy IOptions.Configure
  OptionsFactory`1.Create
  UnnamedOptionsManager`1.get_Value                     ← triggered by BeforeStartEvent subscriber
  DistributedApplication.ExecuteBeforeStartHooksAsync
  DistributedApplication.RunAsync                       ← running on SyncCtx (IsAsyncReturnType=true)
  CapabilityDispatcher.RegisterContextTypeMethod+<>c
  RemoteAppHostService.InvokeCapabilityAsync
  StreamJsonRpc.TargetMethod.InvokeAsync
  NonConcurrentSynchronizationContext.ProcessQueueAsync

Suggested fixes

Option A (minimal) — remove the !IsAsyncReturnType guard when runSyncOnBackgroundThread = true, so async-returning exported methods also move their sync startup off the context:

if (runSyncOnBackgroundThread)   // remove && !IsAsyncReturnType(method.ReturnType)
{
    return await Task.Run(() => InvokeMethodCore(method, target, methodArgs))
        .ConfigureAwait(false);
}

Option B — yield in DistributedApplication.RunAsync before executing BeforeStartEvent subscribers so user code is never on the NonConcurrentSynchronizationContext:

await Task.Yield();  // escape NonConcurrentSynchronizationContext before running user hooks
await ExecuteBeforeStartHooksAsync(...);

Option C — have AspireExportAnalyzer warn when an [AspireExport] method captures a callback delegate that may be invoked from a context other than the export's own invocation thread.


Workaround for library authors

Evaluate the callback eagerly while on the background thread provided by RunSyncOnBackgroundThread, capture the result, then apply it with a plain data copy inside the lazy callback:

[AspireExport(RunSyncOnBackgroundThread = true)]
public static IResourceBuilder<SomeResource> AddMyExtension(
    this IDistributedApplicationBuilder builder,
    Action<MyOptions>? configure = null)
{
    // Safe: this method runs on a background thread (RunSyncOnBackgroundThread = true).
    var snapshot = new MyOptions();
    builder.Configuration.Bind(MyOptions.SectionName, snapshot);
    configure?.Invoke(snapshot);  // evaluate ATS proxy NOW, on background thread

    builder.Services.AddOptions<MyOptions>()
        .BindConfiguration(MyOptions.SectionName)
        .Configure(opts =>
        {
            // DO NOT call configure?.Invoke(opts) here — ATS proxy + SyncCtx = deadlock.
            snapshot.CopyTo(opts);  // plain value copy, no ATS call
        });
}

Environment

  • Aspire CLI: 13.4.0-preview.1.26275.2
  • Affected files:
    • src/Aspire.Hosting.RemoteHost/Ats/CapabilityDispatcher.cs (InvokeMethodAsync, ~line 620)
    • src/Aspire.Hosting.RemoteHost/Ats/AtsCallbackProxyFactory.cs (InvokeSyncVoidWithDtoWriteback)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-polyglotIssues related to polyglot apphosts

    Type

    No fields configured for Bug.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions