Skip to content

Auto-resolve promises in TypeScript codegen to prevent silent RPC drops#15901

Open
davidfowl wants to merge 13 commits intomainfrom
davidfowl/auto-resolve-promises
Open

Auto-resolve promises in TypeScript codegen to prevent silent RPC drops#15901
davidfowl wants to merge 13 commits intomainfrom
davidfowl/auto-resolve-promises

Conversation

@davidfowl
Copy link
Copy Markdown
Contributor

Description

Auto-resolve promises in the TypeScript code generator to prevent un-awaited fluent chains from silently dropping RPC calls.

The Bug: In TypeScript apphosts, every fluent method returns a *PromiseImpl wrapping an async RPC call. If users don't await the chain, the RPC calls never execute before build().run() completes. This causes env vars, annotations, etc. to silently vanish — particularly in publish/deploy mode. Works fine in dev mode, fails silently in publish.

Three changes:

  1. Widen parameter types — handle-type params now also accept PromiseLike<T>, so users don't need to await a resource before passing it as input to withReference, waitFor, publishAsStaticWebsite({ apiTarget }), etc.

  2. Auto-resolve promises before RPC — all generated internal async methods resolve any promise-like handle params before building rpcArgs, using the existing isPromiseLike() helper. Applied consistently across builder methods, type class methods, context methods, wrapper methods, and property setters.

  3. Track & flush pending promises — mutating fluent calls register their promise with the client via trackPromise(). build() calls flushPendingPromises() before proceeding, ensuring all un-awaited chains complete.

Result: await or don't await — both work correctly. Users only need await when extracting a value (like getEndpoint()).

Fixes #15899

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No
  • Does the change require an update in our Aspire docs?
    • Yes
    • No

Copilot AI review requested due to automatic review settings April 4, 2026 17:29
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 15901

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 15901"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the TypeScript ATS codegen/runtime to ensure fluent RPC calls execute even when users don’t await fluent chains, addressing silent drops in publish/deploy scenarios (fixes #15899).

Changes:

  • Widen generated handle-typed parameters to accept PromiseLike<T> and auto-resolve promise-like inputs before RPC argument marshalling.
  • Add client-side tracking of pending fluent-call promises and flush them before build() proceeds.
  • Update TypeScript codegen snapshots/tests and refresh the TypeScript AppHost playground to demonstrate “no-await” chaining.
Show a summary per file
File Description
tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts Snapshot updates for isPromiseLike export + promise tracking/flush on the client.
tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts Snapshot updates reflecting widened types, promise resolution, and PromiseImpl client threading.
tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs Adjust assertions to expect PromiseLike<...> in generated signatures.
src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/transport.ts Implements trackPromise() / flushPendingPromises() and exports isPromiseLike().
src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs Core generator updates: widen inputs, resolve promises before RPC, track promises, flush before build.
playground/TypeScriptAppHost/vite-frontend/package-lock.json Updates lockfile metadata (engines/peer flags).
playground/TypeScriptAppHost/aspire.config.json Sets sdk version/channel for the playground.
playground/TypeScriptAppHost/apphost.ts Updates sample to rely on promise-friendly chaining and build-time flushing.

Copilot's findings

Files not reviewed (1)
  • playground/TypeScriptAppHost/vite-frontend/package-lock.json: Language not supported
  • Files reviewed: 5/8 changed files
  • Comments generated: 3

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

  • Tests / MongoDB.Driver.v2 / MongoDB.Driver.v2 (windows-latest) - Post-test cleanup steps 'Upload logs, and test results | Copy CLI E2E recordings for upload | Upload CLI E2E recordings | Generate test results summary | Post Checkout code' matched the Windows process initialization failure override allowlist.

Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found (1 deadlock bug, 1 stale snapshot, 1 potential unhandled rejection).

