Repository navigation
Description
Bug report
Bug description
For an application that has paused reading from a TLS transport, a FIN signal from the peer parks the connection correctly, but leaves the transport stuck until the reading resumes. This state is not communicated to the application truthfully, which causes effort to be wasted by the application for a client that's no longer listening to the connection. This is not applicable for TCP connections.
This bug was caught during the development of a media server, and can be explained by the following example:
- A peer requests a file, the application gets to work on the request, reading some data from disk and sending it to the peer. The transmission of the data to the peer leverages
loop.sendfile(). - The peer decides to close the connection after reading what arrived and sends the
FINsignal. NOTE: If the peer closes before reading the data, it sends a reset which, well, resets the connection correctly. - While data is being sent to the peer, the application is not reading anything the peer is sending back (
loop.sendfile()over TLS uses a fallback that pauses reading for the whole transfer) so theFINsignal is caught by the TLS layer ofasyncio, which parks the connection correctly and sets the state of the transport toFLUSHING.drain()then checksis_closing()which isFalse, causingloop.sendfile()to carry on -- this is where the bug comes in. - The application keeps reading data from disk but nothing is telling it the peer does not want it anymore. All the data being read is dropped in
_write_appdata()with a log message ofSSL connection is closedafter reachingLOG_THRESHOLD_FOR_CONNLOST_WRITES(currently 5). Sinceis_closing()isFalse, the log message is contradictory. - Eventually, the
loop.sendfile()loop reaches the end of the file and reports success (i.e. returns the full file size for a peer that only got a slice), the fallback has resumed reading incoming data before the loop finished, and then the connection is closed.
While this example covers loop.sendfile(), the same issue can be met with streams. Keep in mind that in this case, the reader pauses itself past 2x limit and nothing resumes it if the handler does not read again. In the loop.sendfile() case reading resumes courtesy of the fallback. The following code leverages streams instead of the function in the description.
repro_issue.py
"""A TLS stream server, a client that sends more than the reader's limit and disconnects."""
import asyncio, socket, ssl
from test.support import findfile
CERT = findfile('keycert.pem', subdir='certdata')
async def handler(reader, writer):
await asyncio.sleep(0.5) # busy; the reader's buffer is full, so reading is paused
writer.close()
try:
await asyncio.wait_for(writer.wait_closed(), 5)
print(' wait_closed() returned')
except TimeoutError:
print(' wait_closed() still waiting after 5 s, socket',
writer.get_extra_info('socket').fileno())
writer.transport.abort()
def client(port, tls):
s = socket.create_connection(('127.0.0.1', port))
if tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(s)
s.sendall(bytes(8192))
s.settimeout(0.2)
try:
s.recv(1) # takes in TLS 1.3 session tickets; nothing else is coming
except TimeoutError:
pass
s.close() # an ordinary disconnect
async def main():
sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sctx.load_cert_chain(CERT)
for tls in (False, True):
print('TLS' if tls else 'TCP')
kw = {'ssl': sctx, 'ssl_shutdown_timeout': 1} if tls else {}
server = await asyncio.start_server(handler, '127.0.0.1', 0, limit=1024, **kw)
await asyncio.to_thread(client, server.sockets[0].getsockname()[1], tls)
await asyncio.sleep(6)
server.close()
asyncio.run(main())TCP
wait_closed() returned
TLS
wait_closed() still waiting after 5 s, socket 8
Important notes about this issue:
close()does nothing because_start_shutdown()returns early forFLUSHING. Also,connection_lost()does not come,wait_closed()does not return, the socket stays open, andssl_shutdown_timeoutdoes not apply.- This bug may look similar to #160520 but it isn't: the parked connection code path returns well before reaching the code that covers that issue.
- This state cannot be cleared by the peer. When the writes are dropped in
_write_appdata(), nothing goes to the peer, so the peer is unable to send a connection reset signal.
The Fix Proposal
is_closing() becomes true in the states where writes are dropped, so the loop.sendfile() TLS fallback's drain() raises ConnectionError, and _start_shutdown() proceeds from FLUSHING when reading is paused.
It is important to keep returning True when moving the state to FLUSHING when the reading is paused (reference: SSLProtocol.eof_received()) because any data the peer sent would be lost otherwise.
With three new tests and this proposed fix, test_asyncio passes on main. I can open a PR if that direction looks right.
This was tested as follows:
| Version | macOS | Linux |
|---|---|---|
main |
Yes | Yes, debug build in a container (Docker) |
| 3.14 | 3.14.9 | No |
| 3.12 | 3.12.13, without repro_issue.py | 3.12.3 on rpi2, including repro_issue.py |
Disclosure: I used Claude (Opus 5.5) to trace this, write the reproducers and tests, and run them on my machines. Claude found the bug as well and wrote the candidate fix. I have read and understood the work, and I am fully responsible for this report.
CPython versions tested on:
3.12, 3.14
Operating systems tested on:
macOS, Linux
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity