Restore RequestAborted after timed HTTP workflow failures (#7712)

* Fix RequestAborted restoration for timed HTTP workflows

* Add timeout restoration tests for HttpWorkflowsMiddleware
This commit is contained in:
Sipke Schoorstra 2026-06-14 19:02:23 +02:00 committed by GitHub
parent 9c24f5efe5
commit 48a087e71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 68 additions and 7 deletions

View file

@ -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)

View file

@ -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<InvalidOperationException>(() => ExecuteWithinTimeoutAsync<int>(_ => 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<OperationCanceledException>(() => ExecuteWithinTimeoutAsync<int>(cancellationToken =>
{
observedToken = cancellationToken;
return Task.FromCanceled<int>(cancellationToken);
}, TimeSpan.FromSeconds(1), httpContext));
Assert.True(observedToken.IsCancellationRequested);
Assert.Equal(requestAbortedSource.Token, httpContext.RequestAborted);
}
private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout, HttpContext httpContext)
{
var method = ExecuteWithinTimeoutAsyncMethod.MakeGenericMethod(typeof(T));
var task = (Task<T>)method.Invoke(_middleware, [action, requestTimeout, httpContext])!;
return await task;
}
private static IEnumerable<StoredBookmark> CreateCollidingHttpEndpointBookmarks()
{
yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);