Skip to content

Commit 42524ce

Browse files
authored
fix: waitForState timeout handler fails to remove stale waiter (#330)
1 parent 2a1f51c commit 42524ce

2 files changed

Lines changed: 84 additions & 13 deletions

File tree

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

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -429,32 +429,49 @@ export class InMemoryOrchestrationBackend {
429429
return new Promise((resolve, reject) => {
430430
// When timeoutMs is 0, no timeout is applied — the waiter will only be
431431
// resolved by a matching state change or rejected by reset().
432+
// `waiter` is declared before the timer so the timeout callback can find it by
433+
// object identity; the waiter reads `timer` only when invoked, by which point
434+
// it has been assigned.
432435
let timer: ReturnType<typeof setTimeout> | undefined;
436+
437+
const waiter: StateWaiter = {
438+
resolve: (result) => {
439+
if (timer !== undefined) {
440+
clearTimeout(timer);
441+
this.pendingTimers.delete(timer);
442+
}
443+
resolve(result);
444+
},
445+
reject: (error) => {
446+
if (timer !== undefined) {
447+
clearTimeout(timer);
448+
this.pendingTimers.delete(timer);
449+
}
450+
reject(error);
451+
},
452+
predicate,
453+
};
454+
433455
if (timeoutMs > 0) {
434456
timer = setTimeout(() => {
457+
if (timer !== undefined) {
458+
this.pendingTimers.delete(timer);
459+
}
435460
const waiters = this.stateWaiters.get(instanceId);
436461
if (waiters) {
437-
const index = waiters.findIndex((w) => w.resolve === resolve);
462+
const index = waiters.indexOf(waiter);
438463
if (index >= 0) {
439464
waiters.splice(index, 1);
440465
}
466+
if (waiters.length === 0) {
467+
this.stateWaiters.delete(instanceId);
468+
}
441469
}
442470
reject(new Error(`Timeout waiting for orchestration '${instanceId}'`));
443471
}, timeoutMs);
472+
this.pendingTimers.add(timer);
444473
}
445474

446-
const waiter: StateWaiter = {
447-
resolve: (result) => {
448-
if (timer !== undefined) clearTimeout(timer);
449-
resolve(result);
450-
},
451-
reject: (error) => {
452-
if (timer !== undefined) clearTimeout(timer);
453-
reject(error);
454-
},
455-
predicate,
456-
};
457-
458475
let waiters = this.stateWaiters.get(instanceId);
459476
if (!waiters) {
460477
waiters = [];

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,4 +933,58 @@ describe("In-Memory Backend", () => {
933933
expect(backend.toClientStatus(suspendedInstance!.status)).toEqual(OrchestrationStatus.SUSPENDED);
934934
});
935935
});
936+
937+
it("should clean up stale waiters after waitForState timeout", async () => {
938+
const instanceId = "timeout-cleanup-test";
939+
backend.createInstance(instanceId, "testOrch");
940+
941+
// Call waitForState with a predicate that will never match and a short timeout
942+
await expect(
943+
backend.waitForState(
944+
instanceId,
945+
() => false, // Will never match
946+
50, // 50ms timeout
947+
),
948+
).rejects.toThrow("Timeout waiting for orchestration");
949+
950+
// After the timeout, the stale waiter should be cleaned up.
951+
// Access internal stateWaiters to verify the waiter was removed.
952+
const stateWaitersMap = (backend as any).stateWaiters as Map<string, any[]>;
953+
954+
// The timed-out waiter should have been removed, and since it was the only
955+
// waiter, the instance entry should be removed from the map entirely.
956+
expect(stateWaitersMap.has(instanceId)).toBe(false);
957+
});
958+
959+
it("should remove only the timed-out waiter when multiple waiters exist", async () => {
960+
const instanceId = "multi-waiter-timeout-test";
961+
backend.createInstance(instanceId, "testOrch");
962+
963+
// Start a waiter with a long timeout (won't time out during the test)
964+
const longWaitPromise = backend.waitForState(
965+
instanceId,
966+
() => false, // Never matches
967+
60000, // 60 second timeout — won't fire
968+
);
969+
970+
// Start a waiter with a very short timeout
971+
await expect(
972+
backend.waitForState(
973+
instanceId,
974+
() => false, // Never matches
975+
50, // 50ms timeout
976+
),
977+
).rejects.toThrow("Timeout waiting for orchestration");
978+
979+
// After the short timeout, only the long-lived waiter should remain
980+
const stateWaitersMap = (backend as any).stateWaiters as Map<string, any[]>;
981+
const waiters = stateWaitersMap.get(instanceId);
982+
983+
expect(waiters).toBeDefined();
984+
expect(waiters!.length).toBe(1);
985+
986+
// Clean up: reset to clear the remaining waiter and its timer
987+
backend.reset();
988+
await longWaitPromise.catch(() => {}); // Ignore the reset rejection
989+
});
936990
});

0 commit comments

Comments
 (0)