Summary
When a TypeScript AppHost exports a method that:
- is annotated with
RunSyncOnBackgroundThread = true, and
- accepts an
async callback delegate from TypeScript, and
- 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)
Summary
When a TypeScript AppHost exports a method that:
RunSyncOnBackgroundThread = true, andasynccallback delegate from TypeScript, andIOptions.Configure) duringDistributedApplication.RunAsync…
aspire start/aspire runsilently hangs for 60 seconds then exits non-zero:No stack trace. No error message. The process just stops responding.
Root cause
CapabilityDispatcher.InvokeMethodAsynconly offloads to a background thread when bothrunSyncOnBackgroundThread = trueand the method has a synchronous return type:Because
DistributedApplication.RunAsync()returnsTask,IsAsyncReturnTypeistrueandTask.Runis skipped. The synchronous startup body ofRunAsync— including allBeforeStartEventsubscribers — runs directly on StreamJsonRpc'sNonConcurrentSynchronizationContext.If anything in that synchronous path invokes an ATS proxy for a TypeScript
asynccallback, the proxy calls.GetAwaiter().GetResult()(inAtsCallbackProxyFactory.InvokeSyncVoidWithDtoWriteback). The TypeScript setter responses queue on the same blocked context → classic sync-over-async deadlock.This is particularly hard to diagnose:
RunSyncOnBackgroundThread = trueprotects the initial method invocation but does not protect code that runs duringRunAsync.Minimal reproduction
Custom extension library (C#)
TypeScript AppHost
Dump-based diagnosis
While
aspire-managed.exeis hung, collect and analyse a dump:Look for a thread blocked in
Task.InternalWaitinsideNonConcurrentSynchronizationContext.ProcessQueueAsync. The stack will show the ATS proxy call (e.g.AtsCallbackProxyFactory+DynamicClass.lambda_method) belowTask.InternalWait.Example stack from the real hang:
Suggested fixes
Option A (minimal) — remove the
!IsAsyncReturnTypeguard whenrunSyncOnBackgroundThread = true, so async-returning exported methods also move their sync startup off the context:Option B — yield in
DistributedApplication.RunAsyncbefore executingBeforeStartEventsubscribers so user code is never on theNonConcurrentSynchronizationContext:Option C — have
AspireExportAnalyzerwarn 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:Environment
13.4.0-preview.1.26275.2src/Aspire.Hosting.RemoteHost/Ats/CapabilityDispatcher.cs(InvokeMethodAsync, ~line 620)src/Aspire.Hosting.RemoteHost/Ats/AtsCallbackProxyFactory.cs(InvokeSyncVoidWithDtoWriteback)