Repository navigation
_SSLProtocolTransport.close() cannot close the connection until timeout or EOF #471
Copy link
Copy link
Open
Labels
Description
- uvloop version: 0.16.0
- Python version: CPython 3.9.12
- Platform: Debian GNU/Linux bookworm/sid x86_64
- Can you reproduce the bug with
PYTHONASYNCIODEBUGin env?: yes - Does uvloop behave differently from vanilla asyncio? How?: yes, the bug is only reproducible with uvloop
Desciption
If uvloop is enabled, as long as EOF is not reached, even if the response, connection, and session are all closed, the server from which aiohttp requested resources, will still keep sending packets to the aiohttp-client for some time. However, the bug is only reproducible with uvloop enabled.
To reproduce
aiohttp[speedups]==3.8.1
uvloop==0.16.0
import asyncio
import aiohttp
import uvloop
import logging
logging.basicConfig(level=logging.DEBUG)
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
logging.debug(response)
logging.debug(response.content)
async def reproduce(wait):
await fetch('https://sgp-ping.vultr.com/vultr.com.1000MB.bin')
await asyncio.sleep(wait) # now you will see that the file is still being downloaded
def main(enable_uvloop):
if enable_uvloop:
logging.info('********** Using uvloop **********')
uvloop.install() # the bug is only reproducible with uvloop
else:
logging.info('********** Using asyncio **********')
loop = asyncio.new_event_loop()
loop.set_debug(True) # or set env PYTHONASYNCIODEBUG=1, not necessary to reproduce the bug
asyncio.set_event_loop(loop)
loop.run_until_complete(reproduce(15 if enable_uvloop else 5))
loop.close()
if __name__ == '__main__':
main(enable_uvloop=False)
main(enable_uvloop=True)
input('Press Enter to exit')Log and screenshot
INFO:root:********** Using asyncio **********
DEBUG:asyncio:Using selector: EpollSelector
DEBUG:asyncio:Get address info sgp-ping.vultr.com:443, type=<SocketKind.SOCK_STREAM: 1>, flags=<AddressInfo.AI_ADDRCONFIG: 32>
INFO:asyncio:Getting address info sgp-ping.vultr.com:443, type=<SocketKind.SOCK_STREAM: 1>, flags=<AddressInfo.AI_ADDRCONFIG: 32> took 209.423ms: [(<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 6, '', ('45.32.100.168', 443))]
DEBUG:asyncio:<asyncio.sslproto.SSLProtocol object at 0x7fc80064dfa0> starts SSL handshake
DEBUG:asyncio:<asyncio.sslproto.SSLProtocol object at 0x7fc80064dfa0>: SSL handshake took 225.2 ms
DEBUG:asyncio:<asyncio.TransportSocket fd=6, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=6, laddr=('192.168.1.101', 51708), raddr=('45.32.100.168', 443)> connected to 45.32.100.168:443: (<asyncio.sslproto._SSLProtocolTransport object at 0x7fc7ff9b5a00>, <aiohttp.client_proto.ResponseHandler object at 0x7fc800205b20>)
DEBUG:root:<ClientResponse(https://sgp-ping.vultr.com/vultr.com.1000MB.bin) [200 OK]>
<CIMultiDictProxy('Server': 'nginx', 'Date': 'Sat, 21 May 2022 00:29:55 GMT', 'Content-Type': 'application/octet-stream', 'Content-Length': '1048576000', 'Last-Modified': 'Mon, 20 Sep 2021 19:54:01 GMT', 'Connection': 'keep-alive', 'Etag': '"6148e6d9-3e800000"', 'Expires': 'Sun, 22 May 2022 00:29:55 GMT', 'Cache-Control': 'max-age=86400', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Access-Control-Allow-Origin': '*', 'Accept-Ranges': 'bytes')>
DEBUG:root:<StreamReader 15968 bytes>
DEBUG:asyncio:<asyncio.sslproto.SSLProtocol object at 0x7fc80064dfa0>: SSL error in data received
Traceback (most recent call last):
File "/usr/lib/python3.9/asyncio/sslproto.py", line 534, in data_received
ssldata, appdata = self._sslpipe.feed_ssldata(data)
File "/usr/lib/python3.9/asyncio/sslproto.py", line 206, in feed_ssldata
self._sslobj.unwrap()
File "/usr/lib/python3.9/ssl.py", line 948, in unwrap
return self._sslobj.shutdown()
ssl.SSLError: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2756)
DEBUG:asyncio:Close <_UnixSelectorEventLoop running=False closed=False debug=True>
INFO:root:********** Using uvloop **********
DEBUG:asyncio:<uvloop.loop.SSLProtocol object at 0x7fc8040f8b40> starts SSL handshake
DEBUG:asyncio:<uvloop.loop.SSLProtocol object at 0x7fc8040f8b40>: SSL handshake took 227.0 ms
DEBUG:root:<ClientResponse(https://sgp-ping.vultr.com/vultr.com.1000MB.bin) [200 OK]>
<CIMultiDictProxy('Server': 'nginx', 'Date': 'Sat, 21 May 2022 00:30:01 GMT', 'Content-Type': 'application/octet-stream', 'Content-Length': '1048576000', 'Last-Modified': 'Mon, 20 Sep 2021 19:54:01 GMT', 'Connection': 'keep-alive', 'Etag': '"6148e6d9-3e800000"', 'Expires': 'Sun, 22 May 2022 00:30:01 GMT', 'Cache-Control': 'max-age=86400', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', 'Access-Control-Allow-Origin': '*', 'Accept-Ranges': 'bytes')>
DEBUG:root:<StreamReader 15968 bytes>
Press Enter to exit
The bug has also been reported to aiohttp: aio-libs/aiohttp#6762
Reactions are currently unavailable

Activity
feat(env): `NO_UVLOOP` to force disable uvloop
[-]aiohttp-client connections cannot be really closed when uvloop installed[/-][+]aiohttp-client connections cannot be really closed before EOF when uvloop enabled[/+]Rongronggg9 commentedon May 22, 2022
Some statistics in my production environment. (In my use case only the response headers and/or the beginning 4KB of the response body is needed.)
Rongronggg9 commentedon May 22, 2022
I'd done some deep-dive and found that the bug is a TLS/SSL-relevant bug. Non-TLS connections can be closed even if
uvloopis enabled.Rongronggg9 commentedon May 22, 2022
I also analyzed the web traffic with Wireshark. With
asyncio,RSTis immediately sent once the connection was closed; but withuvloop, closing the connection won't change anything as if is it not closed,RSTis only sent after the event loop is closed.Rongronggg9 commentedon May 22, 2022
Another deep-dive:
async def fetch(url): async with aiohttp.ClientSession() as session: async with session.get(url) as response: logging.debug(response) logging.debug(response.content) + await asyncio.sleep(5) # at this point, only a little part of the response is read, then pends + return # after the connection is "closed", uvloop will resume reading the responseRongronggg9 commentedon May 24, 2022
A deeper dive shows the direct cause of the bug.
async def reproduce(wait): await fetch('https://sgp-ping.vultr.com/vultr.com.1000MB.bin') - await asyncio.sleep(wait) # now you will see that the file is still being downloaded + await asyncio.sleep(35) # the download will last for exactly 30s, then abortaiohttpclosing its high-level connection causesuvloopto call_SSLProtocolTransport.close().uvloop/uvloop/sslproto.pyx
Lines 42 to 51 in 3e71ddc
uvloop/uvloop/sslproto.pyx
Lines 559 to 574 in 3e71ddc
We can easily verify that the low-level connection is closed 30 seconds after
aiohttphave "closed" its high-level connection (as long as the response body is big enough). This complies withSSL_SHUTDOWN_TIMEOUT. That is to say, it isSSLProtocol._ssl_shutdown_timeout()that eventually closes the connection.uvloop/uvloop/includes/consts.pxi
Line 24 in 3e71ddc
Now that
SSLProtocol._ssl_shutdown_timeout()is called, the conclusion is evident thatSSLProtocol._do_flush()causes the reading to be resumed but meanwhile is stuck in something until EOF or timeout is reached, causing the reading sustained.uvloop/uvloop/sslproto.pyx
Lines 600 to 618 in 3e71ddc
Here's a simple hotfix to stop
uvloopfrom reading the response happily and not willing to close the connection.fix(web): hotfix unclosed `uvloop` SSL transport
rojamit commentedon May 31, 2022
i have the same problem with httpS server using uvloop, but with asyncio it works as expected.
After close() call i cant see new data_received calls associated with "closed" connection, but incoming traffic persists for about 10 seconds after transport.close(). switching to abort() didnt help
Rongronggg9 commentedon May 31, 2022
Hi, @rojamit. I don't really so much familiar with aio servers. Could you please figure out some code to reproduce that?
(I wonder, since it is a TLS/SSL issue, why aio servers are affected? Typically speaking a server itself should not deal with TLS/SSL itself as it is the business of reverse proxies, e.g. Nginx)
rojamit commentedon Jun 1, 2022
@Rongronggg9 right, its a https reverse proxy implemented with asyncio and httptools. problem occurs only with uvloop, and looks like 1:1 as yours, but on the server-side
about code:
and... traffic still persist for about 10-30 seconds AFTER close()
without uvloop - no problems like this
notice: no new data_received calls after close() occurs, but traffic and CPU load
[-]aiohttp-client connections cannot be really closed before EOF when uvloop enabled[/-][+]`_SSLProtocolTransport.close()` cannot close the connection until timeout or EOF[/+]rojamit commentedon Jun 23, 2022
@Rongronggg9 hmm, is uvloop abandoned?...
Rongronggg9 commentedon Jun 23, 2022
Hopefully not. Historically speaking, some of the recent versions were released right after a long-time radio silence. If someone figures out a PR to fix this, it could make the process faster. I am not really familiar with C-binding libraries so I can do little.
fantix commentedon Jun 23, 2022
Sorry for the late reply! With a quick look through the details (thanks @Rongronggg9 for the debugging!), this is probably because the remote peer didn't reply a proper TLS
close_notify.More specifically,
transport.close()is similar tosocket.close(), they don't imply an immediate shutdown on the low-level resources. Instead, they are supposed to do a graceful shutdown, with all the write buffer flushed and nicely closes the connection - in TLS 1.3 this means each end sends aclose_notifymessage. Which TLS version are you using btw?6 remaining items
Rongronggg9 commentedon Sep 15, 2022
So here locates the buggy code:
Being commented out,
uvloopshows 100% the same behavior as vanilla asyncio in CPython 3.11 (closing the connection as expected and logging the fatal error):You may also inspect the source code of both uvloop and asyncio yourself. Vanilla asyncio in CPython 3.11 indeed does NOT read before unwrap in the shutdown stage.
https://github.com/python/cpython/blob/3.11/Lib/asyncio/sslproto.py#L643
Rongronggg9 commentedon Sep 16, 2022
After reading more refs, I finally realized that there do be such a probability that the "weird" behavior is caused by misbehaved servers (my case) or clients (#471 (comment)).
Indeed, according to the spec (e.g. https://www.rfc-editor.org/rfc/rfc5246), waiting for
close_notify("two-way shutdown") is considered more secure (to get rid of truncation attacks). However, there are tons of server/client implementations never reply toclose_notify, making the "secure" behavior "leak" bandwidth. In all use cases of TLS, HTTPS is absolutely the most widespread, and HTTP/1.0 or newer is not vulnerable to truncation attacks no matter whether the TLS shutdown is one-way or two-way.So there are three solutions:
APPLICATION_DATA_AFTER_CLOSE_NOTIFYerror, and no longer wait forclose_notifyreply from the other side.close_notify, if so, drop the connection early (the current 30s timeout insanely leaks bandwidth).The violation of spec is so widespread that until now no other one realized that it is caused by a violation. My preference is 1.
fantix commentedon Oct 1, 2022
Thanks for the very detailed reply and sorry for the delay!
I think we should do (1) for TLS 1.2 and lower to "conform with widespread implementation practice", and cut down the
SSL_SHUTDOWN_TIMEOUTto maybe 10s to reduce the impact in TLS 1.3.Python 3.11 behaves differently because #385 wasn't merged in, I'll probably create a PR to do #385 there, together with the proposed fix here.
Close transport after sending close_notify in TLSv1.2
Rongronggg9 commentedon Oct 2, 2022
At first, I consider it is OK. After doing some tests, I was so sad to find that Cloudflare still violates the spec even in TLS 1.3... It means that the situation of the violation does not become much better in the world of TLS 1.3.
For servers, they maintain a connection pool and recycle connections immediately (which relies on client-side one-way shutdowns), not willing to "waste" any bandwidth to reply to
close_notify. For client developers, they don't know much and hardly care about the spec, but worry about bandwidth a lot.I think we should reconsider Option 3 ("add an option to switch between the two behavior"), both for uvloop and CPython asyncio. If already known that the
SSLProtocolis used by HTTP/1.0 or later, libraries on top of uvloop or asyncio can simply force one-way shutdowns, and vice versa.@Dreamsorcerer @asvetlov @webknjaz Sorry for disturbing you. What do you think about the issue?
Dreamsorcerer commentedon Oct 2, 2022
I'm no expert with TLS, so I'm just relying on your investigations. :P
My only question if we add a switch, who changes the switch? If it's the user, when would they know which option to use?
Rongronggg9 commentedon Oct 2, 2022
That could be a question to be discussed. What's your preference for that? My preference is that everyone can change it, but HTTP libraries should have two-way shutdowns turned off by default. Option 3 is just my personal advice, not a result of the discussion. You may have other choices :)
Even if both CPython asyncio and uvloop adopted #507, libraries on top of them could still call
.abort()to roughly simulate a one-way shutdown.In fact, I am more concerned about aiohttp maintainers' opinion about the question that "comfort to the spec or save users' bandwidth?" At least for HTTP/1.0 or later, doing a two-way shutdown does not show additional advantages according to my research, but wastes a lot of bandwidth if the other side only supports one-way shutdowns. Most users do not expect such behavior.
fantix commentedon Oct 2, 2022
Actually I got a new idea. We can implement the
SSLTransport.write_eof()method and move the two-way shutdown there, while changing the behavior ofSSLTransport.close()to one-way shutdown to meet the general need, as the RFC 8446 does say:If the status quo (of CPython, both <= 3.10 and == 3.11) is already taking this risk of truncation, I think it is okay to keep it that way. Users who do care about this risk could just
write_eof()and explicitly wait untileof_received(), and then close the transport safely. This also opens doors to implementing "TLS downgrade" - the counter-version ofloop.start_tls().fantix commentedon Oct 2, 2022
Proposed shutdown flow:
So basically user could now 1) call
write_eof()(and expect to receive more TLS data), or 2) return different values ineof_received(). Specifically in (2), if the returned value is a false value, we just close the underlying transport; or else if it's a true value:write_eof()wasn't called, and eventually close the transport when the user chooses to do so.set_protocol()on the underlying transport. TheSSLProtocolwill be discarded, and the given protocol will receive the underlying transport throughconnection_made().Rongronggg9 commentedon Oct 3, 2022
@fantix Wow, your flow graph is so clear. I second your proposal!
There is a slight question, however. How can a user "return different values in
eof_received()"? Shouldn't it be a method ofSSLProtocol? Or users are expected to subclassSSLProtocolto change the behavior?fantix commentedon Oct 3, 2022
哈哈,哪里哪里🤓谢谢
You may notice 2
eof_received()in the diagram, the green one is the one you mentioned inSSLProtocol, while the red one is a user-provided protocol implementation inheriting directly fromasyncio.Protocol, a.k.a. “app protocol”. And that’s the one user would return different values from.Rongronggg9 commentedon Oct 8, 2022
Understood. Then I have no more questions and thanks for your explanation :)
feat(env): `NO_UVLOOP` to force disable uvloop
fix(web): hotfix unclosed `uvloop` SSL transport