Skip to content

Fix ten hypercorn open issues across HTTP/1, HTTP/3, WSGI, sockets and the reloader - #50

Merged
davidbrochart merged 21 commits into
davidbrochart:mainfrom
graingert:claude/hypercorn-open-issues
Jul 23, 2026
Merged

Fix ten hypercorn open issues across HTTP/1, HTTP/3, WSGI, sockets and the reloader#50
davidbrochart merged 21 commits into
davidbrochart:mainfrom
graingert:claude/hypercorn-open-issues

Conversation

@graingert

Copy link
Copy Markdown
Contributor

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

  • #357 — Log each request once when the client closes as the response finalises. HTTPStream marked CLOSED only after awaiting the EndBody send, so a StreamClosed during that await double-logged; also an intermittent double access record in the dispatcher e2e test on macOS. Mark CLOSED before the first await.
  • #361 — Suppress unmapped OSError when closing an unreachable connection. _close()'s aclose() caught only SSLError + anyio errors, so EHOSTUNREACH/ENETUNREACH/ETIMEDOUT escaped and crashed the task or hit the app mid-send. Broadened both aclose() sites to OSError. (Also contains the SSL-error-on-close in #314 / #261.)
  • #352 (dup #296) — Stop sending on an HTTP/3 stream the peer has reset. On STOP_SENDING/RESET_STREAM the app kept calling send_data and tripped aioquic's "cannot call write() after reset()". Handle the reset events, skip sends to a reset stream, and catch the race-window assertion.
  • #331WSGIWrapper.run_app PEP 3333 compliance: empty iterables (HEAD/204/304) now start the response, b"" chunks don't flush headers early, and start_response enforces the exc_info / double-call rules.
  • #55 (with #315, #240) — DispatcherMiddleware tolerates mounts that decline lifespan. Declining by raising took the whole dispatcher down; not acking caused a LifespanTimeoutError. Each mount runs through _run_mount, which completes its startup/shutdown on its behalf (a genuine post-startup error still propagates).
  • #127 — Report the peer's websocket close code to the app instead of defaulting a clean client close to 1006.
  • #157 — Log HTTP/1 requests h11 rejects mid-parse. The status was already correct (431 via error_status_hint); the rejection was just silent. Now emits an info log (client, error, status).
  • #269 — Report a non-zero exit status when a --reload fails 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), and main() returned the exit code where click, invoking the command in standalone mode, discards it and exits 0. Only reap again when nothing has failed, and sys.exit() the code explicitly. Click still raises its own SystemExit(2) for usage errors, so those codes are unaffected. Added an end-to-end regression test that reloads onto a SyntaxError and asserts a non-zero exit.
  • #84 — Pass keyfile_password when loading an encrypted HTTP/3 certificate. QuicProtocol built its aioquic config with load_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).
  • #171 — Reserve the listen port exclusively on Windows. _create_sockets set SO_REUSEADDR on 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. Set SO_EXCLUSIVEADDRUSE on Windows (the documented way to claim the port) and keep SO_REUSEADDR on 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)

  • #225 — websocket-upgrade trailing-data worker crash; already guarded, added a driven h11-upgrade integration test (removing the guard reproduces the exact crash).
  • #226 — h2 END_STREAM: EndBody unblocks before draining, so _send_data reaches the complete branch.
  • #125 — an uncaught app error on a connected websocket closes with 1011, not 1000.
  • #258 — the dispatcher shutdown CancelledError is absorbed by anycorn's lifespan cancel-scope (and the Initialise HTTPStream.app_put #55 fix makes mounts finish cleanly).
  • #294 — anycorn's redirect middleware is pure ASGI with no event-loop access, so the "no running event loop" crash cannot occur.

