Skip to content

Commit 623e260

Browse files
authored
Merge pull request #87 from Cysharp/feature/AbortOnCanceled
Fixed to cancel requests correctly when a request is canceled before receiving a response.
2 parents d001fb5 + 70fa2a0 commit 623e260

File tree

5 files changed

+153
-4
lines changed

5 files changed

+153
-4
lines changed

src/YetAnotherHttpHandler/ResponseContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ public void Cancel()
150150

151151
lock (_writeLock)
152152
{
153+
_requestContext.TryAbort();
153154
_responseTask.TrySetCanceled(_cancellationToken);
154155
_pipe.Writer.Complete(new OperationCanceledException(_cancellationToken));
155156
_completed = true;

test/YetAnotherHttpHandler.Test/Http2TestBase.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,37 @@ public async Task DisposeHttpResponseMessage_Post_SendingBody_Duplex()
365365
Assert.IsAssignableFrom<IOException>(ex.InnerException);
366366
}
367367

368+
[ConditionalFact]
369+
public async Task Cancel_Get_BeforeReceivingResponseHeaders()
370+
{
371+
// Arrange
372+
using var httpHandler = CreateHandler();
373+
var httpClient = new HttpClient(httpHandler);
374+
await using var server = await LaunchServerAsync<TestServerForHttp1AndHttp2>();
375+
var id = Guid.NewGuid().ToString();
376+
377+
// Act
378+
var request = new HttpRequestMessage(HttpMethod.Get, $"{server.BaseUri}/slow-response-headers")
379+
{
380+
Version = HttpVersion.Version20,
381+
Headers = { { TestServerForHttp1AndHttp2.SessionStateHeaderKey, id } }
382+
};
383+
384+
// The server responds after one second. But the client cancels the request before receiving response headers.
385+
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
386+
var ex = await Record.ExceptionAsync(async () => await httpClient.SendAsync(request, cts.Token).WaitAsync(TimeoutToken));
387+
await Task.Delay(100);
388+
var isCanceled = await httpClient.GetStringAsync($"{server.BaseUri}/session-state?id={id}&key=IsCanceled").WaitAsync(TimeoutToken);
389+
390+
// Assert
391+
var operationCanceledException = Assert.IsAssignableFrom<OperationCanceledException>(ex);
392+
#if !UNITY_2021_1_OR_NEWER
393+
// NOTE: Unity's Mono HttpClient internally creates a new CancellationTokenSource.
394+
Assert.Equal(cts.Token, operationCanceledException.CancellationToken);
395+
#endif
396+
Assert.Equal("True", isCanceled);
397+
}
398+
368399
[ConditionalFact]
369400
public async Task Cancel_Post_BeforeRequest()
370401
{
@@ -593,7 +624,11 @@ public async Task Grpc_Error_TimedOut_With_HttpClientTimeout()
593624
// Assert
594625
Assert.IsType<RpcException>(ex);
595626
Assert.Equal(StatusCode.Cancelled, ((RpcException)ex).StatusCode);
627+
#if UNITY_2021_1_OR_NEWER
628+
Assert.IsType<OperationCanceledException>(((RpcException)ex).Status.DebugException);
629+
#else
596630
Assert.IsType<TaskCanceledException>(((RpcException)ex).Status.DebugException);
631+
#endif
597632
}
598633

599634
[ConditionalFact]

test/YetAnotherHttpHandler.Test/TestServerForHttp1AndHttp2.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,60 @@
11
using System.Buffers;
2+
using System.Collections.Concurrent;
23
using System.IO.Pipelines;
34
using System.Net;
45
using Grpc.Core;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Http;
78
using Microsoft.AspNetCore.Http.Features;
9+
using Microsoft.Extensions.DependencyInjection;
810
using TestWebApp;
911

1012
namespace _YetAnotherHttpHandler.Test;
1113

