Web forwarding drops HTTP/2 response trailers, which breaks gRPC (HTTP/2 itself now negotiates fine)
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 45/100
- Issue type
- Feature
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- csharp
- Domain
- networking
Research direction
The issue identifies web forwarding as the failing path and provides a minimal Kestrel repro, but no repository files or tests. Start by locating the web-forwarding implementation and its HTTP/2 response handling, then look for tests covering response trailers. Done means the tunnel preserves grpc-status and grpc-message trailers for the repro; the scope and implementation path need project familiarity.
Written by the indexing model from the issue text.
Description
Hi, and apologies for reopening a settled topic. I know HTTP/2 support on *.devtunnels.ms has
been raised before, in #263, and again in #463 and #481 which were closed as duplicates of it. #263
was closed as not planned in February 2024 with the note "At this point, HTTP/2 on *.devtunnels.ms
is not planned", and I could not find a rationale recorded anywhere, so I am not sure which part of
it was the expensive part.
I am raising this again because I think the remaining gap is materially smaller than it was when
those issues were filed, and I want to put fresh measurements in front of you rather than just
re-asking.
What changed since #263
#263 reported that an HTTP/2 connection could not be established at all:
Requesting HTTP version 2.0 with version policy RequestVersionOrHigher while unable to establish
HTTP/2 connection.
That no longer reproduces. Today a tunnelled request negotiates HTTP/2 successfully. I tested with
HttpVersionPolicy.RequestVersionExact, which throws rather than silently downgrading, and the
request completed over HTTP/2 with a 200 response.
So HTTP/2 to the edge appears to work. The one remaining thing gRPC needs is that response
trailers are forwarded, and currently they are not.
Why that single gap breaks gRPC
A gRPC unary call returns its status in the grpc-status trailer whenever the response has a body.
When there is no body, the status can ride in the headers instead. So:
- Every gRPC error reply has an empty body, puts
grpc-statusin the headers, and travels
through a tunnel perfectly. - Every gRPC success reply has a body, puts
grpc-statusin the trailers, and loses its status.
The client then sees a 200 with a payload and no status, and reports:
Grpc.Core.RpcException: Status(StatusCode="Cancelled", Detail="No grpc-status found on response.")
This is an unpleasant failure mode to debug, because everything that goes wrong works, and only
the calls that succeed fail. I spent a while convinced the tunnel was healthy because every error
status I could provoke relayed correctly.
Minimal repro
A plain Kestrel HTTP/2 server that answers in the shape of a gRPC unary reply: a body, then
grpc-status as a trailer.
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o =>
{
o.ListenAnyIP(3979, l =>
{
l.Protocols = HttpProtocols.Http2;
l.UseHttps();
});
});
var app = builder.Build();
// Body, then grpc-status in the TRAILERS. This is what a successful gRPC call looks like.
app.MapPost("/trailer/{size:int}", async (HttpContext ctx, int size) =>
{
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/grpc";
ctx.Response.DeclareTrailer("grpc-status");
var frame = new byte[5 + size];
frame[0] = 0;
frame[1] = (byte)((size >> 24) & 0xFF);
frame[2] = (byte)((size >> 16) & 0xFF);
frame[3] = (byte)((size >> 8) & 0xFF);
frame[4] = (byte)(size & 0xFF);
for (var i = 0; i < size; i++) frame[5 + i] = (byte)(i % 251);
await ctx.Response.Body.WriteAsync(frame);
await ctx.Response.Body.FlushAsync();
ctx.Response.AppendTrailer("grpc-status", "0");
ctx.Response.AppendTrailer("grpc-message", "OK");
});
// Control: no body at all, so grpc-status can ride in the headers.
app.MapPost("/nobody", (HttpContext ctx) =>
{
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/grpc";
ctx.Response.Headers["grpc-status"] = "0";
return Task.CompletedTask;
});
app.Run();
Host port 3979 as https, then call it directly and through the tunnel with an HTTP/2 client that
reads TrailingHeaders.
Results
| Route | Path | Body bytes received | grpc-status in headers |
in trailers |
|---|---|---|---|---|
| direct | /nobody |
0 | 0 |
n/a |
| tunnel | /nobody |
0 | 0 |
n/a |
| direct | /trailer/100 |
105 | n/a | 0 |
| tunnel | /trailer/100 |
105 | n/a | missing |
| direct | /trailer/14319 |
14324 | n/a | 0 |
| tunnel | /trailer/14319 |
14324 | n/a | missing |
The body arrives intact in every case, including at 14 KB. Only the trailers are lost. I also
confirmed separately that request bodies up to 256 KB and request headers up to 9 KB relay fine, so
this does look specific to trailer propagation rather than to size or framing generally.
Our scenario, and why the documented workaround does not fit
We run a gRPC service locally so we can attach a debugger to it, while the service that calls it
runs in a managed cloud environment. We want the cloud caller to reach the locally hosted gRPC
service for a single test account, which is exactly the "debug your web applications from anywhere"
case dev tunnels is built for, except that the protocol is gRPC.
devtunnel connect TUNNELID plus localhost:PORT is suggested in #263, and it does work, because
the SDK forwards over SSH forwarded-tcpip channels and never parses HTTP. But it requires the dev
tunnel CLI to run on the calling machine, and in our case the caller is a container in a managed
environment we do not control. So that path is not available to us.
The alternatives we looked at:
- Third-party tunnelling providers that do support end to end HTTP/2. Our organization's policy does
not permit routing this traffic through an external provider, so these are not an option for us. - gRPC-Web, which moves the trailers into the body. That needs a protocol change on both ends, and
we only own one of them. - Hosting the service in a shared environment instead of locally. That works, but it gives up the
local debugger, which is the entire reason we want this.
That leaves us with no viable path, which is why I am asking rather than quietly working around it.
Suggestions
I appreciate I am on the outside of the implementation here, so please take these as questions
rather than proposals.
- Forward response trailers through web forwarding. If the edge already speaks HTTP/2 to the
client, propagating the trailer frame may be a contained change. If the internal hop to the host
is HTTP/1.1, theTrailerheader plus chunked trailing headers could carry them across. - An opt-in passthrough port mode. A port flag that makes the hosted URL forward the TLS stream
opaquely, with no HTTP inspection, would support gRPC and anything else HTTP/2 based. I realise
this likely conflicts with per-port rate limiting, the forwarding cookie, and abuse protection,
which all need request visibility, so it may only make sense for authenticated, non-anonymous
tunnels. - Failing both, document the limitation explicitly. A line in the docs saying gRPC and HTTP/2
trailers are not supported on*.devtunnels.ms, withdevtunnel connectnamed as the supported
path, would have saved me a day. The current behaviour looks like success, so there is nothing to
search for until you know to look at trailers specifically.
If trailer forwarding is feasible, it would unblock gRPC entirely for us, and I suspect for the
people on the three earlier issues too. We would really appreciate you reconsidering it.
Happy to test any preview build against our setup and report back.
- Dominant language
- C#
- Stars
- 517
- Forks
- 54
- Avg merge
- 3d 11h
- Merged PRs (30d)
- 2
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/dev-tunnels
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
microsoft/dev-tunnels#659 ·
-
devtunnel create routing flaps between near-equidistant clusters, silently creating cross-cluster duplicatesMay be free again @plequere-ms claimed this 130 days ago, and no pull request is open. Open
microsoft/dev-tunnels#642 · 2 comments · 1 assignee ·
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
microsoft/dev-tunnels#608 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
microsoft/dev-tunnels#567 · 1 comment ·
-
Difficulty 3/5 1-2 days Newbie friendliness 25/100
microsoft/dev-tunnels#557 · 1 reaction ·
All issues in microsoft/dev-tunnels
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 73/100
nightscout/nocturne#2135 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
Devolutions/UniGetUI#5541 ·
Maintainers usually reply within 1 day
-
agentic-workflows Needs: Triage :mag:
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
[誤判定]Openlinux アプリ: ブラウザー 再現済み 要トリアージ 誤判定
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
yksr-melt/Meltype#853 · 2 comments ·
Maintainers usually reply within 1 day