Skip to content

Commit 00b2f0a

Browse files
davidfowlCopilot
andcommitted
Address PR review feedback: fix deadlock, unhandled rejection, add comments
- 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>
1 parent 5ffeda9 commit 00b2f0a

4 files changed

Lines changed: 48 additions & 17 deletions

File tree

src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,7 +1412,6 @@ private void GenerateBuilderClass(BuilderModel builder)
14121412
/// <code>
14131413
/// /** @internal */
14141414
/// private async _withEnvironmentInternal(name: string, value: string): Promise&lt;RedisResource&gt; {
1415-
/// await this._client.flushPendingPromises(); // only emitted for build()
14161415
/// const rpcArgs: Record&lt;string, unknown&gt; = { builder: this._handle, name, value };
14171416
/// const result = await this._client.invokeCapability&lt;RedisResourceHandle&gt;('...', rpcArgs);
14181417
/// return new RedisResourceImpl(result, this._client);
@@ -1422,6 +1421,12 @@ private void GenerateBuilderClass(BuilderModel builder)
14221421
/// return new RedisResourcePromiseImpl(
14231422
/// this._withEnvironmentInternal(name, value), this._client);
14241423
/// }
1424+
///
1425+
/// // For build(), the public wrapper flushes pending promises first:
1426+
/// build(): DistributedApplicationPromise {
1427+
/// const flushAndBuild = async () =&gt; { await this._client.flushPendingPromises(); return this._buildInternal(); };
1428+
/// return new DistributedApplicationPromiseImpl(flushAndBuild(), this._client);
1429+
/// }
14251430
/// </code>
14261431
/// <para>When a parameter is a handle type, promise resolution is emitted before the RPC args
14271432
/// (e.g. <c>db = isPromiseLike(db) ? await db : db;</c>).</para>
@@ -1552,12 +1557,6 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
15521557
// Resolve any promise-like handle parameters before building rpcArgs
15531558
GeneratePromiseResolution(capability.Parameters);
15541559

1555-
// Flush pending promises before build to ensure all un-awaited chains complete
1556-
if (string.Equals(capability.MethodName, "build", StringComparison.OrdinalIgnoreCase))
1557-
{
1558-
WriteLine(" await this._client.flushPendingPromises();");
1559-
}
1560-
15611560
// Build args object with conditional inclusion
15621561
GenerateArgsObjectWithConditionals(targetParamName, requiredParams, optionalParams);
15631562

@@ -1597,9 +1596,21 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab
15971596

15981597
// Forward all params to internal method
15991598
var allParamNames = capability.Parameters.Select(p => p.Name);
1600-
Write($" return new {promiseImplementationClass}(this.{internalMethodName}(");
1601-
Write(string.Join(", ", allParamNames));
1602-
WriteLine("), this._client);");
1599+
var internalCall = $"this.{internalMethodName}({string.Join(", ", allParamNames)})";
1600+
1601+
// For build(), flush pending promises before invoking the internal method.
1602+
// This must happen in the public wrapper (not _buildInternal) to avoid deadlock:
1603+
// the PromiseImpl constructor tracks the build promise, and if _buildInternal
1604+
// awaited flushPendingPromises, the flush would re-await the tracked build promise.
1605+
if (string.Equals(capability.MethodName, "build", StringComparison.OrdinalIgnoreCase))
1606+
{
1607+
WriteLine($" const flushAndBuild = async () => {{ await this._client.flushPendingPromises(); return {internalCall}; }};");
1608+
WriteLine($" return new {promiseImplementationClass}(flushAndBuild(), this._client);");
1609+
}
1610+
else
1611+
{
1612+
WriteLine($" return new {promiseImplementationClass}({internalCall}, this._client);");
1613+
}
16031614
WriteLine(" }");
16041615
WriteLine();
16051616
}
@@ -3044,9 +3055,18 @@ private void GenerateTypeClassMethod(BuilderModel model, AtsCapabilityInfo capab
30443055
WriteLine($" {(IsWidenedHandleType(param.Type) ? "let" : "const")} {param.Name} = options?.{param.Name};");
30453056
}
30463057