1214
class TestServerForHttp1AndHttp2 : ITestServerBuilder
1315
{
16+
private const string SessionStateKey = "SessionState";
17+
public const string SessionStateHeaderKey = "x-yahatest-session-id";
18+
19+
record SessionStateFeature(ConcurrentDictionary<string, object> Items);
20+
1421
public static WebApplication BuildApplication(WebApplicationBuilder builder)
1522
{
23+
builder.Services.AddKeyedSingleton(SessionStateKey, new ConcurrentDictionary<string, ConcurrentDictionary<string, object>>());
24+
1625
var app = builder.Build();
1726

27+
// SessionState
28+
app.Use((ctx, next) =>
29+
{
30+
if (ctx.Request.Headers.TryGetValue(SessionStateHeaderKey, out var headerValues))
31+
{
32+
var sessionStates = ctx.RequestServices.GetRequiredKeyedService<ConcurrentDictionary<string, ConcurrentDictionary<string, object>>>(SessionStateKey);
33+
var sessionStateItems = sessionStates.GetOrAdd(headerValues.ToString(), _ => new ConcurrentDictionary<string, object>());
34+
ctx.Features.Set(new SessionStateFeature(sessionStateItems));
35+
}
36+
else
37+
{
38+
ctx.Features.Set(new SessionStateFeature(new ConcurrentDictionary<string, object>()));
39+
}
40+
41+
return next(ctx);
42+
});
43+
app.MapGet("/session-state", (HttpContext ctx, string id, string key) =>
44+
{
45+
var sessionStates = ctx.RequestServices.GetRequiredKeyedService<ConcurrentDictionary<string, ConcurrentDictionary<string, object>>>(SessionStateKey);
46+
if (sessionStates.TryGetValue(id, out var items))
47+
{
48+
if (items.TryGetValue(key, out var value))
49+
{
50+
return Results.Content(value.ToString());
51+
}
52+
return Results.Content(string.Empty);
53+
}
54+
55+
return Results.NotFound();
56+
});
57+
1858
// HTTP/1 and HTTP/2
1959
app.MapGet("/", () => Results.Content("__OK__"));
2060
app.MapGet("/not-found", () => Results.Content("__Not_Found__", statusCode: 404));
@@ -23,6 +63,18 @@ public static WebApplication BuildApplication(WebApplicationBuilder builder)
2363
httpContext.Response.Headers["x-test"] = "foo";
2464
return Results.Content("__OK__");
2565
});
66+
app.MapGet("/slow-response-headers", async (HttpContext httpContext) =>
67+
{
68+
using var _ = httpContext.RequestAborted.Register(() =>
69+
{
70+
httpContext.Features.GetRequiredFeature<SessionStateFeature>().Items["IsCanceled"] = true;
71+
});
72+
73+
await Task.Delay(1000);
74+
httpContext.Response.Headers["x-test"] = "foo";
75+
76+
return Results.Content("__OK__");
77+
});
2678
app.MapGet("/ハロー", () => Results.Content("Konnichiwa"));
2779
app.MapPost("/slow-upload", async (HttpContext ctx, PipeReader reader) =>
2880
{

test/YetAnotherHttpHandler.Unity.Test/Assets/Tests/Http2ClearTextTest.cs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
public class Http2ClearTextTest : YahaUnityTestBase
2020
{
21-
[Fact]
21+
[ConditionalFact]
2222
public async Task FailedToConnect_VersionMismatch()
2323
{
2424
// Arrange
@@ -343,7 +343,7 @@ public async Task Cancel_Post_SendingBody_Duplex()
343343
// Assert
344344
var operationCanceledException = Assert.IsAssignableFrom<OperationCanceledException>(ex);
345345
Assert.Equal(cts.Token, operationCanceledException.CancellationToken);
346-
}
346+
}
347347
#endif
348348

349349
[ConditionalFact]
@@ -376,6 +376,37 @@ public async Task DisposeHttpResponseMessage_Post_SendingBody_Duplex()
376376
Assert.IsAssignableFrom<IOException>(ex.InnerException);
377377
}
378378

379+
[ConditionalFact]
380+
public async Task Cancel_Get_BeforeReceivingResponseHeaders()
381+
{
382+
// Arrange
383+
using var httpHandler = CreateHandler();
384+
var httpClient = new HttpClient(httpHandler);
385+
await using var server = await LaunchServerAsync<TestServerForHttp1AndHttp2>();
386+
var id = Guid.NewGuid().ToString();
387+
388+
// Act
389+
var request = new HttpRequestMessage(HttpMethod.Get, $"{server.BaseUri}/slow-response-headers")
390+
{
391+
Version = HttpVersion.Version20,
392+
Headers = { { TestServerForHttp1AndHttp2.SessionStateHeaderKey, id } }
393+
};
394+
395+
// The server responds after one second. But the client cancels the request before receiving response headers.
396+
var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
397+
var ex = await Record.ExceptionAsync(async () => await httpClient.SendAsync(request, cts.Token).WaitAsync(TimeoutToken));
398+
await Task.Delay(100);
399+
var isCanceled = await httpClient.GetStringAsync($"{server.BaseUri}/session-state?id={id}&key=IsCanceled").WaitAsync(TimeoutToken);
400+
401+
// Assert
402+
var operationCanceledException = Assert.IsAssignableFrom<OperationCanceledException>(ex);
403+
#if !UNITY_2021_1_OR_NEWER
404+
// NOTE: Unity's Mono HttpClient internally creates a new CancellationTokenSource.
405+
Assert.Equal(cts.Token, operationCanceledException.CancellationToken);
406+
#endif
407+
Assert.Equal("True", isCanceled);
408+
}
409+
379410
[ConditionalFact]
380411
public async Task Cancel_Post_BeforeRequest()
381412
{
@@ -646,6 +677,33 @@ public async Task Grpc_Error_TimedOut_With_CancellationToken()
646677
Assert.Equal(StatusCode.Cancelled, ((RpcException)ex).StatusCode);
647678
}
648679

680+
[ConditionalFact]
681+
public async Task Enable_Http2KeepAlive()
682+
{
683+
// Arrange
684+
using var httpHandler = CreateHandler();
685+
httpHandler.Http2KeepAliveInterval = TimeSpan.FromSeconds(5);
686+
httpHandler.Http2KeepAliveTimeout = TimeSpan.FromSeconds(5);
687+
httpHandler.Http2KeepAliveWhileIdle = true;
688+
689+
var httpClient = new HttpClient(httpHandler);
690+
await using var server = await LaunchServerAsync<TestServerForHttp1AndHttp2>();
691+
692+
// Act
693+
var request = new HttpRequestMessage(HttpMethod.Get, $"{server.BaseUri}/")
694+
{
695+
Version = HttpVersion.Version20,
696+
};
697+
var response = await httpClient.SendAsync(request).WaitAsync(TimeoutToken);
698+
var responseBody = await response.Content.ReadAsStringAsync().WaitAsync(TimeoutToken);
699+
700+
// Assert
701+
Assert.Equal("__OK__", responseBody);
702+
Assert.Equal(HttpVersion.Version20, response.Version);
703+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
704+
}
705+
706+
649707
// Content with default value of true for AllowDuplex because AllowDuplex is internal.
650708
class DuplexStreamContent : HttpContent
651709
{

test/YetAnotherHttpHandler.Unity.Test/Assets/Tests/YahaUnityTestBase.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
public abstract class YahaUnityTestBase
99
{
10-
protected HttpMessageHandler CreateHandler()
10+
protected YetAnotherHttpHandler CreateHandler()
1111
=> new YetAnotherHttpHandler() { Http2Only = true };
1212

1313
protected CancellationToken TimeoutToken { get; private set; }
@@ -43,7 +43,10 @@ public enum TestWebAppServerListenMode
4343
SecureHttp1AndHttp2,
4444
}
4545

46-
protected class TestServerForHttp1AndHttp2 { }
46+
protected class TestServerForHttp1AndHttp2
47+
{
48+
public const string SessionStateHeaderKey = "x-yahatest-session-id";
49+
}
4750

4851
[SetUp]
4952
public void Setup()

0 commit comments

Comments
 (0)