From 777ff8f7763375b5e48acf11fa656f53f2f4bc96 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 15 Jul 2026 13:10:51 -0700 Subject: [PATCH 1/2] test(InProcessTestHost): add external-event + durable-timer timeout coverage Adds InProcessTestHost.Tests coverage for WaitForExternalEvent, including the canonical Task.WhenAny timeout pattern that cancels the durable timer when the event wins. Documents that an orchestration does not complete while a durable timer is still outstanding. Relates to #713. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ExternalEventTests.cs | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 test/InProcessTestHost.Tests/ExternalEventTests.cs diff --git a/test/InProcessTestHost.Tests/ExternalEventTests.cs b/test/InProcessTestHost.Tests/ExternalEventTests.cs new file mode 100644 index 00000000..07b2739c --- /dev/null +++ b/test/InProcessTestHost.Tests/ExternalEventTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Testing; +using Microsoft.DurableTask.Worker; +using Xunit; + +namespace InProcessTestHost.Tests; + +/// +/// Tests for external event delivery via , +/// including the canonical "wait for an event with a timeout" pattern built on and a +/// durable timer. +/// +/// +/// A durable timer keeps an orchestration in the "Running" state until it expires or is cancelled. As documented on +/// , all durable timers +/// must either expire or be cancelled via their before the orchestrator +/// can complete. When a timer is used only as a timeout (for example, with ), cancel it +/// once the other task wins so the orchestration completes promptly instead of waiting for the timer to fire. See +/// https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-timers. +/// +public class ExternalEventTests +{ + [Fact] + // A plain external event is delivered to a waiting orchestration and its payload is returned. + public async Task WaitForExternalEvent_IsDelivered() + { + const string orchestratorName = "WaitForEventOrchestrator"; + + await using DurableTaskTestHost host = await DurableTaskTestHost.StartAsync(tasks => + { + tasks.AddOrchestratorFunc(orchestratorName, async ctx => + { + return await ctx.WaitForExternalEvent("MyEvent"); + }); + }); + + string instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + + // Give the instance a moment to start before raising the event. + await Task.Delay(TimeSpan.FromSeconds(1)); + await host.Client.RaiseEventAsync(instanceId, "MyEvent", "hello"); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + OrchestrationMetadata metadata = await host.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, cts.Token); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("hello", metadata.ReadOutputAs()); + } + + [Fact] + // Canonical wait-for-event-with-timeout: when the event wins the Task.WhenAny, cancelling the durable timer lets the + // orchestration complete promptly, well before the 5-minute timer would fire. + public async Task WaitForExternalEvent_WithTimeout_EventWins() + { + const string orchestratorName = "EventWithTimeoutOrchestrator"; + + await using DurableTaskTestHost host = await DurableTaskTestHost.StartAsync(tasks => + { + tasks.AddOrchestratorFunc(orchestratorName, async ctx => + { + using CancellationTokenSource timerCts = new(); + Task eventTask = ctx.WaitForExternalEvent("MyEvent"); + Task timerTask = ctx.CreateTimer(TimeSpan.FromMinutes(5), timerCts.Token); + Task winner = await Task.WhenAny(eventTask, timerTask); + if (winner == eventTask) + { + // Cancel the still-pending durable timer so the orchestration can complete now. + timerCts.Cancel(); + return await eventTask; + } + + return "timeout"; + }); + }); + + string instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + + // Raise the event shortly after start, while the 5-minute timer is still pending. + await Task.Delay(TimeSpan.FromSeconds(1)); + await host.Client.RaiseEventAsync(instanceId, "MyEvent", "event"); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + OrchestrationMetadata metadata = await host.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, cts.Token); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("event", metadata.ReadOutputAs()); + } + + [Fact] + // Same pattern with a short timer and no event raised: the timer wins and the orchestration returns "timeout". + public async Task WaitForExternalEvent_WithTimeout_TimerWins() + { + const string orchestratorName = "TimeoutWinsOrchestrator"; + + await using DurableTaskTestHost host = await DurableTaskTestHost.StartAsync(tasks => + { + tasks.AddOrchestratorFunc(orchestratorName, async ctx => + { + using CancellationTokenSource timerCts = new(); + Task eventTask = ctx.WaitForExternalEvent("MyEvent"); + Task timerTask = ctx.CreateTimer(TimeSpan.FromSeconds(2), timerCts.Token); + Task winner = await Task.WhenAny(eventTask, timerTask); + if (winner == eventTask) + { + timerCts.Cancel(); + return await eventTask; + } + + return "timeout"; + }); + }); + + string instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + OrchestrationMetadata metadata = await host.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, cts.Token); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("timeout", metadata.ReadOutputAs()); + } +} From ccd24450f16c01f8485cf263c8bde183a5abc802 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 15 Jul 2026 13:43:35 -0700 Subject: [PATCH 2/2] test(InProcessTestHost): address review feedback on external-event tests Use WaitForInstanceStartAsync instead of fixed delays, and cancel the losing external-event wait in the timeout branch to mirror the SDK's WaitForExternalEvent(name, timeout) helper. Relates to #713. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ExternalEventTests.cs | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/InProcessTestHost.Tests/ExternalEventTests.cs b/test/InProcessTestHost.Tests/ExternalEventTests.cs index 07b2739c..7cbce7be 100644 --- a/test/InProcessTestHost.Tests/ExternalEventTests.cs +++ b/test/InProcessTestHost.Tests/ExternalEventTests.cs @@ -40,11 +40,12 @@ public async Task WaitForExternalEvent_IsDelivered() string instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); - // Give the instance a moment to start before raising the event. - await Task.Delay(TimeSpan.FromSeconds(1)); + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + + // Wait for the orchestration to start before raising the event. + await host.Client.WaitForInstanceStartAsync(instanceId, cts.Token); await host.Client.RaiseEventAsync(instanceId, "MyEvent", "hello"); - using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); OrchestrationMetadata metadata = await host.Client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true, cts.Token); @@ -65,7 +66,8 @@ public async Task WaitForExternalEvent_WithTimeout_EventWins() tasks.AddOrchestratorFunc(orchestratorName, async ctx => { using CancellationTokenSource timerCts = new(); - Task eventTask = ctx.WaitForExternalEvent("MyEvent"); + using CancellationTokenSource eventCts = new(); + Task eventTask = ctx.WaitForExternalEvent("MyEvent", eventCts.Token); Task timerTask = ctx.CreateTimer(TimeSpan.FromMinutes(5), timerCts.Token); Task winner = await Task.WhenAny(eventTask, timerTask); if (winner == eventTask) @@ -75,17 +77,21 @@ public async Task WaitForExternalEvent_WithTimeout_EventWins() return await eventTask; } + // Cancel the losing external-event wait, mirroring the SDK's WaitForExternalEvent(name, timeout) helper. + eventCts.Cancel(); return "timeout"; }); }); string instanceId = await host.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); - // Raise the event shortly after start, while the 5-minute timer is still pending. - await Task.Delay(TimeSpan.FromSeconds(1)); + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); + + // Wait for the orchestration to start (and begin waiting on the event) before raising it, + // while the 5-minute timer is still pending. + await host.Client.WaitForInstanceStartAsync(instanceId, cts.Token); await host.Client.RaiseEventAsync(instanceId, "MyEvent", "event"); - using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); OrchestrationMetadata metadata = await host.Client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true, cts.Token); @@ -105,7 +111,8 @@ public async Task WaitForExternalEvent_WithTimeout_TimerWins() tasks.AddOrchestratorFunc(orchestratorName, async ctx => { using CancellationTokenSource timerCts = new(); - Task eventTask = ctx.WaitForExternalEvent("MyEvent"); + using CancellationTokenSource eventCts = new(); + Task eventTask = ctx.WaitForExternalEvent("MyEvent", eventCts.Token); Task timerTask = ctx.CreateTimer(TimeSpan.FromSeconds(2), timerCts.Token); Task winner = await Task.WhenAny(eventTask, timerTask); if (winner == eventTask) @@ -114,6 +121,8 @@ public async Task WaitForExternalEvent_WithTimeout_TimerWins() return await eventTask; } + // The timer won: cancel the losing external-event wait so no wait is left outstanding. + eventCts.Cancel(); return "timeout"; }); });