Close the QUIC datagram transports on shutdown - #373
Open
CoryLowe5 wants to merge 1 commit into
Open
Conversation
worker_serve keeps its TCP servers in `servers` and closes them in its finally, but the transport returned by create_datagram_endpoint was discarded, so shutdown could not close what it never kept. Each QUIC-serving worker leaked its datagram transport, visible as a GC-time "unclosed transport" ResourceWarning (python -W always). Keep the datagram transports and close them after the graceful drain, alongside the existing server close. Closing after the drain rather than before it preserves in-flight QUIC traffic during graceful_timeout: unlike a TCP Server, the datagram transport is both the listener and the data path for established connections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CoryLowe5
commented
Aug 31, 2026
Author
Reproduction (self-contained — generates a throwaway self-signed certificate; run with """Reproduce: hypercorn's asyncio worker leaks its QUIC datagram transport.`worker_serve` keeps its TCP servers in `servers` and closes them in itsfinally, but the transport returned by `create_datagram_endpoint` isdiscarded into `_`, so shutdown cannot close what it never kept. Theleaked transport surfaces as a GC-time ResourceWarning.Run against hypercorn 0.18.0 with the h3 extra: python -W always repro_datagram_leak.pyExpected output on 0.18.0: LEAK: 1 'unclosed transport' ResourceWarning(s)With the fix: CLEAN: no unclosed-transport ResourceWarningSelf-contained: generates a throwaway self-signed certificate."""importasyncioimportdatetimeimportgcimporttempfileimportwarningsfrompathlibimportPathfromcryptographyimportx509fromcryptography.hazmat.primitivesimporthashes, serializationfromcryptography.hazmat.primitives.asymmetricimportecfromcryptography.x509.oidimportNameOIDfromhypercorn.app_wrappersimportASGIWrapperfromhypercorn.asyncio.runimportworker_servefromhypercorn.configimportConfigasyncdefapp(scope, receive, send):
ifscope["type"] =="lifespan":
whileTrue:
message=awaitreceive()
ifmessage["type"] =="lifespan.startup":
awaitsend({"type": "lifespan.startup.complete"})
elifmessage["type"] =="lifespan.shutdown":
awaitsend({"type": "lifespan.shutdown.complete"})
returndefwrite_self_signed(directory: Path) ->tuple[Path, Path]:
key=ec.generate_private_key(ec.SECP256R1())
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
now=datetime.datetime.now(datetime.timezone.utc)
cert= (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now+datetime.timedelta(days=1))
.sign(key, hashes.SHA256())
)
cert_path=directory/"cert.pem"key_path=directory/"key.pem"cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
)
returncert_path, key_pathasyncdefmain() ->None:
withtempfile.TemporaryDirectory() astmp:
cert, key=write_self_signed(Path(tmp))
config=Config()
config.bind= []
config.quic_bind= ["127.0.0.1:0"]
config.certfile=str(cert)
config.keyfile=str(key)
config.graceful_timeout=0.1asyncdefshutdown_now() ->None:
returnNoneawaitworker_serve(
ASGIWrapper(app), config, shutdown_trigger=shutdown_now
)
withwarnings.catch_warnings(record=True) ascaught:
warnings.simplefilter("always")
asyncio.run(main())
gc.collect()
leaks= [wforwincaughtifissubclass(w.category, ResourceWarning)
and"unclosed transport"instr(w.message)]
ifleaks:
print(f"LEAK: {len(leaks)} 'unclosed transport' ResourceWarning(s)")
forwinleaks:
print(f" {w.message}")
else:
print("CLEAN: no unclosed-transport ResourceWarning") |
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 freeto 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.
worker_servekeeps its TCP servers inserversand closes them inits finally, but the transport returned by
create_datagram_endpointis discarded, so shutdown cannot close what it never kept. Each
QUIC-serving worker leaks its datagram transport, visible as a
GC-time
unclosed transportResourceWarning underpython -W always(we found it via tracemalloc pointing into
hypercorn/asyncio/run.pyfrom a test suite that serves QUIC in-process many times per run).
The fix keeps the datagram transports and closes them after the
graceful drain, alongside the existing server close. After the drain
rather than before it, deliberately: unlike a TCP
Server— whereclose()only stops new connections — the datagram transport is boththe listener and the data path for established QUIC connections, so
closing it earlier would break in-flight traffic during
graceful_timeout.Includes a test (
tests/asyncio/test_run.py) that serves one QUICsocket through the real
worker_serveand asserts every transportcreated by
create_datagram_endpointis closing after it returns; itskips when aioquic is not installed, matching the h3 extra. A
self-contained reproduction script is attached below in a comment for
convenience.
(The trio worker was inspected and left alone: it hands its socket to
trio.socket.from_stdlib_socketinside the UDPServer under thenursery — different ownership shape, not measured, not touched.)