3047-
Write($" return new {returnPromiseImplementationClass}(this.{internalMethodName}(");
3048-
Write(string.Join(", ", userParams.Select(p => p.Name)));
3049-
WriteLine("), this._client);");
3058+
var internalCall = $"this.{internalMethodName}({string.Join(", ", userParams.Select(p => p.Name))})";
3059+
3060+
// For build(), flush pending promises before invoking the internal method to avoid deadlock
3061+
if (string.Equals(methodName, "build", StringComparison.OrdinalIgnoreCase))
3062+
{
3063+
WriteLine($" const flushAndBuild = async () => {{ await this._client.flushPendingPromises(); return {internalCall}; }};");
3064+
WriteLine($" return new {returnPromiseImplementationClass}(flushAndBuild(), this._client);");
3065+
}
3066+
else
3067+
{
3068+
WriteLine($" return new {returnPromiseImplementationClass}({internalCall}, this._client);");
3069+
}
30503070
WriteLine(" }");
30513071
}
30523072
else if (isVoid)

src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/transport.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,10 +761,15 @@ export class AspireClient implements AspireClientRpc {
761761

762762
trackPromise(promise: Promise<unknown>): void {
763763
this._pendingPromises.add(promise);
764-
promise.finally(() => this._pendingPromises.delete(promise));
764+
promise.finally(() => this._pendingPromises.delete(promise)).catch(() => {});
765765
}
766766

767767
async flushPendingPromises(): Promise<void> {
768+
if (this._pendingPromises.size > 0) {
769+
console.warn(`Flushing ${this._pendingPromises.size} pending promise(s). Consider awaiting fluent calls to avoid implicit flushing.`);
770+
}
771+
// Loop because awaiting existing promises may cause new ones to be enqueued.
772+
// Awaiting a tracked promise also removes it from the set via the .finally() handler.
768773
while (this._pendingPromises.size > 0) {
769774
await Promise.all(this._pendingPromises);
770775
}

tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4055,7 +4055,8 @@ class DistributedApplicationBuilderImpl implements DistributedApplicationBuilder
40554055
}
40564056

40574057
build(): DistributedApplicationPromise {
4058-
return new DistributedApplicationPromiseImpl(this._buildInternal(), this._client);
4058+
const flushAndBuild = async () => { await this._client.flushPendingPromises(); return this._buildInternal(); };
4059+
return new DistributedApplicationPromiseImpl(flushAndBuild(), this._client);
40594060
}
40604061

40614062
/** Adds a connection string with a reference expression */

tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// transport.ts - ATS transport layer: RPC, Handle, errors, callbacks
1+
// transport.ts - ATS transport layer: RPC, Handle, errors, callbacks
22
import * as net from 'net';
33
import * as rpc from 'vscode-jsonrpc/node.js';
44

@@ -761,10 +761,15 @@ export class AspireClient implements AspireClientRpc {
761761

762762
trackPromise(promise: Promise<unknown>): void {
763763
this._pendingPromises.add(promise);
764-
promise.finally(() => this._pendingPromises.delete(promise));
764+
promise.finally(() => this._pendingPromises.delete(promise)).catch(() => {});
765765
}
766766

767767
async flushPendingPromises(): Promise<void> {
768+
if (this._pendingPromises.size > 0) {
769+
console.warn(`Flushing ${this._pendingPromises.size} pending promise(s). Consider awaiting fluent calls to avoid implicit flushing.`);
770+
}
771+
// Loop because awaiting existing promises may cause new ones to be enqueued.
772+
// Awaiting a tracked promise also removes it from the set via the .finally() handler.
768773
while (this._pendingPromises.size > 0) {
769774
await Promise.all(this._pendingPromises);
770775
}

0 commit comments

Comments
 (0)