Skip to content

Commit f3f170f

Browse files
committed
fix: sub-orchestration watcher uses unintended 30s default timeout in InMemoryOrchestrationBackend
The watchSubOrchestration method in InMemoryOrchestrationBackend intended to wait indefinitely for sub-orchestrations to complete (as documented by the inline comment 'No timeout'), but actually used the default 30-second timeout from waitForState. After 30 seconds, the watcher silently dropped the completion event via .catch(), causing the parent orchestration to hang indefinitely. Fix: - Modified waitForState to support no-timeout mode when timeoutMs is 0 - Updated watchSubOrchestration to pass timeoutMs=0 matching the documented intent - Added tests for sub-orchestrations with timer delays and failure propagation - Added tests for waitForState zero-timeout behavior and cleanup on reset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d437b57 commit f3f170f

2 files changed

Lines changed: 103 additions & 13 deletions

File tree

packages/durabletask-js/src/testing/in-memory-backend.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -337,24 +337,29 @@ export class InMemoryOrchestrationBackend {
337337
}
338338

339339
return new Promise((resolve, reject) => {
340-
const timer = setTimeout(() => {
341-
const waiters = this.stateWaiters.get(instanceId);
342-
if (waiters) {
343-
const index = waiters.findIndex((w) => w.resolve === resolve);
344-
if (index >= 0) {
345-
waiters.splice(index, 1);
340+
// When timeoutMs is 0, no timeout is applied — the waiter will only be
341+
// resolved by a matching state change or rejected by reset().
342+
let timer: ReturnType<typeof setTimeout> | undefined;
343+
if (timeoutMs > 0) {
344+
timer = setTimeout(() => {
345+
const waiters = this.stateWaiters.get(instanceId);
346+
if (waiters) {
347+
const index = waiters.findIndex((w) => w.resolve === resolve);
348+
if (index >= 0) {
349+
waiters.splice(index, 1);
350+
}
346351
}
347-
}
348-
reject(new Error(`Timeout waiting for orchestration '${instanceId}'`));
349-
}, timeoutMs);
352+
reject(new Error(`Timeout waiting for orchestration '${instanceId}'`));
353+
}, timeoutMs);
354+
}
350355

351356
const waiter: StateWaiter = {
352357
resolve: (result) => {
353-
clearTimeout(timer);
358+
if (timer !== undefined) clearTimeout(timer);
354359
resolve(result);
355360
},
356361
reject: (error) => {
357-
clearTimeout(timer);
362+
if (timer !== undefined) clearTimeout(timer);
358363
reject(error);
359364
},
360365
predicate,
@@ -590,8 +595,7 @@ export class InMemoryOrchestrationBackend {
590595
this.waitForState(
591596
subInstanceId,
592597
(inst) => this.isTerminalStatus(inst.status),
593-
// No timeout - sub-orchestration will eventually complete, fail, or be terminated
594-
// If parent is terminated, we check that when delivering the event
598+
0, // No timeout — sub-orchestration will eventually complete, fail, or be terminated
595599
)
596600
.then((subInstance) => {
597601
const parentInstance = this.instances.get(parentInstanceId);

packages/durabletask-js/test/in-memory-backend.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,56 @@ describe("In-Memory Backend", () => {
146146
expect(activityCounter).toEqual(1);
147147
});
148148

149+
it("should handle sub-orchestrations with timer delays", async () => {
150+
const childWithTimer: TOrchestrator = async function* (ctx: OrchestrationContext): any {
151+
// Sub-orchestration uses a short timer before returning a result
152+
yield ctx.createTimer(0.1);
153+
return "child-done";
154+
};
155+
156+
const parentOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
157+
const result = yield ctx.callSubOrchestrator(childWithTimer);
158+
return `parent-received-${result}`;
159+
};
160+
161+
worker.addOrchestrator(childWithTimer);
162+
worker.addOrchestrator(parentOrchestrator);
163+
await worker.start();
164+
165+
const id = await client.scheduleNewOrchestration(parentOrchestrator);
166+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
167+
168+
expect(state).toBeDefined();
169+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
170+
expect(state?.serializedOutput).toEqual(JSON.stringify("parent-received-child-done"));
171+
});
172+
173+
it("should handle sub-orchestration failure", async () => {
174+
const failingChild: TOrchestrator = async (_ctx: OrchestrationContext) => {
175+
throw new Error("child failed");
176+
};
177+
178+
const parentOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
179+
try {
180+
yield ctx.callSubOrchestrator(failingChild);
181+
return "should not reach";
182+
} catch (error: any) {
183+
return `caught: ${error.message}`;
184+
}
185+
};
186+
187+
worker.addOrchestrator(failingChild);
188+
worker.addOrchestrator(parentOrchestrator);
189+
await worker.start();
190+
191+
const id = await client.scheduleNewOrchestration(parentOrchestrator);
192+
const state = await client.waitForOrchestrationCompletion(id, true, 10);
193+
194+
expect(state).toBeDefined();
195+
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
196+
expect(state?.serializedOutput).toContain("caught:");
197+
});
198+
149199
it("should handle external events", async () => {
150200
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
151201
const value = yield ctx.waitForExternalEvent("my_event");
@@ -351,4 +401,40 @@ describe("In-Memory Backend", () => {
351401
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
352402
expect(state?.serializedOutput).toEqual(JSON.stringify(42));
353403
});
404+
405+
it("waitForState with zero timeout should wait indefinitely until state matches", async () => {
406+
const orchestrator: TOrchestrator = async (_: OrchestrationContext) => "done";
407+
408+
worker.addOrchestrator(orchestrator);
409+
await worker.start();
410+
411+
const id = await client.scheduleNewOrchestration(orchestrator);
412+
413+
// Use waitForState with timeoutMs=0 (no timeout).
414+
// The orchestration completes quickly, so this should resolve.
415+
const instance = await backend.waitForState(
416+
id,
417+
(inst) => backend.toClientStatus(inst.status) === OrchestrationStatus.COMPLETED,
418+
0,
419+
);
420+
421+
expect(instance).toBeDefined();
422+
});
423+
424+
it("waitForState with zero timeout should be rejected on reset", async () => {
425+
// Create an instance that won't complete (no worker started)
426+
backend.createInstance("stuck-instance", "test", JSON.stringify("input"));
427+
428+
// Start waiting with no timeout
429+
const waitPromise = backend.waitForState(
430+
"stuck-instance",
431+
() => false, // Never matches
432+
0,
433+
);
434+
435+
// Reset should reject the waiter
436+
backend.reset();
437+
438+
await expect(waitPromise).rejects.toThrow("Backend was reset");
439+
});
354440
});

0 commit comments

Comments
 (0)