// Flush pending promises before build to ensure all un-awaited chains complete
if (string.Equals(capability.MethodName, "build", StringComparison.OrdinalIgnoreCase))
{
WriteLine(" await this._client.flushPendingPromises();");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deadlock: flushPendingPromises() is inside _buildInternal, not the public build() wrapper

The earlier review thread identified this deadlock and the fix was described, but it was not applied. flushPendingPromises() is still emitted inside _buildInternal():

  1. build() calls _buildInternal(), which suspends at await flushPendingPromises()
  2. The resulting Promise is passed to new PromiseImpl(buildPromise, client)
  3. The constructor calls client.trackPromise(buildPromise) — the build promise is now in _pendingPromises
  4. flushPendingPromises() re-checks the set in its while loop, finds the build promise, and awaits it
  5. The build promise is waiting for flushPendingPromises() to return → circular await → hangs forever

The fix described in the review (async closure in the public build() wrapper) needs to be applied — move the flush before _buildInternal() is called, e.g.:

build(): DistributedApplicationPromise {
    const flushAndBuild = async () => {
        await this._client.flushPendingPromises();
        return this._buildInternal();
    };
    return new DistributedApplicationPromiseImpl(flushAndBuild(), this._client);
}

/// <code>
/// /** @internal */
/// private async _withEnvironmentInternal(name: string, value: string): Promise&lt;RedisResource&gt; {
/// await this._client.flushPendingPromises(); // only emitted for build()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale snapshot files

The snapshot TwoPassScanningGeneratedAspire.verified.ts does not contain flushPendingPromises anywhere. The generated _buildInternal() at its line 4048 is missing the flush call. This means the snapshots were accepted before the flush code was added (or the test models don't include a build capability with that exact MethodName). Either way, the snapshots and the code generator are out of sync — running dotnet test will likely fail with a snapshot mismatch once the deadlock fix is applied to the correct location.


trackPromise(promise: Promise<unknown>): void {
this._pendingPromises.add(promise);
promise.finally(() => this._pendingPromises.delete(promise));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential unhandled promise rejection

The return value of promise.finally(...) is discarded. If the tracked promise rejects, .finally() re-throws that rejection into a new derived promise that nobody observes. In Node.js (since v15), unhandled rejections crash the process by default (--unhandled-rejections=throw).

flushPendingPromises() observes the original promise via Promise.all, but the derived promise from .finally() is a separate chain.

Consider suppressing the secondary rejection:

trackPromise(promise: Promise<unknown>): void {
    this._pendingPromises.add(promise);
    promise.finally(() => this._pendingPromises.delete(promise)).catch(() => {});
}

Or use void promise.then(...) with both paths to avoid creating an unobserved chain.

Comment on lines +768 to +770
while (this._pendingPromises.size > 0) {
await Promise.all(this._pendingPromises);
}
Copy link
Copy Markdown
Member

@JamesNK JamesNK Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this loop so new promises added while adding existing promises are awaited?

Add a quick comment to explain. Also say awaiting pending promoses removes them from the collection

promise.finally(() => this._pendingPromises.delete(promise));
}

async flushPendingPromises(): Promise<void> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much logging do we do in TS client code? Should we log if there are pending promises? It seems like having them is a possible mistake that user/agent should clean up.

davidfowl and others added 13 commits April 5, 2026 08:24
Three changes to make un-awaited fluent chains work correctly:

1. Widen parameter types to accept PromiseLike<T> for handle-type inputs
2. Auto-resolve promise-like params before RPC calls in all internal methods
3. Track pending mutating promises and flush them in build()

This ensures that both awaited and un-awaited fluent chains produce
correct results, preventing env vars and annotations from being
silently dropped in publish/deploy mode.

Fixes #15899

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove unnecessary awaits on Redis and Vite frontend chains to
demonstrate that build() now flushes pending promises automatically.
Await is only needed when the resolved resource is used elsewhere.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Revert playground/TypeScriptAppHost/aspire.config.json changes
- Use promise.finally() instead of .then(cleanup, cleanup) for cleaner promise tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add GenerateResolveAndBuildArgs unified helper for resolve + arg building
- Add GeneratePromiseResolutionForParam for property setters
- Fix GenerateEntryPointFunction: add promise resolution (async IIFE for fluent path)
- Use shared helpers in context/wrapper methods instead of inline logic
- Target let/const: only use let for handle-type optional params

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix union type splitting: use structural AtsTypeRef approach instead of
  string splitting to avoid breaking PromiseLike<A | B> generics
- Fix potential deadlock: move flushPendingPromises to public build() wrapper
  (before PromiseImpl tracking) instead of inside _buildInternal
- Fix promise resolution predicate: rename IsHandleOrHandleUnionType to
  IsWidenedHandleType to match exactly which types are widened with PromiseLike
- Fix var→const in playground apphost.ts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add Awaitable<T> = T | PromiseLike<T> to base.ts. Generated signatures
now use Awaitable<RedisResource> instead of the verbose
RedisResource | PromiseLike<RedisResource> inline expansion.

For unions: string | Awaitable<RedisResource | PostgresResource>
instead of duplicating the full union inside PromiseLike<>.

Also fixes union type construction to use structural AtsTypeRef
approach instead of string splitting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Awaitable<T> type is used in generated method signatures but was
only imported internally. Consumers importing types from aspire.js
need it resolved for tsc --noEmit to pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e<T>

IsWidenedHandleType now checks _wrapperClassNames to match the exact
widening logic in MapInputTypeToTypeScript. This excludes special types
like ReferenceExpression that are handle-category but not widened,
preventing 'Type unknown is not assignable to ReferenceExpression'
tsc errors in generated code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
RunWithMissingAwaitShowsHelpfulError → RunWithMissingAwaitSucceedsWithAutoResolve
Now that withReference accepts PromiseLike<T>, un-awaited chains work
correctly and the app starts successfully instead of showing an error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The e2e test was timing out because aspire run needs PostgreSQL/DCP
which isn't available in this CI environment. Use tsc --noEmit instead
to validate that un-awaited chains (withReference on PromiseLike<T>)
compile without type errors — which is the actual behavior being tested.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mments

- Move flushPendingPromises() from _buildInternal to public build() wrapper
  using async closure pattern to prevent deadlock (JamesNK)
- Apply same fix in GenerateTypeClassMethod for type class build() paths
- Add .catch(() => {}) to promise.finally() in trackPromise to prevent
  unhandled rejection crashes in Node.js (JamesNK)
- Add console.warn when flushing pending promises to surface implicit flushes
- Add explanatory comment on the while loop in flushPendingPromises (JamesNK)
- Update doc comment to reflect flush location change
- Update snapshots (TwoPassScanning, transport)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl davidfowl force-pushed the davidfowl/auto-resolve-promises branch from 45a9a68 to 00b2f0a Compare April 5, 2026 15:25
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

🎬 CLI E2E Test Recordings — 56 recordings uploaded (commit 00b2f0a)

View recordings
Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View Recording
AddPackageWhileAppHostRunningDetached ▶️ View Recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View Recording
AgentInitCommand_DefaultSelection_InstallsSkillOnly ▶️ View Recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View Recording
AllPublishMethodsBuildDockerImages ▶️ View Recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View Recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View Recording
Banner_DisplayedOnFirstRun ▶️ View Recording
Banner_DisplayedWithExplicitFlag ▶️ View Recording
Banner_NotDisplayedWithNoLogoFlag ▶️ View Recording
CertificatesClean_RemovesCertificates ▶️ View Recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View Recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View Recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View Recording
CreateAndRunEmptyAppHostProject ▶️ View Recording
CreateAndRunJavaEmptyAppHostProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View Recording
CreateAndRunTypeScriptStarterProject ▶️ View Recording
CreateJavaAppHostWithViteApp ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DashboardRunWithOtelTracesReturnsNoTraces ▶️ View Recording
DescribeCommandResolvesReplicaNames ▶️ View Recording
DescribeCommandShowsRunningResources ▶️ View Recording
DetachFormatJsonProducesValidJson ▶️ View Recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View Recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View Recording
GlobalMigration_PreservesAllValueTypes ▶️ View Recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View Recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View Recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View Recording
LegacySettingsMigration_AdjustsRelativeAppHostPath ▶️ View Recording
LogsCommandShowsResourceLogs ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View Recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View Recording
RestoreGeneratesSdkFiles ▶️ View Recording
RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes ▶️ View Recording
RunFromParentDirectory_UsesExistingConfigNearAppHost ▶️ View Recording
SecretCrudOnDotNetAppHost ▶️ View Recording
SecretCrudOnTypeScriptAppHost ▶️ View Recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View Recording
StopAllAppHostsFromAppHostDirectory ▶️ View Recording
StopAllAppHostsFromUnrelatedDirectory ▶️ View Recording
StopNonInteractiveMultipleAppHostsShowsError ▶️ View Recording
StopNonInteractiveSingleAppHost ▶️ View Recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View Recording
UnAwaitedChainsCompileWithAutoResolvePromises ▶️ View Recording

📹 Recordings uploaded automatically from CI run #24004528901

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeScript apphost: un-awaited fluent chains silently drop RPC calls — auto-resolve promises in codegen

3 participants