Claude/anycorn hypercorn gaps - #48
Merged
davidbrochart merged 35 commits intoJul 22, 2026
Merged
Conversation
- Add Config.daemon / -D, --daemon so multiprocess workers can be run non-daemonic, propagated through to Process.daemon in run.py. - Register a SIGHUP handler that gracefully restarts workers, matching the reload path already used for --reload file-change events. - H2Protocol.stream_send: send trailers with end_stream=True after unblocking priority and draining the stream buffer, instead of leaving the stream open. - H3Protocol.stream_send: pop the stream from self.streams on StreamClosed instead of silently doing nothing. - WSGIWrapper.run_app: gate the http.response.start send behind the first produced chunk. Sending it eagerly broke any WSGI app implemented as a generator, since start_response is not called until the generator is first iterated. - DispatcherMiddleware: copy the scope and extend root_path instead of mutating the caller's scope["path"] in place. Ported by cross-referencing the actual hypercorn 0.18.0 source (pulled from PyPI) rather than guessing at the diffs.
Mocks signal.signal, _populate and wait so run() can complete a single iteration of the multiprocess loop without spawning real processes, then asserts SIGHUP was wired to the reload() closure.
Patching signal.signal in place on the shared signal module object (via anycorn.run.signal, the same object as the global import) would intercept every signal.signal() call from any code running during the test, not just run()'s, and monkeypatch's undo only reverts attribute assignments - a genuinely installed handler would survive it. Rebind the `signal` name inside anycorn.run's own namespace instead, so the real module is never touched.
…n_it_raises run()'s multiprocess branch calls the real signal.signal(SIGINT, SIG_IGN) as the very first thing in its worker loop, before _populate or anything else runs - this test drove that branch without mocking signal at all, so it genuinely set SIGINT to ignored in the pytest process itself, with nothing to undo it once _populate's injected PicklingError skipped the later signal.signal(SIGINT, shutdown) restore. Verified with signal.getsignal(SIGINT) before/after: the handler really did change. Hoisted the SIGHUP test's _FakeSignalModule to module scope and applied the same anycorn.run.signal rebind here, so run() never reaches the real signal module in either test.
tests/test_run.py's SIGHUP test mocks signal.signal entirely, so it only proves run() registers the handler - it can't prove a real SIGHUP actually reloads a worker end-to-end. Add tests/e2e/test_sighup.py, which spawns a genuine `python -m anycorn` subprocess, sends it a real SIGHUP, and confirms the worker serving requests afterward has a different PID. The spawned worker's --worker-class is pinned to the outer test's own anyio_backend_name, so the trio-parametrised run exercises a real trio worker rather than always falling back to anycorn's asyncio default. Verified the test fails without the fix: SIGHUP's default disposition on Linux terminates a process with no handler installed, so reverting the run.py change kills the subprocess outright instead of reloading it. tests/assets/pid_app.py is a minimal ASGI app that echoes os.getpid(), used to detect the worker swap. free_tcp_port_factory is called with AF_INET explicitly - the plain free_tcp_port fixture also probes AF_INET6, which this sandbox's kernel rejects even though socket.has_ipv6 is true (the same gap that already skips test_h3.py here), unrelated to what this test checks.
The AF_INET-only workaround was routing around a gap in this sandbox specifically (no real AF_INET6 support despite socket.has_ipv6 being true). CI has IPv6, so there's no need to avoid the regular fixture there - this test will simply xfail/error the same way test_h3.py already does in environments without IPv6.
Config.daemon controls process.daemon on each spawned worker, which run.py used to hardcode to True. The concrete, OS-level consequence: Python's multiprocessing refuses to let a daemonic process spawn its own children, raising AssertionError from Process.start(). Add tests/e2e/test_daemon.py, spawning two real `python -m anycorn` subprocesses - default (daemon=True) and one configured with daemon=False via a config file, since there's no CLI flag to turn it off - and asserting only the latter's worker can create a child of its own. Verified the test fails without the fix: temporarily hardcoding process.daemon = True in run.py again flips the daemon=False case's result back to the "not allowed" error. Extracted the subprocess spawn/cleanup boilerplate shared with test_sighup.py into a new anycorn_subprocess fixture in tests/e2e/conftest.py, since two tests now need identical lifecycle handling around a real `python -m anycorn` process.
test_daemon.py was spawning its subprocess without --worker-class at all, so it always exercised an asyncio worker regardless of whether the test itself was parametrised under trio - silently skipping half the coverage. Move the --worker-class anyio_backend_name argument into the anycorn_subprocess fixture itself, so every current and future caller gets it automatically instead of needing to remember to pass it per test. test_sighup.py no longer builds it manually now that the fixture does.
…nager The fixture layer wasn't earning its keep: the context manager already handles its own setup/teardown via async with, so the only thing the fixture indirection provided was auto-injecting anyio_backend_name. Move it to tests/e2e/_subprocess.py as a plain importable @asynccontextmanager instead, taking anyio_backend_name as a required keyword argument - a caller that forgets it now fails immediately with a TypeError rather than pytest silently resolving it via fixture injection. conftest.py is removed; both test modules import the context manager directly.
WSGIWrapper.__call__ handled a lifespan scope by returning immediately without ever awaiting receive(). Lifespan.handle_lifespan only sets self.supported = False from its except branch, so a clean return left it True; its finally block then closed the ASGI lifespan channels regardless. wait_for_startup() still saw supported=True and tried to send into the now-closed channel, raising ClosedResourceError and crashing the whole worker before it ever served a request. 100% reproducible via the real CLI with any WSGI app, not just a test artifact - confirmed with a 3-line `def app(environ, start_response)` run directly through `python -m anycorn`. hypercorn's WSGIWrapper has the identical "return without reading" lifespan branch, but survives it by accident: its Lifespan uses asyncio.Queue, which silently accepts a put() after being abandoned, whereas anycorn's port to anyio memory-object streams explicitly aclose()s them, and sending into a closed stream raises. This looks like a regression introduced during that port rather than an upstream-inherited bug. Fixed by actually implementing the trivial lifespan handshake in WSGIWrapper._handle_lifespan: read lifespan.startup, ack it, read lifespan.shutdown, ack it, return. This is protocol-correct rather than relying on the race hypercorn happens to win.
Per PEP 3333, once run_app is done consuming the iterable a WSGI app returns, it must call close() on it if present - the app's hook for releasing resources tied to the response's lifetime. Add tests/e2e/test_wsgi_close.py, driving a real in-process anycorn.serve() with a real HTTP client, covering both a normal completion and the app raising mid-stream. The response body is a plain object with its own close() method rather than a generator relying on try/finally: a generator's close() (and so its finally block) runs automatically once CPython's refcounting GC collects it, regardless of whether the server ever calls it - which made an earlier, generator-based version of this test pass even with run_app's own getattr(response_body, "close", None) call deleted entirely. Verified this version actually discriminates: temporarily removed that call and confirmed all four cases fail, then restored it and confirmed they pass. In-process rather than a subprocess, since nothing here needs real OS-level process semantics.
stream_send's StreamClosed handling used to be `pass # ??`, silently leaving every finished stream's HTTPStream/WSStream sitting in self.streams for the life of the QUIC connection - an unbounded leak on any connection that outlives more than a handful of requests. Add test_stream_closed_forgets_the_stream to tests/e2e/test_h3.py, reusing the file's existing real aioquic client/server harness rather than constructing an H3Protocol directly and calling stream_send by hand (as the existing unit test in tests/protocol/test_h3.py does). Captures the real H3Protocol instance the server creates for the connection via a monkeypatched __init__, drives one real HTTP/3 request through it, and asserts self.streams is empty afterward - polled with a timeout, since StreamClosed is sent after the response body, so the client seeing the full response doesn't guarantee the server has processed it yet. Verified the test fails without the fix: temporarily restored the `pass # ??` and confirmed both backends time out waiting for streams to empty, then restored the fix and confirmed they pass again.
UDPServer.protocol and QuicProtocol.connections are already plain public attributes, so constructing UDPServer directly - instead of going through the full anycorn.serve() stack - gives a real reference to walk down to the H3Protocol instance the server itself created (connection.h3), with no need to monkeypatch H3Protocol.__init__ to capture it. Only QUIC is relevant here, so config.bind is left empty to skip creating an unused TCP listener, and the free_tcp_port fixture is no longer needed by this test. Re-verified both directions after the rewrite: temporarily restored h3.py's original `pass # ??` and confirmed both backends still time out waiting for streams to empty, then restored the fix and confirmed all 8 tests in the file pass again.
…test Client and server both run as tasks in the same event loop here (real sockets over loopback, one process), so anyio.wait_all_tasks_blocked() deterministically waits for the server's own request-handling task to finish processing StreamClosed and go idle, rather than polling connection.h3.streams with a fixed sleep interval - the same pattern tests/protocol/test_h2.py already uses for StreamBuffer. Bonus: without the fix, this now fails fast with a real assertion (~0.3s) instead of after a 10s timeout. Re-verified both directions again after the change.
WSGIWrapper.run_app defers sending http.response.start until the app's first body chunk is produced. Eagerly checking response_started right after constructing the response object (rather than after requesting its first chunk) broke every generator-based WSGI app - the idiomatic way to stream a WSGI response - since a generator function's body, including its own start_response() call, never runs until the generator is first iterated. Calling it just builds the generator object. Add tests/e2e/test_wsgi_generator_headers.py, driving a real in-process anycorn.serve() with a real HTTP client against a generator-based WSGI app, asserting the request succeeds end to end with the status/headers/body the app's start_response() call and yields actually set. Verified the test fails without the fix: temporarily restored the eager response_started check before the loop and confirmed both backends get a real 500 response with "WSGI app did not call start_response" in the server log, then restored the fix and confirmed both pass again.
…ying DispatcherMiddleware used to mutate scope["path"] in place - truncating off the mount prefix - rather than copying the scope and extending root_path instead. Because HTTPStream keeps and reuses the very same scope dict it handed to the app for the access log call that runs after the app has finished, that in-place mutation was externally observable: anycorn's own access log recorded the mount-stripped path instead of the path the client actually requested. Add tests/e2e/test_dispatcher_scope.py, driving a real in-process anycorn.serve() with a real HTTP client through a DispatcherMiddleware-mounted app. Checks two things a scope-copying fix and a scope-mutating bug would disagree on: the mounted app's own view of path/root_path, and - the part that specifically depends on the caller's scope object surviving unmutated - the access log record anycorn emits afterward, captured via a real logging.Handler rather than assumed from the mounted app's view alone. Verified the test fails without the fix: temporarily restored the in-place scope["path"] mutation and confirmed both backends see the mounted app get the truncated path with an empty root_path, then restored the fix and confirmed both pass again.
_FakeSignalModule set SIGHUP = signal.SIGHUP unconditionally as a class attribute, which raised AttributeError at class-definition time (i.e. module import time) on Windows, since Windows' signal module has no SIGHUP - breaking collection of the entire file, not just the SIGHUP test. run.py itself already guards this correctly with hasattr(); the test class didn't. Guard the attribute the same way, and skip test_run_registers_sighup_to_reload_workers outright on win32 - it's inherently testing POSIX-only reload-on-SIGHUP behaviour, matching the existing skipif pattern already used for the Unix-only socket tests in tests/test_config.py. Verified by deleting signal.SIGHUP at runtime before collection: the file now collects and the other 7 tests in it still pass.
…ist there Same underlying platform gap as the previous test_run.py fix, this time in tests/e2e/test_sighup.py's real subprocess test: it calls process.send_signal(signal.SIGHUP) directly, which raises AttributeError on Windows since that module has no SIGHUP attribute at all. This test is inherently exercising POSIX-only reload-on-SIGHUP behaviour - run.py itself only registers a SIGHUP handler when hasattr(signal, "SIGHUP") - so skip it outright there, matching the skipif pattern already used for the equivalent unit test and for the Unix-only socket tests in tests/test_config.py. Grepped the rest of the test suite for other unguarded signal.SIG* references; this was the only remaining one.
_handle_lifespan's signature fits on one line under ruff's formatting rules; CI's format --check caught what a plain check didn't.
davidbrochart
approved these changes
Jul 22, 2026
davidbrochart
left a comment
Owner
There was a problem hiding this comment.
LGTM, I guess the test failure is unrelated?
Contributor
Author
|
No I suspect related |
graingert
marked this pull request as draft
July 22, 2026 15:00
graingert
force-pushed
the
claude/anycorn-hypercorn-gaps-9ju73t
branch
2 times, most recently
from
July 22, 2026 15:20
c39701f to
ccbe615
Compare
Config.quic_bind was already fully implemented and wired through create_sockets()/worker_serve() to serve HTTP/3 - it's what tests/e2e/test_h3.py already exercises via config objects directly - but __main__.py had no CLI flag for it at all, unlike hypercorn's --quic-bind. QUIC could only be turned on via a TOML/Python config file, never from the command line. Add it matching the existing --bind/--insecure-bind pattern (multiple=True). Found via a systematic audit of hypercorn 0.18.0 against anycorn, run after finishing the six originally-targeted parity fixes.
hypercorn's asyncio backend installs a fallback signal handler inside worker_serve whenever the caller doesn't supply a shutdown_trigger, so that anycorn.serve(app, config) and --workers 0 still shut down gracefully - respecting graceful_timeout, same as every other shutdown source - on Ctrl-C or SIGTERM. Without it, SIGTERM has no handling at all (confirmed directly: exit code -15, no graceful path, no log line) and SIGINT falls back to a raw KeyboardInterrupt. Ported for the asyncio backend only, matching hypercorn's own asymmetry: its trio backend has no equivalent, relying solely on trio's built-in SIGINT-to-cancellation behaviour with no SIGTERM handling whatsoever. Implemented with anyio.open_signal_receiver rather than hand-rolled loop.add_signal_handler, since it already works identically across anyio's backends - detecting "are we actually running under asyncio" via sniffio.current_async_library(), matching the existing convention in datagram.py, rather than trusting config.worker_class (worker_serve can run under either backend regardless of what the config says). Also fixes a latent bug the new test exposed in the shared anycorn_subprocess test fixture: it called process.terminate() unconditionally in cleanup, which raises ProcessLookupError under the asyncio backend if the process already exited on its own (as it now legitimately does in this test, via the signal it's sent). Guarded it with the same returncode-is-None check already used before the second, kill() attempt. Verified directly (not just via the test) with a real subprocess: without the fix, SIGTERM exits with code -15 in under 0.01s; with it, code 0 in ~0.06s. tests/e2e/test_worker_serve_signal_fallback.py exercises both backends and asserts the deliberate asymmetry itself - graceful on asyncio, killed on trio - via --workers 0, the only path where worker_serve ever sees shutdown_trigger=None (workers >= 1 gets a real trigger from the multiprocess supervisor's shared Event). Found via a systematic audit of hypercorn 0.18.0 against anycorn, run after finishing the six originally-targeted parity fixes.
The task group already races shutdown_trigger against every other trigger source via raise_shutdown, so the signal watcher only needs to be shaped like a shutdown_trigger callable (await, then return) rather than routing through its own anyio.Event and a separately spawned task. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Real CTRL_C_EVENT can't be targeted at a single child process on Windows - GenerateConsoleCtrlEvent only accepts process-group 0 for it, which delivers to every process sharing the console, including the test runner. CTRL_BREAK_EVENT (mapped to SIGBREAK, which the asyncio fallback already watches) is the one console signal that can be aimed at just the child, via CREATE_NEW_PROCESS_GROUP. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
ty resolves platform-specific stdlib attributes against Python 3.10 on an unspecified platform, so it can't see subprocess.CREATE_NEW_PROCESS_GROUP or signal.CTRL_BREAK_EVENT even though the test itself only runs on win32. Use getattr(..., 0) for the former, matching the existing getattr(signal, "SIGBREAK", None) convention in run.py, and a ty:ignore for the latter since there's no meaningful fallback value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
anyio.open_process's asyncio backend creates Windows subprocesses via ProactorEventLoop's IOCP-based subprocess_exec(), which conflicts with creationflags=CREATE_NEW_PROCESS_GROUP - the child failed to become ready within the test's timeout on CI. A plain subprocess.Popen() run in a worker thread (via anyio.to_thread.run_sync) sidesteps ProactorEventLoop entirely, avoiding the conflict; this also explains why only the asyncio-parametrised run failed and trio's passed, since trio's own Windows subprocess support never went through Proactor either. Reverts the now-unnecessary creationflags parameter added to the shared anycorn_subprocess helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
anyio.open_signal_receiver drives asyncio's loop.add_signal_handler, which raises NotImplementedError on Windows - so the asyncio --workers 0 signal fallback crashed the worker on startup there rather than shutting it down gracefully. Match hypercorn's asyncio backend, which catches that and registers via signal.signal instead (which does work on Windows for these console signals). The original dispositions are restored on exit, so worker_serve leaves no global signal state behind when it returns. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Replaces the manual previous-handler dict and try/finally with an ExitStack that registers each restore callback right before installing the new handler, so the restores can't drift out of sync with the installs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
…r test A client that disconnects just as the response finalises can prompt a second, response-less access record (http_stream logs the stream close in addition to the completed response), so the count is timing- and platform-dependent - it surfaced as 2 records under trio on macOS. Both records still read the same scope path, which is the property the test actually guards, so assert every access record shows "/api/hello" instead of asserting exactly one record. Verified the relaxed test still fails when the dispatcher mutates scope["path"] in place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
The QUIC connection timer reschedules itself: _handle_timer does its work and then send_all calls restart on the very SingleTask running it. restart cancelled the old handle before awaiting the start of the replacement, so that pending self-cancellation tore the reschedule down at restart's own await - the timer fired once and stopped. On loopback this stayed hidden because incoming ACKs kept re-arming the timer from the receive loop, but a genuinely dropped datagram (as happens under the concurrent-response burst on Windows) left retransmission to the timer alone, which then died after a single attempt and hung the connection. Start the replacement first, then cancel the previous handle, so the reschedule survives a self-restart. Adds a socket-free regression test that drives a self-rescheduling SingleTask and fails without this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
graingert
marked this pull request as ready for review
July 22, 2026 16:59
Co-authored-by: David Brochart <david.brochart@gmail.com>
Co-authored-by: David Brochart <david.brochart@gmail.com>
Co-authored-by: David Brochart <david.brochart@gmail.com>
Revert last commit
Replaces the getattr(response_body, "close", None) probe with an isinstance check against a runtime_checkable _SupportsClose Protocol - same structural, method-presence semantics, but named and typed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Owner
|
It looks like we can still have some shared state between connections? |
Owner
I think there is some flakiness, since the failure is gone. I will restart tests a few times. |
test_state_is_not_shared_between_connections recorded id(scope["state"]) for two sequential connections and asserted the ids differed. But the first connection's state dict is freed before the second's is created, so CPython can hand the second the same address - a false negative that failed under asyncio on Windows. Hold the real state objects (as the concurrent test already does, and for the same documented reason) and compare them with `is not`, so identities can't be recycled out from under the assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZR77xNKLjWiP3Cnwq91ah
Owner
|
Thanks again @graingert. |
This was referenced Jul 23, 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.
Summary
Ports behavioral fixes and CLI features from hypercorn 0.18.0 that anycorn was missing — the original six found by cross-referencing the actual hypercorn 0.18.0 source pulled from PyPI, then two more found via a systematic file-by-file audit of the rest of hypercorn's source against anycorn once those six were done. Also fixes two severe pre-existing bugs found along the way (one that broke every WSGI app, one that hung HTTP/3 connections after packet loss), and adds real integration test coverage for all of it.
The six originally-targeted fixes
Config.daemon/-D,--daemon, so multiprocess workers can be run non-daemonic. Propagated through toProcess.daemoninrun.py(previously hardcodedTrue).run.pynow registerssignal.SIGHUPto gracefully restart workers, sharing thereload()logic already used for--reloadfile-change events.H2Protocol.stream_sendnow sends trailers withend_stream=Trueafter unblocking priority and draining the stream buffer, instead of leaving the stream open.H3Protocol.stream_sendnow doesself.streams.pop(event.stream_id, None)onStreamClosed, instead ofpass # ??(an unbounded leak ofHTTPStream/WSStreamobjects on any connection carrying more than a handful of requests).WSGIWrapper.run_appnow gateshttp.response.startbehind the first produced chunk via afirst_chunkflag. This wasn't cosmetic: eagerly checkingresponse_startedright after constructing the response object broke every generator-based WSGI app, since a generator function's body — including its ownstart_response()call — never runs until the generator is first iterated.DispatcherMiddlewarenow copies the scope and extendsroot_path, instead of mutating the caller'sscope["path"]in place. The mutation was externally observable:HTTPStreamreuses the same scope dict for its access log call after the app returns, so the log used to record the mount-stripped path instead of what the client actually requested.Two more, found via a systematic audit
Once the six above were done, went through hypercorn's entire source tree (protocol/, config, app_wrappers, middleware, utils, run/CLI, logging, and the asyncio+trio backend split vs anycorn's unified anyio port) looking for anything else. Everything else checked out — anycorn's anyio unification is a careful merge of both hypercorn backends, in places (exception-group handling in the task group) more robust than either original. Two real gaps turned up:
--quic-bindCLI flag was entirely missing.Config.quic_bindwas already fully implemented and wired throughcreate_sockets()/worker_serve()— it's whattests/e2e/test_h3.pyalready exercises via config objects — but there was no CLI flag for it at all. QUIC could only be turned on via a TOML/Python config file, never from the command line.worker_serve. hypercorn's asyncio backend installs a fallback signal handler whenever the caller doesn't supply ashutdown_trigger, sohypercorn.asyncio.serve(app, config)and single-worker (--workers 0) invocations still shut down gracefully — respectinggraceful_timeout, same as every other shutdown source. anycorn had no equivalent at all: confirmed directly with a real subprocess thatSIGTERMjust killed it outright (exit code-15, no graceful path, no log line). Ported for the asyncio backend only, matching hypercorn's own asymmetry — its trio backend has no equivalent either, relying solely on trio's built-in SIGINT-to-cancellation behaviour with no SIGTERM handling at all. The signal watcher is itself theshutdown_trigger(an awaitable that returns once a signal arrives), so the existing task-group race machinery drives it with no extraEventor task. It usesanyio.open_signal_receiver, falling back tosignal.signalwhen that raisesNotImplementedError— which is what happens on Windows, where asyncio has noloop.add_signal_handler; this matches hypercorn's own asyncio-on-Windows fallback exactly, so--workers 0 -k asynciostays gracefully shutdownable there rather than crashing on startup. The original signal dispositions are restored via anExitStackon the way out, soworker_serveleaves no global signal state behind.Bugs found and fixed along the way
1. anycorn crashed on startup for every single WSGI app. 100% reproducible via the real CLI with a 3-line app. Root cause:
WSGIWrapper.__call__handled a lifespan scope by returning immediately without ever reading fromreceive(). Per the ASGI lifespan spec, the only sanctioned way for an app to decline lifespan support is to raise —Lifespan.handle_lifespanonly setssupported = Falsefrom itsexceptbranch, so a clean return left itTrue, andwait_for_startup()went on to send into a channelLifespan's own cleanup had already closed (ClosedResourceError). hypercorn has the identical "return without reading" branch but survives it by accident (itsLifespanusesasyncio.Queue, which tolerates aput()after abandonment; anycorn's port to anyio memory-object streams does not). Fixed by actually implementing the trivial lifespan handshake inWSGIWrapper._handle_lifespan, matching the ASGI spec's own reference example almost verbatim.2. HTTP/3 connections hung permanently after a single lost datagram. The QUIC connection's retransmission timer (
AnyioSingleTask) reschedules itself:_handle_timerdoes its work and thensend_allcallsrestarton the verySingleTaskrunning it.restartcancelled the old handle before awaiting the start of the replacement, so that pending self-cancellation tore the reschedule down atrestart's ownawait— the timer fired once and then stopped. On loopback this stayed hidden, because a steady stream of incoming ACKs kept re-arming the timer from the receive loop (which doesn't self-cancel) before it ever needed to fire; but a genuinely dropped datagram left retransmission to the timer alone, which died after a single attempt and hung the connection. This surfaced as an intermittent Windows-only timeout in the concurrent-HTTP/3 test, where the response burst makes a drop likely. Fixed by starting the replacement task first, then cancelling the previous handle, so a self-restart survives — which is what keeps QUIC retransmitting. (The sameSingleTaskalso backs the TCP idle-timeout; the reorder is equally safe there.)Test coverage
Every fix above has real, verified regression coverage — not just assertions that happen to pass, but each test was confirmed to fail when the corresponding fix was temporarily reverted, then confirmed to pass again once restored:
tests/e2e/test_sighup.py— spawns a realpython -m anycornsubprocess, sends a genuineSIGHUP, confirms the worker serving requests afterward has a different PID.tests/e2e/test_daemon.py— spawns real subprocesses (default vs.--config file:...daemon=False) and confirms only the non-daemon worker can spawn its own child process (multiprocessingrefuses this for daemonic processes).tests/e2e/test_wsgi_close.py— real in-process server + real HTTP client, asserting a WSGI response object'sclose()runs both on normal completion and when the app raises mid-stream. Uses a plain object with an explicitclose()method rather than a generator, since a generator'sclose()runs automatically under CPython's refcounting GC regardless of whether the server calls it — which silently defeated an earlier draft of this test.tests/e2e/test_h3.py::test_stream_closed_forgets_the_stream— real aioquic client/server HTTP/3 request, inspecting the actualH3Protocol.streamsdict via genuinely public attributes (UDPServer.protocol.connections[...].h3) rather than monkeypatching; waits for server-side settlement viaanyio.wait_all_tasks_blocked()rather than polling.tests/e2e/test_wsgi_generator_headers.py— real server + real client against a generator-based WSGI app, confirming the request succeeds end-to-end (without the fix: real500,RuntimeError: WSGI app did not call start_response).tests/e2e/test_dispatcher_scope.py— real server + real client through aDispatcherMiddleware-mounted app, checking both the mounted app's own view ofpath/root_pathand the actual access log record anycorn emits, via a reallogging.Handler. Asserts every emitted access record shows the full requested path rather than asserting an exact record count: a client that disconnects just as the response finalises can legitimately prompt a second, response-less access record (http_streamlogs the stream close too), which surfaced under trio on macOS — the path is the property under test, not the count.tests/e2e/test_worker_serve_signal_fallback.py— real subprocess with--workers 0, sends realSIGTERM, asserts graceful exit (code0) on asyncio and the deliberate un-graceful exit on trio, proving the backend asymmetry is intentional rather than accidental. Includes a Windows-only companion that sendsCTRL_BREAK_EVENT(the one console signal that can be targeted at a single child, viaCREATE_NEW_PROCESS_GROUP, mapped toSIGBREAK) — spawned via a thread-wrappedsubprocess.Popenrather thananyio.open_process, since the asyncio ProactorEventLoop's IOCP-based subprocess creation conflicts withCREATE_NEW_PROCESS_GROUP.tests/test_worker_context.py— socket-free regression test for the QUIC-timer bug: drives aSingleTaskthat reschedules itself and asserts it keeps firing (times out on both backends without therestartreorder).tests/test___main__.py::test_quic_bind_cli_flag/test_quic_bind_cli_omitted_preserves_default— CLI-level coverage for--quic-bind.tests/test_run.py::test_run_registers_sighup_to_reload_workers/test_populate_sets_process_daemon_from_config— mocked unit-level coverage for the SIGHUP registration and daemon propagation, scoped toanycorn.run's own namespace (not the realsignalmodule).tests/test_app_wrappers.py::test_wsgi_lifespan_handshake/test_wsgi_generator_app_defers_start_response— focused unit coverage for the lifespan fix and the generator-deferred-start_responsecase.All e2e tests use the plain
free_tcp_port/free_udp_portfixtures (real IPv6 available on CI); local sandbox verification during development used a temporary, uncommitted workaround for an environment gap unrelated to this PR. Also fixed two Windows-only collection crashes (signal.SIGHUPdoesn't exist there) and a latentProcessLookupErrorin the shared subprocess test helper, surfaced by the signal-fallback test.Test plan
ruff check/ruff format --check/ty checkclean across all changed filesasyncioandtriobackends exercised throughout, including the deliberate asymmetry in the signal-fallback fix