serve: fix HTTP/1.1 keep-alive desync and SSE framing (#597 item 3)#610
Merged
Merged
Conversation
β¦tem 3)
The reported `Bad request syntax ('{...json body...}POST /v1/chat/...')`
is a previous request's body being parsed as the next request line. Two
independent causes, both reproduced against dev:
1. Early rejections return before read_json(), so the body stays in the
socket and the next readline() eats it. _check_host() (403) and
require_auth() (401) both precede the body read -- which is why the
reporter hit this through Tailscale Serve, where every request was a
403 until the item 2 allowlist landed. A bad or over-MAX_BODY
Content-Length leaks the same way.
2. The streaming 200 declared no Content-Length and no Connection:
close, yet close_connection was set only after the final event. A
client is told to expect a reusable connection for a response whose
only boundary is the close. Worse, when generation raised after the
commit, that line never ran: do_POST then wrote a complete
`HTTP/1.1 500` response *into* the event stream, and left the
connection advertised as reusable.
Rather than teaching each early return to consume its body, drain once
at the request boundary in handle_one_request() -- it covers every
present and future early exit. Where the body can't be swallowed safely
(chunked, unparseable or over-limit length, short read) close instead:
an unreusable connection is correct, a desynchronised one is not.
Both SSE paths now send Connection: close and set close_connection when
the 200 is committed, not when the stream ends, so a mid-stream failure
can't leave a reusable socket. send_response() is overridden as the one
choke point recording that the status line is out, and _fail() uses it
to stay silent instead of splicing a second response into a committed
stream.
Plain JSON responses keep HTTP/1.1 persistence; that is covered by a
test so the fix can't quietly degrade into close-everything.
11 tests: 8 fail on dev, 3 are the no-regression guards. Full suite 197.
Refs JustVugg#597
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last open item of #597. @mkelcb reported
Bad request syntax ('{...json body...}POST /v1/chat/completions HTTP/1.1')β a previous request's body being parsed as the next request line β and suggested either dropping to HTTP/1.0 or doing HTTP/1.1 persistence properly with tests. This is the latter.I reproduced it against
devbefore writing anything. There are two independent causes, and the diagnosis explains why the symptom seemed to come and go.Cause 1 β early rejections leak the request body
do_POSTruns_check_host()(403) andrequire_auth()(401) beforeread_json(). Both raise, the response goes out with aContent-Length,close_connectionstays false, and the unread body is still sitting in the socket when the nextreadline()looks for a request line. Reproduced verbatim:This is why it showed up through Tailscale Serve: every request was a 403 until the item 2 allowlist landed in #606, so every request leaked a body. Fixing the allowlist removed the trigger, not the bug β hence "item 3 (retest)". A bad or over-
MAX_BODYContent-Lengthleaks the same way and still does today.Cause 2 β the streaming 200 is close-framed but says otherwise
start_streamsends noContent-Lengthand noConnection: close, so the only possible message boundary is the close β yetclose_connection = Truesat at the end of the stream (openai_server.py:1845, and:2110for Anthropic), outside anytry/finally. Two consequences:do_POSTthen callssend_json, writing a completeHTTP/1.1 500response inside the event stream, and leaves the socket advertised as reusable. My probe againstdev:#609's deferred commit narrowed the window before ACCEPT; everything after it was still exposed.
The fix
Rather than teaching each early return to remember its body, drain once at the request boundary in
handle_one_request()β one place, covering every early exit that exists now or later. Where the body can't be swallowed safely (chunked, unparseable or over-limit length, short read) it closes instead: an unreusable connection is correct, a desynchronised one is not, and swallowing an arbitrary declared length just to keep a socket warm is not a trade worth making.Both SSE paths now send
Connection: closeand setclose_connectionwhen the 200 is committed, not when the stream ends β so a mid-stream failure cannot leave a reusable socket.send_response()is overridden as the single choke point recording that the status line is out, and_fail()consults it to stay silent rather than splice a second response into a committed stream.Plain JSON keeps HTTP/1.1 persistence β explicitly tested, so this can't quietly degrade into close-everything (which is what the HTTP/1.0 workaround would have cost).
Tests
KeepAliveFramingTest, 11 cases covering the six points from the issue's regression list plus the paths above. 8 fail ondev, 3 are no-regression guards that pass either way:Content-Lengthclose rather than desyncconnection: closeand reach EOFraw.count("HTTP/1.") == 1)make check: 197 passed, 13 skipped.python3 -m pytest tests/test_openai_server.py: 94 passed.Verified on Linux only β the change is pure
http.serverbehaviour with no platform-specific paths.@mkelcb if you can retest through Tailscale Serve, the interesting case is now a deliberately wrong
--allowed-hostfollowed by a real request on the same connection: that used to poison the connection and should now just be a clean 403.