Repository navigation
Description
Summary
When a TLS peer sends its data and then closes the TCP connection without sending
close_notify, and the application protocol has called pause_reading() at that
moment, uvloop discards plaintext that was received but not yet delivered to
data_received(). connection_lost(None) is then called, so the loss looks like a
clean close. The standard asyncio loop delivers all the data in the same scenario.
Reproduction
Self-contained; needs only the openssl CLI to make a throwaway certificate.
"""Usage: python repro.py [uvloop|asyncio]"""
import asyncio, socket, ssl, subprocess, sys, tempfile, threading
SIZE = 2_000_000
def serve(listener, ctx):
raw, _ = listener.accept()
tls = ctx.wrap_socket(raw, server_side=True)
tls.recv(2)
tls.sendall(b"x" * SIZE)
tls.close() # TCP close, no unwrap(): no close_notify is sent
class SlowReader(asyncio.Protocol):
def __init__(self, loop):
self.loop, self.received = loop, 0
self.closed = loop.create_future()
def connection_made(self, transport):
self.transport = transport
transport.write(b"go")
def data_received(self, data):
self.received += len(data)
self.transport.pause_reading()
self.loop.call_later(0.02, self.transport.resume_reading)
def connection_lost(self, exc):
self.closed.set_result(exc)
async def main(port):
loop = asyncio.get_running_loop()
ctx = ssl.create_default_context()
ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
_, proto = await loop.create_connection(lambda: SlowReader(loop), "127.0.0.1", port, ssl=ctx)
exc = await asyncio.wait_for(proto.closed, 30)
print(f"{type(loop).__module__}: received {proto.received} of {SIZE}, connection_lost({exc!r})")
with tempfile.TemporaryDirectory() as d:
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-subj", "/CN=localhost", "-keyout", f"{d}/k.pem", "-out", f"{d}/c.pem"],
check=True, capture_output=True,
)
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(f"{d}/c.pem", f"{d}/k.pem")
listener = socket.create_server(("127.0.0.1", 0))
threading.Thread(target=serve, args=(listener, server_ctx), daemon=True).start()
port = listener.getsockname()[1]
if sys.argv[1:] == ["uvloop"]:
import uvloop
uvloop.run(main(port))
else:
asyncio.run(main(port))Actual vs expected
uvloop: received 1884160 of 2000000, connection_lost(None)
asyncio.unix_events: received 2000000 of 2000000, connection_lost(None)
The amount lost varies from run to run. If the server calls unwrap() before closing
(sends close_notify), uvloop delivers everything.
Real-world impact
Amazon S3 answers every 100th request on a keep-alive connection with
Connection: close and closes this way. An aiohttp client that streams such a
response to a slower consumer (so aiohttp's flow control pauses reading) gets
ClientPayloadError: Response payload is not completed: ContentLengthError on
uvloop, and never on asyncio.
Versions
- uvloop 0.19.0, 0.21.0, 0.22.1 and 0.23.0 all reproduce
- CPython 3.12.13, OpenSSL 3.5.5
- macOS 27 (arm64) and Linux (x86_64)
Activity