From f56785f8c3a7abfe4373899f9a5464a2b8d0280f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:56:23 +0000 Subject: [PATCH] fix: use toLowerCase() instead of toLocaleLowerCase() for event name matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForExternalEvent() used toLocaleLowerCase() to normalize event names, while handleEventRaised() in the orchestration executor used toLowerCase(). In locales where these produce different results (e.g., Turkish 'I' -> dotless 'ı' vs 'i'), event names registered by waitForExternalEvent would not match events delivered by the executor, causing orchestrations to hang indefinitely. This also violates the determinism invariant: the same orchestration code running on machines with different locale settings would produce different event name keys, potentially breaking replay. The fix changes toLocaleLowerCase() to toLowerCase() in waitForExternalEvent() to be consistent with the executor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 2 +- .../test/orchestration_executor.spec.ts | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 811c179..2170af7 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -366,7 +366,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // arrives. If there are multiple events with the same name, we return // them in the order they were received. const externalEventTask = new CompletableTask(); - const eventName = name.toLocaleLowerCase(); + const eventName = name.toLowerCase(); const eventList = this._receivedEvents[eventName]; if (eventList?.length) { diff --git a/packages/durabletask-js/test/orchestration_executor.spec.ts b/packages/durabletask-js/test/orchestration_executor.spec.ts index 2eef795..599453f 100644 --- a/packages/durabletask-js/test/orchestration_executor.spec.ts +++ b/packages/durabletask-js/test/orchestration_executor.spec.ts @@ -742,6 +742,139 @@ describe("Orchestration Executor", () => { expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("terminated!")); }); + it("should match external event names case-insensitively across waitForExternalEvent and handleEventRaised", async () => { + // Regression test: waitForExternalEvent must use toLowerCase() (not toLocaleLowerCase()) + // to stay consistent with handleEventRaised, which uses toLowerCase(). + // Using toLocaleLowerCase() causes mismatches in locales where the two differ (e.g., Turkish). + const testCases = [ + { waitName: "MY_EVENT", raiseName: "my_event" }, + { waitName: "my_event", raiseName: "MY_EVENT" }, + { waitName: "My_Event", raiseName: "MY_EVENT" }, + { waitName: "ITEM_UPDATED", raiseName: "item_updated" }, + ]; + + for (const { waitName, raiseName } of testCases) { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { + const res = yield ctx.waitForExternalEvent(waitName); + return res; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + let oldEvents: any[] = []; + let newEvents = [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)]; + + // First execution: orchestration waits for the event + let executor = new OrchestrationExecutor(registry, testLogger); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(0); + + // Second execution: send event with different casing — should still match + oldEvents = newEvents; + newEvents = [newEventRaisedEvent(raiseName, '"data"')]; + executor = new OrchestrationExecutor(registry, testLogger); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completeAction?.getResult()?.getValue()).toEqual('"data"'); + } + }); + + it("should match buffered external events case-insensitively when event arrives before waitForExternalEvent", async () => { + // When an event is buffered (arrives before waitForExternalEvent), the executor stores it + // using toLowerCase(). waitForExternalEvent must also use toLowerCase() to find it. + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { + yield ctx.createTimer(new Date(ctx.currentUtcDateTime.getTime() + 24 * 60 * 60 * 1000)); + const res = yield ctx.waitForExternalEvent("MY_EVENT"); + return res; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + let oldEvents: any[] = []; + let newEvents = [ + newOrchestratorStartedEvent(), + newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID), + newEventRaisedEvent("my_event", '"buffered_value"'), + ]; + + // First execution: event arrives early and is buffered; orchestration waits for timer + let executor = new OrchestrationExecutor(registry, testLogger); + let result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + expect(result.actions.length).toBe(1); + expect(result.actions[0].hasCreatetimer()).toBeTruthy(); + + // Complete the timer — orchestration should find the buffered event despite different casing + const timerDueTime = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); + newEvents.push(newTimerCreatedEvent(1, timerDueTime)); + oldEvents = newEvents; + newEvents = [newTimerFiredEvent(1, timerDueTime)]; + executor = new OrchestrationExecutor(registry, testLogger); + result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completeAction?.getResult()?.getValue()).toEqual('"buffered_value"'); + }); + + it("should match event names correctly even when toLocaleLowerCase diverges (Turkish-I simulation)", async () => { + // This test simulates the Turkish locale bug by monkey-patching toLocaleLowerCase so + // "I" maps to "ı" (dotless-i) instead of "i". Before the fix, waitForExternalEvent used + // toLocaleLowerCase() and handleEventRaised used toLowerCase(), so "ITEM" would normalize + // to "ıtem" vs "item" — a mismatch that hangs the orchestration forever. + // After the fix both sides use toLowerCase(), so the monkey-patch has no effect. + const original = String.prototype.toLocaleLowerCase; + // Simulate Turkish locale: uppercase I → dotless-i ı + String.prototype.toLocaleLowerCase = function (this: string) { + return this.replace(/I/g, "ı").replace(/[A-Z]/g, (c) => c.toLowerCase()); + }; + + try { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { + // "ITEM_UPDATED" contains "I" — toLocaleLowerCase would give "ıtem_updated" + const res = yield ctx.waitForExternalEvent("ITEM_UPDATED"); + return res; + }; + + const registry = new Registry(); + const orchestratorName = registry.addOrchestrator(orchestrator); + + // First execution: orchestration starts waiting + let executor = new OrchestrationExecutor(registry, testLogger); + let result = await executor.execute( + TEST_INSTANCE_ID, + [], + [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)], + ); + expect(result.actions.length).toBe(0); + + // Second execution: raise event with lowercase "item_updated" — toLowerCase() normalizes + // both sides to "item_updated" so they match; toLocaleLowerCase() would give "ıtem_updated" + // on the wait side and "item_updated" on the raise side — a mismatch. + executor = new OrchestrationExecutor(registry, testLogger); + result = await executor.execute( + TEST_INSTANCE_ID, + [newOrchestratorStartedEvent(), newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID)], + [newEventRaisedEvent("item_updated", '"ok"')], + ); + + const completeAction = getAndValidateSingleCompleteOrchestrationAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED, + ); + expect(completeAction?.getResult()?.getValue()).toEqual('"ok"'); + } finally { + String.prototype.toLocaleLowerCase = original; + } + }); + it("should be able to continue-as-new", async () => { for (const saveEvent of [true, false]) { const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any {