Skip to content

Claude/anycorn hypercorn gaps - #48

Merged
davidbrochart merged 35 commits into
davidbrochart:mainfrom
graingert:claude/anycorn-hypercorn-gaps-9ju73t
Jul 22, 2026
Merged

Claude/anycorn hypercorn gaps#48
davidbrochart merged 35 commits into
davidbrochart:mainfrom
graingert:claude/anycorn-hypercorn-gaps-9ju73t

Conversation

@graingert

@graingert graingert commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

  • Daemonize workersConfig.daemon / -D, --daemon, so multiprocess workers can be run non-daemonic. Propagated through to Process.daemon in run.py (previously hardcoded True).
  • Reload workers on SIGHUPrun.py now registers signal.SIGHUP to gracefully restart workers, sharing the reload() logic already used for --reload file-change events.
  • H2 trailersH2Protocol.stream_send now sends trailers with end_stream=True after unblocking priority and draining the stream buffer, instead of leaving the stream open.
  • H3 stream cleanupH3Protocol.stream_send now does self.streams.pop(event.stream_id, None) on StreamClosed, instead of pass # ?? (an unbounded leak of HTTPStream/WSStream objects on any connection carrying more than a handful of requests).
  • WSGI header delayWSGIWrapper.run_app now gates http.response.start behind the first produced chunk via a first_chunk flag. This wasn't cosmetic: eagerly checking response_started right after constructing the response object broke every generator-based WSGI app, since a generator function's body — including its own start_response() call — never runs until the generator is first iterated.
  • Dispatcher path/root_pathDispatcherMiddleware now copies the scope and extends root_path, instead of mutating the caller's scope["path"] in place. The mutation was externally observable: HTTPStream reuses 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-bind CLI flag was entirely missing. Config.quic_bind was already fully implemented and wired through create_sockets()/worker_serve() — it's what tests/e2e/test_h3.py already 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.
  • No fallback SIGINT/SIGTERM/SIGBREAK handling in worker_serve. hypercorn's asyncio backend installs a fallback signal handler whenever the caller doesn't supply a shutdown_trigger, so hypercorn.asyncio.serve(app, config) and single-worker (--workers 0) invocations still shut down gracefully — respecting graceful_timeout, same as every other shutdown source. anycorn had no equivalent at all: confirmed directly with a real subprocess that SIGTERM just 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 the shutdown_trigger (an awaitable that returns once a signal arrives), so the existing task-group race machinery drives it with no extra Event or task. It uses anyio.open_signal_receiver, falling back to signal.signal when that raises NotImplementedError — which is what happens on Windows, where asyncio has no loop.add_signal_handler; this matches hypercorn's own asyncio-on-Windows fallback exactly, so --workers 0 -k asyncio stays gracefully shutdownable there rather than crashing on startup. The original signal dispositions are restored via an ExitStack on the way out, so worker_serve leaves 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 from receive(). Per the ASGI lifespan spec, the only sanctioned way for an app to decline lifespan support is to raiseLifespan.handle_lifespan only sets supported = False from its except branch, so a clean return left it True, and wait_for_startup() went on to send into a channel Lifespan's own cleanup had already closed (ClosedResourceError). hypercorn has the identical "return without reading" branch but survives it by accident (its Lifespan uses asyncio.Queue, which tolerates a put() after abandonment; anycorn's port to anyio memory-object streams does not). Fixed by actually implementing the trivial lifespan handshake in WSGIWrapper._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_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 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 same SingleTask also 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 real python -m anycorn subprocess, sends a genuine SIGHUP, 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 (multiprocessing refuses this for daemonic processes).
  • tests/e2e/test_wsgi_close.py — real in-process server + real HTTP client, asserting a WSGI response object's close() runs both on normal completion and when the app raises mid-stream. Uses a plain object with an explicit close() method rather than a generator, since a generator's close() 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 actual H3Protocol.streams dict via genuinely public attributes (UDPServer.protocol.connections[...].h3) rather than monkeypatching; waits for server-side settlement via anyio.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: real 500, RuntimeError: WSGI app did not call start_response).
  • tests/e2e/test_dispatcher_scope.py — real server + real client through a DispatcherMiddleware-mounted app, checking both the mounted app's own view of path/root_path and the actual access log record anycorn emits, via a real logging.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_stream logs 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 real SIGTERM, asserts graceful exit (code 0) 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 sends CTRL_BREAK_EVENT (the one console signal that can be targeted at a single child, via CREATE_NEW_PROCESS_GROUP, mapped to SIGBREAK) — spawned via a thread-wrapped subprocess.Popen rather than anyio.open_process, since the asyncio ProactorEventLoop's IOCP-based subprocess creation conflicts with CREATE_NEW_PROCESS_GROUP.
  • tests/test_worker_context.py — socket-free regression test for the QUIC-timer bug: drives a SingleTask that reschedules itself and asserts it keeps firing (times out on both backends without the restart reorder).
  • 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 to anycorn.run's own namespace (not the real signal module).
  • 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_response case.

All e2e tests use the plain free_tcp_port/free_udp_port fixtures (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.SIGHUP doesn't exist there) and a latent ProcessLookupError in the shared subprocess test helper, surfaced by the signal-fallback test.

Test plan

  • ruff check / ruff format --check / ty check clean across all changed files
  • Full test suite passes (360 passing, plus 2 Windows-only tests skipped on non-Windows)
  • Every fix's test independently verified to fail without the fix and pass with it restored
  • Both asyncio and trio backends exercised throughout, including the deliberate asymmetry in the signal-fallback fix

claude added 19 commits July 22, 2026 11:15
- 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 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.

LGTM, I guess the test failure is unrelated?

@graingert

Copy link
Copy Markdown
Contributor Author

No I suspect related

@graingert
graingert marked this pull request as draft July 22, 2026 15:00
@graingert
graingert force-pushed the claude/anycorn-hypercorn-gaps-9ju73t branch 2 times, most recently from c39701f to ccbe615 Compare July 22, 2026 15:20
claude added 6 commits July 22, 2026 15:53
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
claude added 4 commits July 22, 2026 16:32
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
graingert marked this pull request as ready for review July 22, 2026 16:59
@graingert
graingert requested a review from davidbrochart July 22, 2026 17:04
Comment thread src/anycorn/__main__.py Outdated
Comment thread src/anycorn/app_wrappers.py Outdated
Comment thread src/anycorn/worker_context.py Outdated
graingert and others added 5 commits July 22, 2026 21:34
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
@davidbrochart

Copy link
Copy Markdown
Owner

It looks like we can still have some shared state between connections?

@davidbrochart

Copy link
Copy Markdown
Owner

It looks like we can still have some shared state between connections?

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
@graingert
graingert requested a review from davidbrochart July 22, 2026 21:03

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

Makes sense.

@davidbrochart
davidbrochart merged commit 1b5bac6 into davidbrochart:main Jul 22, 2026
19 checks passed
@davidbrochart

davidbrochart commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks again @graingert.
I'm wondering if we could ask Claude to go over hypercorn's GitHub issues, check if they also exist in anycorn, and fix them?

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