From 48a087e71e2b2d8c2f5b01bd9d3e5b80226383eb Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 14 Jun 2026 19:02:23 +0200 Subject: [PATCH] Restore RequestAborted after timed HTTP workflow failures (#7712) * Fix RequestAborted restoration for timed HTTP workflows * Add timeout restoration tests for HttpWorkflowsMiddleware --- .../Middleware/HttpWorkflowsMiddleware.cs | 16 ++--- .../HttpWorkflowsMiddlewareTests.cs | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs index 53d529d4c..577a062ff 100644 --- a/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs @@ -252,13 +252,15 @@ public class HttpWorkflowsMiddleware(RequestDelegate next) // Replace the original cancellation token with the combined one. httpContext.RequestAborted = combinedTokenSource.Token; - // Execute the action. - var result = await action(httpContext.RequestAborted); - - // Restore the original cancellation token. - httpContext.RequestAborted = originalCancellationToken; - - return result; + try + { + return await action(httpContext.RequestAborted); + } + finally + { + // Restore the original cancellation token even when execution faults or is canceled. + httpContext.RequestAborted = originalCancellationToken; + } } private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path) diff --git a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs index 529b3c226..90eac8bed 100644 --- a/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs +++ b/test/unit/Elsa.Http.UnitTests/Middleware/HttpWorkflowsMiddlewareTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Reflection; using Elsa.Http.Bookmarks; using Elsa.Http.Middleware; using Elsa.Http.Options; @@ -14,6 +15,7 @@ namespace Elsa.Http.UnitTests.Middleware; public class HttpWorkflowsMiddlewareTests { + private static readonly MethodInfo ExecuteWithinTimeoutAsyncMethod = typeof(HttpWorkflowsMiddleware).GetMethod("ExecuteWithinTimeoutAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; private const string CurrentTenantId = "tenant-a"; private const string OtherTenantId = "tenant-b"; private const string BookmarkHash = "http-endpoint:/colliding:get"; @@ -54,6 +56,63 @@ public class HttpWorkflowsMiddlewareTests Assert.False(filter.TenantAgnostic); } + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterSuccess() + { + using var requestAbortedSource = new CancellationTokenSource(); + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + var observedToken = CancellationToken.None; + + var result = await ExecuteWithinTimeoutAsync(async cancellationToken => + { + observedToken = cancellationToken; + Assert.Equal(cancellationToken, httpContext.RequestAborted); + await Task.CompletedTask; + return 42; + }, TimeSpan.FromSeconds(1), httpContext); + + Assert.Equal(42, result); + Assert.NotEqual(requestAbortedSource.Token, observedToken); + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterFault() + { + using var requestAbortedSource = new CancellationTokenSource(); + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + + await Assert.ThrowsAsync(() => ExecuteWithinTimeoutAsync(_ => throw new InvalidOperationException("Boom"), TimeSpan.FromSeconds(1), httpContext)); + + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + [Fact] + public async Task ExecuteWithinTimeoutAsync_RestoresRequestAbortedAfterCancellation() + { + using var requestAbortedSource = new CancellationTokenSource(); + requestAbortedSource.Cancel(); + + var httpContext = new DefaultHttpContext { RequestAborted = requestAbortedSource.Token }; + var observedToken = CancellationToken.None; + + await Assert.ThrowsAnyAsync(() => ExecuteWithinTimeoutAsync(cancellationToken => + { + observedToken = cancellationToken; + return Task.FromCanceled(cancellationToken); + }, TimeSpan.FromSeconds(1), httpContext)); + + Assert.True(observedToken.IsCancellationRequested); + Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted); + } + + private async Task ExecuteWithinTimeoutAsync(Func> action, TimeSpan? requestTimeout, HttpContext httpContext) + { + var method = ExecuteWithinTimeoutAsyncMethod.MakeGenericMethod(typeof(T)); + var task = (Task)method.Invoke(_middleware, [action, requestTimeout, httpContext])!; + return await task; + } + private static IEnumerable CreateCollidingHttpEndpointBookmarks() { yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);