Additional correctness fixes (found while auditing, not tied to a numbered issue)

  • WSGI SERVER_PORT_build_environ passed the port as a raw int, but PEP 3333 requires every CGI-style environ value to be a native string. A WSGI app treating SERVER_PORT as text would TypeError. Now stringified, matching SERVER_NAME and the rest of the environ.
  • Lifespan shutdown-timeout messagewait_for_shutdown raised LifespanTimeoutError("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

h2 4.3.0 treats it as a connection error (GOAWAY, connection CLOSED); no per-stream 400 is possible afterward without disabling h2's inbound validation. anycorn already forwards the correct GOAWAY; the fix belongs upstream in h2.

Also in this PR

Reworked the h3 protocol tests to construct H3Protocol through its real __init__ (a _make_protocol helper) rather than __new__. __new__ is now gone from the codebase entirely.

Test plan

  • ruff check / ruff format --check / ty check clean across changed files
  • Full test suite passes (397 passed, 2 skipped)
  • Each fix's test verified to fail without the change and pass with it restored
  • Both asyncio and trio backends exercised

Full 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.

count status
10 ✅ Fixed in this PR
3 ✅ Fixed in this PR (duplicate)
11 ✅ Already in anycorn
1 ⏭️ Skipped — upstream
3 ⏭️ Upstream (h11/h2/aioquic)
38 🔍 Bug candidate (follow-up)
29 🧩 Feature request
24 ❓ Question / support / docs
3 ⚙️ hypercorn-internal / N-A
Issue Title Status Note
#361 Plain OSError (EHOSTUNREACH) escapes TCPServer._close() on abrupt c… ✅ Fixed in this PR OSError on abrupt disconnect
#359 Change access log atom %({...}e)s to read ASGI scope 🧩 Feature request access-log atom semantics change
#357 Duplicate access logs ✅ Fixed in this PR duplicate access logs
#354 Crashes in Python 3.14 🔍 Bug candidate (follow-up) Python 3.14 support
#353 serve() parameter type annotations gets in the way 🧩 Feature request serve() type-annotation ergonomics
#352 AssertionError: cannot call write() after reset() ✅ Fixed in this PR write() after reset() (HTTP/3)
#351 Python 3.14 compatibility - TaskGroup, wait_for, and timeout requir… ⚙️ hypercorn-internal / N-A hypercorn asyncio wait_for/timeout; anycorn is anyio
#349 Send data in a thread ❓ Question / support / docs
#348 Header values with leading/trailing whitespaces in HTTP/2 mode caus… ⏭️ Skipped — upstream h2 forces a connection GOAWAY; no per-stream 400
#347 Add support for Abstract Unix Sockets 🧩 Feature request abstract unix sockets
#346 Add ASGI pathsend extension support 🧩 Feature request ASGI pathsend extension
#345 Why force set_inheritable()? ❓ Question / support / docs
#344 Un-graceful termination on Windows 🔍 Bug candidate (follow-up) Windows shutdown; PR #2 added the asyncio signal fallback
#340 Allow stdout to be directed to one of the log files 🧩 Feature request
#339 Singular hypercorn before/after hook 🧩 Feature request before/after hooks
#338 RuntimeWarning: coroutine 'TCPServer._idle_timeout' was never awaited 🔍 Bug candidate (follow-up) idle-timeout coroutine warning; anycorn uses SingleTask
#336 Running on https://{ip}:{port}/ message contains a URL that will fa… ❓ Question / support / docs cosmetic 'Running on' URL
#335 HTTP/2 Server terminates all streams once exceeded 🔍 Bug candidate (follow-up) h2 max-concurrent-streams handling
#333 Daemon processes ✅ Already in anycorn Config.daemon / --daemon added in PR #2
#331 WSGIWrapper violates WSGI protocol ✅ Fixed in this PR WSGI PEP 3333 response rules
#330 Connection:close ignored? 🔍 Bug candidate (follow-up) Connection: close handling
#329 TCPServer crash 🔍 Bug candidate (follow-up) vague crash report
#327 Code 1006 during web socket termination on Google Chrome when outgo… 🔍 Bug candidate (follow-up) websocket close with queued data
#325 Documentation mentions non-existent TLS version ❓ Question / support / docs docs
#324 get certificate from client peer 🧩 Feature request peer client certificate in scope
#322 App timeout ❓ Question / support / docs
#315 DispatcherMiddleware throws LifespanTimeoutError ✅ Fixed in this PR (duplicate) resolved by the #55 fix
#314 Incomplete cleanup when closing response before consuming request w… 🔍 Bug candidate (follow-up) SSL-error-on-close is contained by the #361 fix; request-drain is a larger follow-up
#313 How to drop a connection without sending a response ❓ Question / support / docs
#311 Loggers cannot be pickled but custom loggers are only supported thr… 🔍 Bug candidate (follow-up) picklable custom loggers
#308 graceful_timeout not respected on Python 3.12 🔍 Bug candidate (follow-up) graceful_timeout not respected
#307 Enabling the ws-per-message-deflate setting like in uvicorn. ✅ Already in anycorn websocket_permessage_deflate is configurable
#306 Allow passing custom SSLContext 🧩 Feature request custom SSLContext
#301 Accept header corrupted with commit d1c1a23 🔍 Bug candidate (follow-up) Accept header corruption (hypercorn-specific commit)
#300 `sys:1: RuntimeWarning: coroutine 'QuicProtocol._handle_timer' was … 🔍 Bug candidate (follow-up) QUIC timer coroutine warning; anycorn uses SingleTask
#298 Hypercorn Does Not Load Environment Variables From Session ❓ Question / support / docs
#297 Debugging doesn't work with serve(). ❓ Question / support / docs
#296 ERROR Error in ASGI Framework `AssertionError: cannot call wri… ✅ Fixed in this PR (duplicate) same as #352
#295 `QUIC: recvmsg() unexpectedly returned -1 (errno=90; Message too lo… 🔍 Bug candidate (follow-up) QUIC datagram size / recvmsg
#294 HTTPToHTTPSRedirectMiddleware crashes with `RuntimeError: no runn… ✅ Already in anycorn anycorn's redirect middleware has no event-loop access
#284 Unbounded Content-Length Parsing Issue 🔍 Bug candidate (follow-up) Content-Length bounds (mostly h11)
#280 Duplicate Content-Type Parsing Error ⏭️ Upstream (h11/h2/aioquic) duplicate Content-Type (h11)
#279 Incorrect Parsing When Both Content-Length and Transfer-Encoding Ar… ⏭️ Upstream (h11/h2/aioquic) CL+TE smuggling (h11)
#278 HTTP Request Method Parsing Error ⏭️ Upstream (h11/h2/aioquic) method parsing (h11)
#277 Misuse of the CONNECT Method 🔍 Bug candidate (follow-up) CONNECT without :path is rejected by h2 before anycorn sees it — not reachable
#273 Feature Request: Limit HTTP methods 🧩 Feature request limit HTTP methods
#269 hypercorn autoreload return exit status 0 when reloading encounters… ✅ Fixed in this PR reloader exit status on a failed reload
#266 ConnectionResetError during page load 🔍 Bug candidate (follow-up) ConnectionResetError
#265 RuntimeError on page refresh 🔍 Bug candidate (follow-up) RuntimeError on refresh
#264 ssl error ❓ Question / support / docs
#263 Count of pending requests 🧩 Feature request pending-request count
#261 SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY 🔍 Bug candidate (follow-up) SSL data-after-close-notify
#260 Can we have a default config filename? 🧩 Feature request default config filename
#258 DispatcherMiddleware causes CancelledError on shudown ✅ Already in anycorn absorbed by the lifespan cancel-scope; with the #55 fix mounts finish cleanly
#254 HTTP2 Trailers not being sent ✅ Already in anycorn H2 trailers fixed in PR #2
#250 Documentation needs an http2 example ❓ Question / support / docs docs
#249 feat: configure hypercorn with environment variables 🧩 Feature request env-var configuration
#247 Type hint for check_multiprocess_shutdown_event is confusing for mypy 🧩 Feature request typing of check_multiprocess_shutdown_event
#245 Hypercorn processes daemon property fails multiprocessing.Process /… ✅ Already in anycorn daemon=False now possible (Config.daemon, PR #2)
#244 error hypercorn --config python:hypercorn_config.config ❓ Question / support / docs
#243 Opentelemetry Tracing + Logging Issue ❓ Question / support / docs opentelemetry
#242 ProxyFixMiddleware: Turns out GCP LBs and possibly AWS LBs only hav… 🔍 Bug candidate (follow-up) ProxyFix x-forwarded-proto; anycorn has proxy_fix
#240 Cannot start WSGI application using Dispatcher middleware to suppor… ✅ Fixed in this PR (duplicate) resolved by the #55 fix
#238 Unexpected "shutdown without Lifespan support" error on Hypercorn 0… 🔍 Bug candidate (follow-up) lifespan support error; PR #2 touched lifespan
#235 TCP server keep-alive times out even though data is being received. 🔍 Bug candidate (follow-up) keep-alive timeout despite traffic
#234 pytest-cov / pytest-sugar not included in pyproject.toml ⚙️ hypercorn-internal / N-A hypercorn dev deps
#231 Getting permission Error [WinError 10013] An attempt was made to ac… ❓ Question / support / docs Windows/IIS environment
#229 Can the ASGI application be specified in the config file? 🧩 Feature request app path in config
#226 Occassionally with HTTP2, server does not send "End Stream" flag as… ✅ Already in anycorn END_STREAM emitted via the EndBody unblock/drain/complete path
#225 Entire hypercorn server crashes when receiving trailing data on a w… ✅ Already in anycorn already guarded; full-path regression test added here
#221 FastAPI deployed with hypercorn in GCP Cloud Run returning 503 spor… ❓ Question / support / docs
#219 Performance issue ❓ Question / support / docs performance
#215 Is Hypercorn pre-fork or post-fork? How can I integrate it with ope… ❓ Question / support / docs
#214 Need clear documentation for configuration ❓ Question / support / docs docs
#210 disconnect detection broken when running with trio 🔍 Bug candidate (follow-up) trio disconnect detection; anycorn is anyio
#202 SSL shutdown timed out 🔍 Bug candidate (follow-up) SSL shutdown timeout
#200 use_reloader not working as intended 🔍 Bug candidate (follow-up) use_reloader
#198 Websocket endpoint and ProxyFixMiddleware 🔍 Bug candidate (follow-up) ws + ProxyFix
#196 Handling log file rotation 🧩 Feature request log rotation
#195 Analog to uvicorn/gunicorn --forwarded-allow-ips 🧩 Feature request forwarded-allow-ips
#192 How could I ignore all the hypercorn output? ❓ Question / support / docs
#191 question: running ProcessPoolExecutor inside web-app served by hype… ❓ Question / support / docs ProcessPoolExecutor (see #245)
#189 Error : unable to perform operation on ; the handler … 🔍 Bug candidate (follow-up) closed-transport error
#184 InvalidStateError during termination when running hypercorn progr… 🔍 Bug candidate (follow-up) InvalidStateError via anyio; relevant to anycorn
#180 Graceful shutdown not possible on Windows 🔍 Bug candidate (follow-up) Windows graceful shutdown; PR #2 signal work relates
#176 Improve WSGI behavior for large requests. 🔍 Bug candidate (follow-up) WSGI large requests; PR #2 touched WSGI
#174 Support for PROXY-Protocol 🧩 Feature request PROXY protocol
#171 Running a second server on the same port doesn't fail on Windows ✅ Fixed in this PR SO_EXCLUSIVEADDRUSE on Windows (verified via unit test, not Windows CI)
#170 Socket remains ESTABLISHED after keep_alive_timeout 🔍 Bug candidate (follow-up) socket stays ESTABLISHED after keep-alive
#169 Implement HTTP CONNECT with Hypercorn 🧩 Feature request HTTP CONNECT
#168 Raise h11 and h2 exceptions in debug mode 🧩 Feature request raise h11/h2 exceptions in debug
#167 keep-alive connection re-use is still slowing down HTTP clients 🔍 Bug candidate (follow-up) keep-alive reuse latency
#160 Binding to both IPv4 and IPv6 for a given host on an available port 🧩 Feature request bind IPv4+IPv6 on one port
#157 h11 protocol surfaces 431 (request headers too large) as 400 withou… ✅ Fixed in this PR log HTTP/1 requests h11 rejects
#141 Changelogs are not visible in git tags ⚙️ hypercorn-internal / N-A hypercorn repo/tags meta
#137 application_path is ignored 🔍 Bug candidate (follow-up) application_path ignored
#135 dispatcher.py / DispatcherMiddleware review 🔍 Bug candidate (follow-up) dispatcher review
#127 Hypercorn server websocket consider close code as 1006 upon client … ✅ Fixed in this PR peer close code reported to the app
#125 Websocket connection with 1000 (CLOSE_NORMAL) upon uncaught excepti… ✅ Already in anycorn app error closes a connected ws with 1011 via app_send(None)
#120 hypercorn overrides application logging handlers 🔍 Bug candidate (follow-up) uses named loggers, not root; app handlers not touched
#119 Very high memory usage? ❓ Question / support / docs memory usage
#114 AssertionError: first packet must be INITIAL when using multiple wo… 🔍 Bug candidate (follow-up) QUIC multi-worker INITIAL assertion
#106 Hypercorn skip socket shutdown when cancelled with trio 🔍 Bug candidate (follow-up) skip socket shutdown when cancelled (trio); anyio-relevant
#99 Support reloading non-python files 🧩 Feature request reload non-python files
#96 Readyness callback in serve() function. 🧩 Feature request readiness callback in serve()
#93 Is it possible to customize the error log? ❓ Question / support / docs
#92 Access log prints only part of request path when using FastAPI subapps ✅ Already in anycorn subapp access-log path fixed via dispatcher scope copy (PR #2)
#91 Error deploying ver 0.14.3 on DigitalOcean Apps Platform ❓ Question / support / docs deployment
#89 Issue when sending large payload (HTTP CODE 400 or 104) 🔍 Bug candidate (follow-up) large payload 400/104
#85 Specifying both IPv4 and IPv6 binds 🧩 Feature request IPv4+IPv6 binds (see #160)
#84 HTTP/3 [ Password was not given but private key is encrypted ] ✅ Fixed in this PR forward keyfile_password to the HTTP/3 cert loader
#81 Limit the number of currently open sockets 🧩 Feature request limit open sockets
#78 Config typing as a Protocol 🧩 Feature request Config as Protocol
#74 Check ContextVar set in lifespan is available in requests for async… ❓ Question / support / docs ContextVar in lifespan
#73 Support SNI 🧩 Feature request SNI
#72 Add a dev cert option 🧩 Feature request dev cert option
#71 More process options 🧩 Feature request more process options
#70 Possible to disable lifespan in config? 🧩 Feature request disable lifespan in config
#62 Implement the TLS extension ✅ Already in anycorn TLS extension implemented (build_tls_extension)
#55 Fix dispatcher middleware for apps that don't use lifespan ✅ Fixed in this PR dispatcher tolerates mounts without lifespan
#50 Task was destroyed but it is pending! 🔍 Bug candidate (follow-up) 'Task was destroyed but pending'
#40 How to integrate hypercorn's loggers into existing logging setup ❓ Question / support / docs logging integration

🤖 Generated with Claude Code

https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah

claude added 20 commits July 23, 2026 09:58
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
Comment thread src/anycorn/protocol/h11.py Outdated
Comment thread src/anycorn/protocol/h3.py Outdated
Comment thread src/anycorn/protocol/h3.py Outdated
Comment thread src/anycorn/protocol/http_stream.py Outdated
Comment thread src/anycorn/protocol/quic.py Outdated
Comment thread tests/test_app_wrappers.py Outdated
Comment thread tests/test_config.py Outdated
Comment thread tests/test_config.py Outdated
Comment thread tests/test_config.py Outdated
Comment thread tests/test_tcp_server.py Outdated
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 davidbrochart left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@davidbrochart
davidbrochart merged commit 2975b62 into davidbrochart:main Jul 23, 2026
19 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.

3 participants