Skip to content

fix(server): answer cleartext h2c upgrade requests in HTTP/1.1 instead of dropping them - #16514

Merged
diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.52from
tiagolabsit:fix/h2c-upgrade-http1-fallback
Oct 16, 2026
Merged

diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.52from
tiagolabsit:fix/h2c-upgrade-http1-fallback

Conversation

@tiagolabsit

Copy link
Copy Markdown
Contributor

Summary

HTTP clients that prefer HTTP/2 over plain TCP open the connection with the cleartext upgrade handshake (RFC 9113 §3.2):

POST /v1/messages HTTP/1.1
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url>

The JDK java.net.http.HttpClient does this by default, so every JetBrains IDE / Air connection test ("Add an AI provider" → Anthropic API key → Test Connection) and any other JDK-based client pointed at OmniRoute failed with:

java.io.IOException: HTTP/1.1 header parser received no bytes

OmniRoute never answered: the socket was closed before a single response byte.

Root cause. As soon as an upgrade listener is registered on a Node http.Server, Node delivers every request that carries an Upgrade header to that listener instead of request. Both standalone-server-ws.mjs and Next register one; neither serves h2c, so the unclaimed request ended in socket.destroy().

Fix. RFC 9110 §7.8 explicitly lets a server ignore Upgrade and keep answering in HTTP/1.1, and the client then simply carries on in HTTP/1.1. A new sibling module, scripts/dev/h2c-upgrade-guard.cjs, does that: it removes the two negotiation headers, pushes the request bytes back into the socket and hands it to the server as a fresh connection, so the normal request pipeline (peer stamp, method/HEAD guards, Next) serves it. The guard is called first in the upgrade wrapper of standalone-server-ws.mjs (what omniroute serve, Docker and Electron run) and in run-next.mjs (npm start / dev).

Scope and safety (what is deliberately not touched)

  • Only Upgrade: h2c and nothing else. Every protocol token must be h2c (case-insensitive). websocket, h2c, websocket, websocket, h2c or any other protocol keep flowing to the existing handlers unchanged (/v1/responses, /v1/ws, /live-ws, Next HMR).
  • Never over TLS. RFC 9113 §3.1 forbids the h2c upgrade on https, so a TLS socket (socket.encrypted) is never rewritten and keeps today's behaviour.
  • No change for clients that already work. Requests without an Upgrade header never enter the upgrade path. Clients that were already getting an answer see no difference.
  • Request framing is preserved. The head is rebuilt from req.rawHeaders (original case, order, duplicates); only Upgrade, HTTP2-Settings and those two tokens inside Connection are removed (Connection: keep-alive, Upgrade, HTTP2-Settings → keep-alive; an otherwise empty Connection is dropped). Node has already validated the head, so re-serializing it cannot introduce a framing ambiguity. Bytes Node already read past the head (start of the body) are re-injected right after it, and bodies that arrive later flow through the new parser as usual. Keep-alive on the same socket keeps working.
  • No auth/routing surface change. The re-injected request goes through exactly the same request wrappers as any other HTTP/1.1 request.

