ATS dump loses async-ness of callback delegates (Task/ValueTask collapsed to void)
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
## Summary
The ATS dump produced by `aspire sdk dump --format json` is lossy for callback return types: it collapses both synchronous (`Action`, `Func`) and asynchronous (`Func`, `Func`, `Func>`, `Func>`) callback delegates into the same `AtsTypeRef` shape, so downstream consumers of the dump can no longer tell them apart.
This bit us while rendering the TypeScript API reference on aspire.dev — see https://github.com/microsoft/aspire.dev/pull/729. Methods like `publishAsDockerComposeService`, `publishAsAzureAppServiceWebsite`, `publishAsAzureContainerApp`, `withProperties`, `publishAsKubernetesService`, `withConfiguration`, `subscribeBeforeStart`, `subscribeAfterResourcesCreated`, and `withPgAdmin` take `Func<…, Task>` callbacks on the C# side and expose `=> Promise` on the actual TS SDK `.d.ts`, but the docs were rendering them as `=> void` because that is what the dump says.
## Where it happens
In `src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs`, both code paths that derive a callback return type map `Task` / `Task` / `ValueTask` / `ValueTask` down to a plain void or their inner `T`, discarding the async-ness:
`BuildCallbackSignature` (≈ line 1723–1745):
```csharp
if (funcReturnType == typeof(void))
{
returnTypeRef = voidTypeRef;
}
else if (funcReturnType == typeof(Task))
{
returnTypeRef = voidTypeRef; // <-- Task becomes void
}
else if (funcReturnType.IsGenericType && funcReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{
// Task - get the inner type
var innerType = funcReturnType.GetGenericArguments().FirstOrDefault();
returnTypeRef = innerType is not null
? CreateTypeRef(innerType, …) ?? voidTypeRef // <-- Task becomes T
: voidTypeRef;
}
```
The same collapse happens for capability return types around lines 1634–1649 and in `CreateTypeRef` / `MapToAtsTypeId` (≈ 1767–1795, 2034–2065).
As a result `AtsParameterInfo.CallbackReturnType` carries no flag indicating whether the delegate was declared as `Action`/`Func<…>` (sync) or `Func<…, Task>`/`Func<…, Task>` (async). The schema in `src/Aspire.TypeSystem/AtsCapabilityInfo.cs` (`AtsParameterInfo`) also has no `IsAsync` / `IsAwaitable` property to carry that signal.
## Why downstream consumers get away with it today
- **TypeScript SDK codegen** (`src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs`, `GenerateCallbackTypeSignature`, ≈ line 2092) hardcodes `=> Promise` for every callback because RPC invocation is always async. Comment in source: `// Callbacks are always async in TypeScript`. So the TS SDK is correct regardless of what the dump says.
- **Python SDK codegen** and **Java SDK codegen** likewise render callbacks uniformly.
- **aspire.dev docs**, however, were taking the dump at face value and rendering `=> void`, causing the `.d.ts` ↔ docs mismatch that broke twoslash in aspire.dev#729. We worked around it in aspire.dev by also hardcoding `=> Promise` in the docs transformer, but that only works because every current consumer chooses to be always-async; the underlying dump is still lossy.
## Why this is worth fixing upstream
Any future consumer that wants to faithfully represent the C# signature (e.g. a "sync vs async" badge in docs, a code-generator targeting a language where you want `Action` → sync / `Func<…, Task>` → async, or tooling that decides whether to `await` a callback) cannot do it from today's dump. The information is already known at scan time — it just isn't written to the schema.
Related: this parallels the existing `IsCallback` flag on `AtsParameterInfo`; we just need the "is this callback asynchronous" signal alongside it.
## Proposed fix
1. **Extend the ATS schema** in `src/Aspire.TypeSystem/AtsCapabilityInfo.cs`:
- Add `bool IsAsyncCallback { get; init; }` on `AtsParameterInfo` (only meaningful when `IsCallback` is `true`).
- Optionally add `bool IsAsync` on the capability itself for capability return types (Task-returning capabilities vs sync returns).
- Keep `CallbackReturnType` unwrapped to the inner `T` (so `Func>` still reports `Y` as the return type ref); the new boolean is what tells consumers to wrap in `Task` / `Promise` / `Awaitable` / `CompletableFuture`.
2. **Populate it in `AtsCapabilityScanner`**:
- In `BuildCallbackSignature`, set `IsAsyncCallback = true` when `funcReturnType` is `Task`, `Task`, `ValueTask`, or `ValueTask`.
- Do the same in `CreateCallbackDescriptor` / wherever capability-level async-ness gets decided.
3. **Update the generators to honor the flag** where they currently hardcode:
- TS: keep current behavior but drive `Promise` from the flag (still always-true in practice today; future-proof).
- Python: same (`typing.Awaitable[T]` vs plain `T`).
- Java: same (`CompletableFuture` vs plain `T`).
4. **Consume it in aspire.dev** (`src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs`): once the flag is available, replace our current "always wrap in `Promise`" workaround with the flag-driven version. That lets the docs accurately show `=> void` vs `=> Promise` for the underlying C# API.
## Repro
```bash
aspire sdk dump --format json \
-o dump.json \
D:\GitHub\aspire\src\Aspire.Hosting.Docker\Aspire.Hosting.Docker.csproj
# In dump.json, find the `publishAsDockerComposeService` capability — its
# `configure` parameter has CallbackReturnType = { TypeId: "void", Category: "Primitive" },
# indistinguishable from an Action<…>-style callback even though the C# signature is
# Func.
```
## Workaround (currently in place on aspire.dev)
aspire.dev's `AtsTransformer.FormatCallbackSignature` now unconditionally wraps callback return types as `Promise` to match the TS SDK's always-async convention. This is a symptom-level fix and should be reverted once the dump carries the real async flag.
---
_Originally uncovered while debugging twoslash samples in microsoft/aspire.dev#729._
Contributor guide
Assessment
This issue has not been assessed yet.