Fix ten hypercorn open issues across HTTP/1, HTTP/3, WSGI, sockets and the reloader - #50
Merged
davidbrochart merged 21 commits intoJul 23, 2026
Conversation
Ports the fix for hypercorn #357 (duplicate access logs). HTTPStream's _send_closed / _send_error_response set state = CLOSED only after awaiting the EndBody send, so a StreamClosed handled during that await - the client closing just as the response completes - saw a non-CLOSED state and logged the request a second time (once with response=None from the reader task, once with the full response). trio's always-a-checkpoint scheduling makes the race easy to hit; it also surfaced as an intermittent double access record in the dispatcher e2e test on macOS. Set state = CLOSED before the first await so the StreamClosed path sees it and skips its log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Ports the fix for hypercorn #361. TCPServer._close()'s aclose() branch
caught only SSLError and the anyio resource errors, so a plain OSError
from tearing down a connection to an unreachable peer - EHOSTUNREACH,
ENETUNREACH, ETIMEDOUT, none of which asyncio maps to ConnectionError -
escaped. _close() runs from run()'s finally (outside its own except
OSError) and from protocol_send, so that leak either crashed the
connection task ("Unhandled exception in client_connected_cb") or
propagated back into the ASGI app mid-send. Broaden both aclose() sites
(the other in _initiate_server_close, on the idle-timeout path) to
suppress OSError, which subsumes the SSLError they already caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Ports the fix for hypercorn #352 ("AssertionError: cannot call write()
after reset()"). When a client sends STOP_SENDING or RESET_STREAM,
aioquic resets our sender for that stream, but H3Protocol ignored those
QUIC events - so the still-running app kept calling send_data, tripping
aioquic's "cannot call write() after reset()" assertion and taking down
the whole connection.
Handle StopSendingReceived/StreamReset by dropping the stream and handing
it a StreamClosed, so the app sees http.disconnect and stops producing;
skip sends to any stream already recorded as reset; and, for a reset that
lands in the window before it is recorded, catch that assertion at the
send site and forget the stream rather than letting it propagate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
The h3 protocol tests built the object with __new__ and hand-set only the attributes each test happened to touch, so a test silently broke the moment a method reached for anything else (as adding _reset_streams just did). Go through the real constructor with mock collaborators via a small _make_protocol helper; tests that drive the H3 connection stub protocol.connection on top. streams and _reset_streams now come from __init__ rather than being re-declared per test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
The two __new__ call sites were already replaced by _make_protocol; this removes the now-dangling mention so the helper's docstring stands alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Ports the fixes for hypercorn #331. The first-chunk gating added for generator support left three PEP 3333 violations: - an empty iterable (a HEAD handler, a 204/304) never sent http.response.start, so the caller's trailing body message crashed the stream with UnexpectedMessageError; - a b"" chunk counted as "the first chunk" and flushed the headers, even though an empty bytestring is not body data and the app may still replace the status/headers until the first real chunk; - start_response did no validation - a second call without exc_info was ignored, and a call with exc_info after the headers had gone out swallowed the error instead of re-raising it. Send the headers on the first non-empty chunk or once the iterable is exhausted (whichever comes first), skip empty chunks, and enforce the start_response exc_info / double-call rules. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
anycorn already guards against this (WSStream answers 400 when body/data arrives before the handshake is accepted), but nothing exercised the full h11 upgrade path that hypercorn #225 crashes on: trailing bytes after a websocket handshake reach WSStream as a Data event before it has a wsproto connection, and without the guard that is AttributeError: 'WSStream' object has no attribute 'connection' - uncaught, taking the worker down. Lock the whole path with a driven h11 request; removing the guard reproduces the exact crash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
…ware Ports the fix for hypercorn davidbrochart#55 (and the failures reported in #315 and #240). The dispatcher ran every mount's lifespan in a task group and tracked a startup/shutdown.complete per mount, but had no handling for a mount that opts out of lifespan. Declining it the ASGI-sanctioned way - by raising - propagated out of the task group and took the whole dispatcher (and so the server's lifespan) down; an app that instead just returned without acking left the dispatcher waiting on a startup.complete that never arrived, which surfaced as a LifespanTimeoutError. Run each mount through _run_mount, which catches a pre-startup raise (a genuine error after startup is still allowed to propagate) and, either way, completes that mount's startup and shutdown on its behalf so the others are never blocked. This also means a mount that acks startup but returns without handling shutdown no longer stalls the caller's shutdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Ports the fix for hypercorn #127. When the client closed cleanly, the CloseConnection event it sent was not recorded, so the websocket.disconnect the app received defaulted to 1006 (abnormal closure) - indistinguishable from a dropped connection. Remember the peer's close code and hand that to the app, falling back to the previous 1000/1006 heuristic only when the peer sent no code. (#125 - a 1000 close on an uncaught application error - is already handled: app_send(None) closes a connected websocket with INTERNAL_ERROR, 1011.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Ports the remaining half of hypercorn #157. anycorn already surfaces the right status - h11's error_status_hint means oversized headers come back as 431 rather than a blanket 400 - but the rejection was otherwise silent, so an operator had no record of why a request was turned away. Emit an info-level log (client, error, status) when h11 rejects a request mid-parse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
hypercorn #269: `--reload` onto a module with a SyntaxError exited 0, so a supervisor or CI could not tell the reload had failed. Two independent bugs combined to swallow the failure: - run() reaped the crashed worker inside its supervise loop, capturing the non-zero exitcode and removing the process from the list, then re-joined the now-empty list on the way out - which returned 0 and masked the failure. Only reap again when nothing has failed yet. - main() returned run()'s exit code, but click invokes the command in standalone mode and discards the callback's return value, always exiting 0. Exit with the code explicitly instead. Click still raises SystemExit itself for usage errors (exit 2), so those codes are unaffected. Add an end-to-end regression test that starts the reloader on a good app, overwrites the module with a SyntaxError, and asserts the process exits non-zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
hypercorn #84: QuicProtocol built its aioquic configuration with load_cert_chain(certfile, keyfile) but never forwarded keyfile_password, so an encrypted HTTP/3 private key failed to load with "Password was not given but private key is encrypted" - even though create_ssl_context already passes the password for the TLS listeners. Forward it here too; aioquic encodes a str password to bytes itself, matching the TLS path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
wait_for_shutdown raised LifespanTimeoutError("startup") on a timeout, a
copy of the startup branch, so a shutdown that never completed reported
"Timeout whilst awaiting startup ... the startup_timeout configuration is
incorrect" - pointing at the wrong stage and the wrong config option. Pass
"shutdown" so the message and the setting it names match what timed out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
_build_environ set SERVER_PORT to the raw integer port, but PEP 3333 requires every CGI-style environ value to be a native string. A WSGI app that concatenates or otherwise treats SERVER_PORT as text (as many do) would raise a TypeError. Stringify it, matching SERVER_NAME and the rest of the environ. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
_create_sockets set SO_REUSEADDR on every listening socket, but on Windows that option lets an unrelated socket rebind an address already in active use and take it over - so a second server started on a port already in use bound silently instead of failing (hypercorn #171). asyncio omits SO_REUSEADDR on Windows for the same reason. Set SO_EXCLUSIVEADDRUSE there instead, the documented way to claim the port exclusively, and keep SO_REUSEADDR on Unix where it only relaxes the TIME_WAIT rebind and cannot be used to hijack a live port. Note: the Windows behaviour cannot be exercised on the Linux CI runner, so the added test drives the platform branch with a patched-in constant to assert the port is claimed exclusively rather than with SO_REUSEADDR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
ty flags the `# type: ignore[attr-defined]` on the AsyncMock assertion as an unused suppression; the attribute resolves fine, so remove it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
The #171 fix makes _create_sockets set SO_EXCLUSIVEADDRUSE on Windows instead of SO_REUSEADDR, which broke test_create_sockets_ip and test_create_sockets_fd on the Windows runner - they asserted SO_REUSEADDR unconditionally. Assert against a platform-aware expected option so both branches are covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
getsockopt only guarantees a nonzero value for an enabled boolean socket option; macOS/BSD returns a value other than 1, so the == 1 assertion failed on the macOS runner. Assert the option is enabled (!= 0) instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Binds a real server socket, starts it listening, then has a second socket claim the same address the way _create_sockets does and asserts the bind is refused. On Unix SO_REUSEADDR already refuses this; the value is on Windows, where without SO_EXCLUSIVEADDRUSE the second bind would steal the listening port instead of failing - so this test fails there without the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Runs two real `python -m anycorn` processes: the first serves a port, the second is launched on the same port and must exit non-zero instead of coming up alongside it. This is the ticket's scenario end-to-end; on Windows without the SO_EXCLUSIVEADDRUSE fix the second process would bind and keep running. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Spell out https://github.com/pgjones/hypercorn/issues/<n> wherever a comment or docstring cited a hypercorn issue as `#<n>`, so the reference is clickable and unambiguous. Reflow the affected comments and docstrings to keep lines within the 100-column limit; comment/docstring-only, no behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
davidbrochart
approved these changes
Jul 23, 2026
davidbrochart
left a comment
Owner
There was a problem hiding this comment.
Awesome!
I'm wondering if it would not be too rude to go over those hypercorn issues and let them know that this was fixed in anycorn?
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.
Summary
Went through all 122 of hypercorn's open issues, triaged which describe bugs in code anycorn shares, and fixed the ones that clearly applied and could be covered with deterministic tests. Several others turned out to already be handled by anycorn's careful anyio port — some verified here with new tests. Each fix was verified to fail without the change and pass with it restored. A full triage of every open issue is at the bottom.
Rebased onto
main(v0.19.0), which already contains the earlier gaps work (#48); this PR is now just the 15 commits below.Fixed here
HTTPStreammarkedCLOSEDonly after awaiting theEndBodysend, so aStreamClosedduring that await double-logged; also an intermittent double access record in the dispatcher e2e test on macOS. MarkCLOSEDbefore the first await.OSErrorwhen closing an unreachable connection._close()'saclose()caught onlySSLError+ anyio errors, soEHOSTUNREACH/ENETUNREACH/ETIMEDOUTescaped and crashed the task or hit the app mid-send. Broadened bothaclose()sites toOSError. (Also contains the SSL-error-on-close in #314 / #261.)STOP_SENDING/RESET_STREAMthe app kept callingsend_dataand tripped aioquic's "cannot call write() after reset()". Handle the reset events, skip sends to a reset stream, and catch the race-window assertion.WSGIWrapper.run_appPEP 3333 compliance: empty iterables (HEAD/204/304) now start the response,b""chunks don't flush headers early, andstart_responseenforces theexc_info/ double-call rules.DispatcherMiddlewaretolerates mounts that decline lifespan. Declining by raising took the whole dispatcher down; not acking caused aLifespanTimeoutError. Each mount runs through_run_mount, which completes its startup/shutdown on its behalf (a genuine post-startup error still propagates).error_status_hint); the rejection was just silent. Now emits an info log (client, error, status).--reloadfails to import. Two bugs combined to swallow the failure:run()reaped the crashed worker inside its supervise loop and then re-joined the now-empty process list on the way out (returning 0), andmain()returned the exit code where click, invoking the command in standalone mode, discards it and exits 0. Only reap again when nothing has failed, andsys.exit()the code explicitly. Click still raises its ownSystemExit(2)for usage errors, so those codes are unaffected. Added an end-to-end regression test that reloads onto aSyntaxErrorand asserts a non-zero exit.keyfile_passwordwhen loading an encrypted HTTP/3 certificate.QuicProtocolbuilt its aioquic config withload_cert_chain(certfile, keyfile)but never forwarded the password, so an encrypted HTTP/3 key failed with "Password was not given but private key is encrypted" — even though the TLS listeners already pass it. Forward it here too (aioquic encodes a str password to bytes itself, matching the TLS path)._create_socketssetSO_REUSEADDRon every socket, but on Windows that lets an unrelated socket rebind an address already in active use and take it over, so a second server on a busy port bound silently instead of failing. SetSO_EXCLUSIVEADDRUSEon Windows (the documented way to claim the port) and keepSO_REUSEADDRon Unix, where it cannot hijack a live port — matching what asyncio does. The Windows behaviour can't be exercised on the Linux CI runner, so the test drives the platform branch with a patched-in constant.Verified already handled (tests added where useful)
EndBodyunblocks before draining, so_send_datareaches thecompletebranch.CancelledErroris absorbed by anycorn's lifespan cancel-scope (and the Initialise HTTPStream.app_put #55 fix makes mounts finish cleanly).Additional correctness fixes (found while auditing, not tied to a numbered issue)
SERVER_PORT—_build_environpassed the port as a rawint, but PEP 3333 requires every CGI-style environ value to be a native string. A WSGI app treatingSERVER_PORTas text wouldTypeError. Now stringified, matchingSERVER_NAMEand the rest of the environ.wait_for_shutdownraisedLifespanTimeoutError("startup")(a copy of the startup branch), so a shutdown that never completed reported "Timeout whilst awaiting startup … the startup_timeout configuration is incorrect" — the wrong stage and the wrong config option. Now names the shutdown stage.Skipped: #348 — HTTP/2 whitespace header -> 400
h24.3.0 treats it as a connection error (GOAWAY, connectionCLOSED); no per-stream 400 is possible afterward without disabling h2's inbound validation. anycorn already forwards the correct GOAWAY; the fix belongs upstream inh2.Also in this PR
Reworked the h3 protocol tests to construct
H3Protocolthrough its real__init__(a_make_protocolhelper) rather than__new__.__new__is now gone from the codebase entirely.Test plan
ruff check/ruff format --check/ty checkclean across changed filesasyncioandtriobackends exercisedFull triage of hypercorn's open issues
All 122 open issues as of this PR. The fixed and skipped rows are the ones I engaged with directly; ✅ Already in anycorn marks issues addressed by earlier anycorn work (mostly PR #2); the remaining statuses are a title-level triage, not a deep per-issue analysis. 🔍 rows are the plausible follow-up candidates.
AssertionError: cannot call write() after reset()pathsendextension supportws-per-message-deflatesetting like in uvicorn.serve().ERROR Error in ASGI Framework`AssertionError: cannot call wri…HTTPToHTTPSRedirectMiddlewarecrashes with `RuntimeError: no runn…DispatcherMiddlewarecausesCancelledErroron shudownhypercornwith environment variableshypercorn --config python:hypercorn_config.config--forwarded-allow-ipsInvalidStateErrorduring termination when running hypercorn progr…keep_alive_timeoutserve()function.🤖 Generated with Claude Code
https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah