SocketsHttpHandler で HTTP/2 の双方向ストリームを使用した際にハングする問題
発生する問題
.NET の SocketsHttpHandler で HTTP/2 の Duplex ストリーム (双方向ストリーム) を使用する際にレスポンスヘッダーが返ってくるのを待機するとリクエストが送信されずハングしてしまうことがあります。
問題が発生する条件
この問題が発生する条件は以下の通りです。
- .NET 5以上
- .NET 10 RC 1 でも発生する
- SocketsHttpHandler を使用している
- 低レイテンシーまたは事前にリクエストしていて TCP 接続確立済み
- 特に非 HTTPS (h2c) の場合に発生しやすい
- HTTP/2 のリクエストとレスポンス
- サーバーが HTTP/2 である
- Duplex (双方向ストリーム) を使用する
- SendAsync に HttpCompletionOption.ResponseHeadersRead を指定している
- リクエストストリームへの書き込みが遅延している
- データの準備ができていない、データ送信は確立後に開始、など
これに該当するような使用方法は HTTP/2 の双方向ストリームを直接使用する場合のみなので Grpc.Net.Client のような gRPC クライアントを自作するといったかなり限られたケースで、ほぼ 99% の .NET 開発者は影響を受けないといっても差し支えはないと思われます。
再現コード
下記のコードを実行すると、レスポンスヘッダーを待つところでハングします。タイミングによっては正常に完了してしまうこともあるかもしれません。
using System.IO.Pipelines;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(5000, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http2;
});
});
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.MapPost("/post", async (HttpContext httpContext) =>
{
// ステータスコードとレスポンスヘッダーを即座に返す
httpContext.Response.StatusCode = (int)HttpStatusCode.OK;
httpContext.Response.Headers["Content-Type"] = "text/plain";
await httpContext.Response.BodyWriter.FlushAsync();
// 何か処理があるような雰囲気のためのディレイ
await Task.Delay(1000);
});
_ = app.RunAsync();
var httpHandler = new SocketsHttpHandler();
var httpClient = new HttpClient(httpHandler);
Console.WriteLine("サーバーに接続を確立する");
if (true)
{
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000")
{
Version = HttpVersion.Version20,
VersionPolicy = HttpVersionPolicy.RequestVersionExact
};
var response = await httpClient.SendAsync(request);
_ = await response.Content.ReadAsByteArrayAsync();
}
Console.WriteLine("Duplex なリクエストを送信する");
var pipe = new Pipe();
var content = new DuplexContent(pipe.Reader.AsStream()); // まだデータが準備されていない Stream の疑似再現
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/post")
{
Version = HttpVersion.Version20,
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
Content = content
};
// HttpCompletionOption.ResponseHeadersRead でレスポンスヘッダーが返ってくるまでを待機 (ボディーの完了を待たない)
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"レスポンスヘッダーを受信: StatusCode={response.StatusCode}");
// Duplex 可能かどうかは AllowDuplex という internal なプロパティーなので HttpContent を継承すると自動で true になる
class DuplexContent(Stream source) : HttpContent
{
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
return source.CopyToAsync(stream);
}
protected override bool TryComputeLength(out long length)
{
length = -1;
return false;
}
}
この時サーバーのログを見ると2回目の Duplex リクエストが到達していないようにも見えます。
Console.WriteLine("サーバーに接続を確立する");
の後の if
ブロックを実行しないようにすると Duplex リクエストが成功する可能性が大きくなります。
問題の原因
この問題は SocketsHttpHandler の内部で HTTP/2 のフレームがバッファリングされていることに由来しています。
SocketsHttpHandler ではリクエストを送るにあたり HEADERS フレームを送出しますが、それ以外にも SETTINGS フレーム、SETTINGS (ACK) フレームといったものもやり取りしています。SETTINGS (ACK) フレームというのはサーバーからの SETTINGS フレームに対する応答です。
フレームのバッファリングは一定バイト以上の書き込みがなされるか、フレームを書き込む処理で Flush が必要だというフラグを返すと Flush される仕組みとなっています。HEADERS フレームの書き込みでは限定条件でのみ Flush を要求しますので、通常は他のフレームの書き込みと一緒になることが多いです。
つまり、ほとんどのケースでは以下のどちらかのフローで HEADERS フレームが送信されます。
- サーバーからの SETTINGS フレームを受信すると SETTINGS (ACK) フレームを書き込むときに強制 Flush を要求し、一緒に HEADERS も送出
- リクエストボディーを DATA フレームとして書き込むとバッファ上限に達して Flush され HEADERS も送出
では、どういうときに問題になるのかというとサーバー側から SETTINGS フレームを早期に受信するというケースです。
早期に受信した場合は HEADERS フレームをバッファに書き込む前に SETTINGS (ACK) フレームをバッファに書き込んで Flush を実行します。結果として HEADERS フレームが Flush されずリクエストが開始されない状態になります。リクエストボディーとして送信するデータがある場合には続けて DATA フレームと共に送出されますが、それがない場合には何も起きず待機し続けます。
これは HTTP/2 のコネクション開始時 (SetupAsync) に preface + SETTINGS フレームを書き込み、フレームの入出力ループを開始していることと、ループ内で SETTINGS フレームの受信に対する処理があること、HEADERS の書き込みはループ開始後、というタイミングが絡み合って起こる問題です。
解決方法
この問題の解決方法はシンプルで HttpContent の SerializeToStreamAsync で FlushAsync を呼び出すだけです。
class DuplexContent(Stream source) : HttpContent
{
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
{
await stream.FlushAsync().ConfigureAwait(false);
await source.CopyToAsync(stream).ConfigureAwait(false);
}
protected override bool TryComputeLength(out long length)
{
length = -1;
return false;
}
}
Grpc.Net.Client の PushStreamContent を見ても FlushAsync を呼び出しています。
おわりに
この挙動はバグなのではと思ったのですが、どうもこの Grpc.Net.Client のコードのコメントからたどれる先のやり取りを見た感じそういう仕様な雰囲気があります。あまり納得できないですが…。
If you aren't going to immediately send request body data, but you do want the request to be sent to the server immediately, then you need to call FlushAsync on the request body to guarantee this.
完全に罠な挙動だったのでこのように備忘録としてしたためておきますが、まあ多分これに引っかかる人は HTTP/2 の双方向ストリームを直接使おうとしている人なので最初に書いた通り全 .NET プログラマーでも数人だと思います。
おまけ: GET や POST など単方向ストリームの時に問題にならないのか
Duplex 以外の時には問題にならないのかですが、GET の場合にはリクエストボディーがないのでHEADERS フレームの書き込み処理でリクエストボディーの Stream の最後なら Flush を強制するというフラグによって即座に HEADERS が送出されます。
単方向 POST の場合もアップロードが完了して初めてレスポンスヘッダーを受け取れるので結果的に問題になりにくいと思いますが、アップロードするデータがバッファのしきい値に満たない場合はリクエストの開始 (HEADERS の送出) が遅れるということは発生します。
Discussion