Skip to content

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

Open
davidfowl wants to merge 1 commit intomainfrom
davidfowl/auto-resolve-promises
Open

Auto-resolve promises in TypeScript codegen to prevent silent RPC drops#15901
davidfowl wants to merge 1 commit 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).

@davidfowl davidfowl force-pushed the davidfowl/auto-resolve-promises branch from 45a9a68 to 00b2f0a Compare April 5, 2026 15:25
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.

2 issues found:\n- 1 bugflushPendingPromises() while loop deadlocks when the build promise is tracked after flush begins (transport.ts)\n- 1 test gap — E2E test validates type-checking only, not runtime flush behavior (TypeScriptCodegenValidationTests.cs)

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.

1 issue found (bug). Duplicate error recording in trackPromise/flushPendingPromises — rejected promise errors are pushed into _rejectedErrors from both the trackPromise handler and the allSettled loop.

Comment on lines +763 to +793
private _rejectedErrors: unknown[] = [];
throwOnPendingRejections = true;

constructor(private socketPath: string) { }

trackPromise(promise: Promise<unknown>): void {
this._pendingPromises.add(promise);
// Remove on both resolve and reject. The reject handler swallows the
// error to prevent Node.js unhandled-rejection crashes.
//
// When throwOnPendingRejections is true (default), rejection errors are
// eagerly collected so that flushPendingPromises can re-throw them even
// if the promise settles before flush is called.
//
// Limitation: JavaScript provides no way to detect whether a rejection
// was already observed by the caller (e.g., try { await p } catch {}).
// The .then() reject handler fires regardless. This means user-caught
// rejections will also be re-thrown by build(). We accept this tradeoff
// because the common case — an un-awaited chain fails silently — should
// fail loud. The uncommon case (catch an error from an un-awaited chain,
// then continue to build) can opt out with:
// createBuilder({ throwOnPendingRejections: false })
promise.then(
() => this._pendingPromises.delete(promise),
(err) => {
this._pendingPromises.delete(promise);
if (this.throwOnPendingRejections) {
this._rejectedErrors.push(err);
}
}
);
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.

Bug: Duplicate error recording for promises that reject during flush

When a tracked promise rejects during the await Promise.allSettled(pending) call, its error is pushed into _rejectedErrors twice:

  1. By trackPromise's rejection handler (line 769): this._rejectedErrors.push(err)
  2. By the allSettled loop (line 782): this._rejectedErrors.push(result.reason)

Both fire because the .then(_, onRejected) handler in trackPromise runs as a microtask when the promise settles, and allSettled independently records the same rejection in its results array. The AggregateError thrown at the end will contain duplicate entries.

Fix direction: Remove the allSettled rejection-collection loop entirely — trackPromise's rejection handler already captures errors for both pre-flush and during-flush cases. Or alternatively, stop collecting in trackPromise's handler and rely solely on allSettled:

trackPromise(promise: Promise<unknown>): void {
    this._pendingPromises.add(promise);
    promise.then(
        () => this._pendingPromises.delete(promise),
        () => this._pendingPromises.delete(promise)  // just remove, don't collect
    );
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

GPT 5.4 just found this as well.

@davidfowl davidfowl force-pushed the davidfowl/auto-resolve-promises branch 5 times, most recently from e30b628 to 7319b95 Compare April 10, 2026 04:13
@github-actions
Copy link
Copy Markdown
Contributor

🎬 CLI E2E Test Recordings — 56 recordings uploaded (commit 7319b95)

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 #24226007414

When users write un-awaited fluent chains like:
  const db = builder.addPostgres('pg').addDatabase('db');
  builder.addContainer('c', 'nginx').withReference(db);
  await builder.build().run();

the generated TypeScript now accepts PromiseLike<T> for handle parameters
(via Awaitable<T>) and automatically resolves them before RPC calls.
build() flushes all pending un-awaited promises before proceeding.

Key design decisions in transport.ts:
- trackPromise: .then(resolve, reject) removes from set on settlement.
  Reject handler swallows errors to prevent Node.js unhandled-rejection
  crashes. Errors are NOT eagerly collected — only promises still pending
  at flush time are observed, so user-caught rejections are not re-thrown.
- flushPendingPromises: snapshots the pending set before awaiting to
  prevent deadlock. The build promise is tracked by the PromiseImpl
  constructor while flush is suspended — without the snapshot, a while-loop
  re-check would find and await it, causing a circular await.
- Promise.allSettled collects all errors as AggregateError.

Testing:
- 8 vitest unit tests for trackPromise/flushPendingPromises covering
  deadlock prevention, error aggregation, and user-caught rejection safety
- E2E test with aspire start/stop validating runtime behavior
- 73 snapshot tests for generated code shape

Fixes #15899

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl davidfowl force-pushed the davidfowl/auto-resolve-promises branch from 7319b95 to e61bf76 Compare April 10, 2026 05:07
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

4 participants