From e85874fb6c6e4a9443c8203800795026de9306fe Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 2 Mar 2026 21:14:47 +0000 Subject: [PATCH] [copilot-finds] Bug: Fix whenAll([]) hanging orchestration forever whenAll([]) creates a WhenAllTask that never completes because no children exist to trigger onChildCompleted(). The condition _completedTasks == _tasks.length (0 == 0) inside onChildCompleted is never reached since the method is never called. This causes orchestrations to hang forever when using whenAll with a dynamically generated list that happens to be empty. Changes: - WhenAllTask: Immediately complete with [] when given an empty task array - WhenAnyTask: Throw an error for empty task array (matching .NET behavior) - RuntimeOrchestrationContext.run(): Handle already-complete initial yielded tasks by calling resume() immediately, enabling the generator to continue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../durabletask-js/src/task/when-all-task.ts | 6 +++ .../durabletask-js/src/task/when-any-task.ts | 3 ++ .../worker/runtime-orchestration-context.ts | 6 +++ .../test/orchestration_executor.spec.ts | 51 +++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/packages/durabletask-js/src/task/when-all-task.ts b/packages/durabletask-js/src/task/when-all-task.ts index a8c96b6..8677c20 100644 --- a/packages/durabletask-js/src/task/when-all-task.ts +++ b/packages/durabletask-js/src/task/when-all-task.ts @@ -13,6 +13,12 @@ export class WhenAllTask extends CompositeTask { this._completedTasks = 0; this._failedTasks = 0; + + // An empty task list should complete immediately with an empty result + if (tasks.length === 0) { + this._result = [] as T[]; + this._isComplete = true; + } } pendingTasks(): number { diff --git a/packages/durabletask-js/src/task/when-any-task.ts b/packages/durabletask-js/src/task/when-any-task.ts index 290af15..e18c1f5 100644 --- a/packages/durabletask-js/src/task/when-any-task.ts +++ b/packages/durabletask-js/src/task/when-any-task.ts @@ -9,6 +9,9 @@ import { Task } from "./task"; */ export class WhenAnyTask extends CompositeTask> { constructor(tasks: Task[]) { + if (tasks.length === 0) { + throw new Error("whenAny requires at least one task"); + } super(tasks); } diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index cd8b327..c01e19d 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -121,6 +121,12 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // TODO: check if the task is null? this._previousTask = value; + + // If the yielded task is already complete (e.g., whenAll with an empty array), + // resume immediately so the generator can continue. + if (this._previousTask instanceof Task && this._previousTask.isComplete) { + await this.resume(); + } } async resume() { diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index dda9478..3079af4 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -1128,6 +1128,57 @@ describe("Orchestration Executor", () => { expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); }); }); + + it("should complete immediately when whenAll is called with an empty task array", async () => { + const orchestrator: TOrchestrator = async function* (_ctx: OrchestrationContext): any { + const results = yield whenAll([]); + return results; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + const oldEvents: any[] = []; + const newEvents = [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + // The orchestration should complete immediately with an empty array result + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify([])); + }); + + it("should complete when whenAll with empty array is followed by more work", async () => { + const hello = (_: any, name: string) => `Hello ${name}!`; + + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + const emptyResults = yield whenAll([]); + const activityResult = yield ctx.callActivity(hello, "World"); + return { emptyResults, activityResult }; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + const activityName = registry.addActivity(hello); + + // First execution: should schedule the activity after completing whenAll([]) + const oldEvents: any[] = []; + const newEvents = [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; + + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + // The whenAll([]) should complete, then an activity should be scheduled + expect(result.actions.length).toEqual(1); + expect(result.actions[0].hasScheduletask()).toBeTruthy(); + expect(result.actions[0].getScheduletask()?.getName()).toEqual(activityName); + }); + + it("should throw when whenAny is called with an empty task array", () => { + expect(() => whenAny([])).toThrow("whenAny requires at least one task"); + }); }); function getAndValidateSingleCompleteOrchestrationAction(