Proof Of Fix (Hard Rule #18)

TDD. tests/unit/h2c-upgrade-guard.test.ts (13 tests, real sockets through a real http.Server with an upgrade listener, mirroring the production wiring).

Before the fix (module not present yet):

Error: Cannot find module '../../scripts/dev/h2c-upgrade-guard.cjs'
✖ tests/unit/h2c-upgrade-guard.test.ts
ℹ pass 0 / ℹ fail 1

After: ℹ pass 13 / ℹ fail 0. Coverage: sole-h2c detection (case-insensitive), WebSocket and mixed upgrades left alone, TLS never rewritten, POST with body, bodiless GET, body arriving after the headers, header stripping (Upgrade, HTTP2-Settings, Connection tokens), other Connection tokens kept, keep-alive reuse, WebSocket upgrade still reaching the existing handler, a baseline test documenting the original drop, and wiring checks for both servers.

Real client, before/after. The production wrapper (scripts/dev/standalone-server-ws.mjs as server-ws.mjs plus its sibling closure, with a stand-in server.js that registers an upgrade listener that closes unclaimed upgrades, like Next's) driven by the JDK 24 HttpClient:

before: FAIL IOException: HTTP/1.1 header parser received no bytes
after : OK  version=HTTP_1_1 status=200 body={"m":"POST","u":"/v1/messages","up":null,"body":"{\"probe\":1}"}

The handler sees the body intact and no Upgrade header.

Tests Added Or Updated

  • tests/unit/h2c-upgrade-guard.test.ts (new).
  • tests/unit/pack-artifact-policy.test.ts and tests/unit/standalone-server-ws-shutdown-owner.test.ts: register the new sibling in the expected lists, like head-response-guard.cjs.

Packaging

The new module is a sibling import of server-ws.mjs, so it is registered the same way as head-response-guard.cjs: assembleStandalone.mjs (EXTRA_MODULE_ENTRIES + header table) and pack-artifact-policy.ts (dist/h2c-upgrade-guard.cjs required entry and the bare allowed root path). check:pack-policy and the closure suites (pack-artifact-server-ws-closure, pack-artifact-entrypoint-closures, assemble-standalone) cover it.

Validation

  • New and related suites pass: h2c guard (13), head-request-closes-6400, standalone-server-ws-*, pack-artifact-policy, pack-artifact-server-ws-closure, assemble-standalone.
  • eslint clean on the touched files, prettier --check clean, check:pack-policy, check:test-discovery, check:test-masking pass.
  • Rebased on / merges cleanly with release/v3.8.52 @ e2d5747.

Reviewer Notes

  • Two things fail on release/v3.8.52 itself and are unrelated to this change: check:file-size (open-sse/services/combo/executeTargetAttempt.ts, 1288 vs frozen 1284) and the pack-artifact-entrypoint-closures test "every relative import of a shipped script is itself shipped" (scripts/build/build-next-isolated.mjs -> scripts/i18n/generate-global-error-messages.mjs). Both reproduce on the untouched branch.
  • The change is intentionally limited to h2c. If you prefer a more general "ignore any non-WebSocket Upgrade" policy, that is a one-line change in isCleartextH2cUpgrade, but I kept it minimal to avoid altering behaviour for protocols nobody asked about.

@tiagolabsit

Copy link
Copy Markdown
Contributor Author

Heads-up for review: the workflow runs on this PR (CI, Quality Gates, semgrep, API Route Typecheck) are sitting in action_required because this is a fork PR, so none of them have executed yet. Could a maintainer approve the runs?

Checked locally by merging this branch onto the current release/v3.8.52 (e2d5747): clean merge, the new/changed tests pass, eslint, prettier --check and typecheck:core are clean, and check:test-discovery / check:changelog-integrity pass. The PR description lists the exact commands and the before/after output.

Once the checks are green, the PR still needs the queue label for the Mergify release queue to pick it up (the Mergify check is currently "waiting for queue conditions").

Two things fail on release/v3.8.52 itself and are unrelated to this change, in case a red Quality Gates run shows up: check:file-size (open-sse/services/combo/executeTargetAttempt.ts, 1288 lines vs the frozen 1284) and check:cycles (14 SCCs on the untouched branch too).

@tiagolabsit
tiagolabsit force-pushed the fix/h2c-upgrade-http1-fallback branch from d2543b9 to 90ef280 Compare October 14, 2026 06:16
@tiagolabsit

Copy link
Copy Markdown
Contributor Author

Rebased onto current release/v3.8.52 (9ecddd1) and re-pushed. The red checks on the previous run were not caused by this diff:

  • check:docs-all / the .env.example sync test (MODEL_VARIANT) failed on the old base and is green after the rebase.
  • check:file-size still reports open-sse/services/combo/executeTargetAttempt.ts: 1288 > frozen 1284. This PR does not touch that file; it reproduces on the release tip.
  • The agent-skills generator sync check (Merge integrity) is also independent of this change.

Local proof on the rebased branch: the new/changed unit tests pass (41/41 for the h2c guard + pack policy, 56/56 for auth + client-api policy). Could a maintainer re-approve the workflow runs?

@tiagolabsit

Copy link
Copy Markdown
Contributor Author

Status: the remaining red checks here are the shared release-base reds (file-size on executeTargetAttempt.ts, drifted skills mirrors, ConnectionRow type), not this diff; they are being drained in #16420 / #16496 / #15682. I'll rebase again once those land. No overlap with other open PRs for the h2c upgrade drop (searched).

…d of dropping them

HTTP clients that prefer HTTP/2 over plain TCP (the JDK HttpClient used by the
JetBrains IDEs / Air "Add an AI provider" connection test) send
`Connection: Upgrade, HTTP2-Settings` + `Upgrade: h2c`. Once an `upgrade`
listener exists, Node routes every request carrying an Upgrade header to it
instead of `request`; nothing claims h2c, so the socket was closed without a
response ("HTTP/1.1 header parser received no bytes").

RFC 9110 section 7.8 lets a server ignore Upgrade and keep answering in
HTTP/1.1. The new h2c-upgrade-guard strips the two negotiation headers and
re-injects the request into Node's HTTP/1.1 parser, only when h2c is the sole
protocol offered and the socket is not TLS. WebSocket upgrades are untouched.
Wired into the standalone server wrapper and run-next.mjs, and shipped like the
other sibling modules (assembleStandalone, pack-artifact-policy).
@tiagolabsit
tiagolabsit force-pushed the fix/h2c-upgrade-http1-fallback branch from 90ef280 to 28cd478 Compare October 15, 2026 08:00
@tiagolabsit

Copy link
Copy Markdown
Contributor Author

Rebased onto the current release/v3.8.52 (88c1e33) and re-pushed, now that the earlier base-reds (#16420, #16496) have landed. On this branch check:docs-all and check:agent-skills-sync are green locally, and the new/changed tests pass. What check:file-size still flags (responseSanitizer.ts, error.ts and two test files over their frozen ceilings) comes from other merges on the release tip, not from this diff. Could a maintainer approve the workflow runs for this fork PR when convenient?

@diegosouzapw
diegosouzapw merged commit d29e000 into cheaper-inference:release/v3.8.52 Oct 16, 2026
46 of 60 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants