-
-
Notifications
You must be signed in to change notification settings - Fork 31.8k
Unable to cancel Server.serve_forever() if a reader is blocked while reading (3.12 regression) #123720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
I will try to look at this when I'm at the core sprint, Sept 23-27. |
@paravoid The above code doesn't work; it crashes on line 6. I guess you didn't actually run it before filing the bug? Also I don't know what Finally, your code differs (apart from the crash) from the original code in several ways:
Which of these is significant? |
@gvanrossum Thanks for taking a look! I did run the code but tried simplifying it after the fact (it's all in the GitHub history). Fixed now, it was a
None of the two are significant. The write has been removed for simplicity (it doesn't matter). The try/except is there to make it easier to see that error handling does work when a client is not connected. |
My first reaction, after putting a bunch of print calls in the code you quoted in Server.close() in base_events.py, is that this was done intentionally (but without thinking through all the consequences). The cure would then to be to put a timeout in each handler, e.g.
You could also catch the TimeoutError and continue to close the connection (so you don't get a traceback logged). However it looks that in this case you will hang in the handler's In any case, this gives a workaround for 3.12 and 3.13 (the latter isn't out yet but has been in feature freeze for months) but it's far from perfect. I'm not sure what to do to make the experience better without adding timeouts to every handler. Maybe the solution is just to send CancelledError into every handler that's still active when the server is closed? That's close to what I assumed was happening before 3.12 -- the server process responds immediately (?) to ^C, so the handlers are effectively abandoned. What do you think, @paravoid ? |
Here's a diff that appears to fix your issue and break no tests (though it clearly needs a new test that will demonstrate it changed something). index ffcc0174e1e..a42c4ec0037 100644
--- a/Lib/asyncio/base_events.py
+++ b/Lib/asyncio/base_events.py
@@ -381,6 +381,7 @@ async def serve_forever(self):
except exceptions.CancelledError:
try:
self.close()
+ self.close_clients()
await self.wait_closed()
finally:
raise |
Ah! That's neat. I originally discovered this in 3.12 and only later reproduced it in 3.13, so I hadn't noticed the addition of In light of this fix, it may be entirely moot now, but with regards to your previous message: adding arbitrarily-valued timeouts doesn't strike me as a great solution. The value may be too short for legitimate use cases, forcing users to have to reimplement retries in a loop to |
I will have to ponder whether it's okay to backport this to 3.12 and 3.13. I can't decide whether it's a new feature that might break code that worked before in 3.12 or 3.13, or just a bug fix (which would qualify for backporting). To deal with the cancellation, handlers that want to send a final goodbye to their clients will now have to catch CancelledError. Then again in 3.12/3.13 they currently have no way to tell that the server is being closed, so, yeah, I'm thinking of it more and more as a bugfix that more or less restores the old (3.11 and earlier) behavior. I have to come up with a test case too. :-( |
Thanks so much for pushing a fix! My (non-strong) opinion is that this deserves to be fixed in a minor release, both because it's a regression compared to 3.11, and because it's hard to workaround in downstream code, at least without reimplementing some of stdlib's internals. (For 3.12 it's even worse, because |
I will try to find time to complete the PR. I will have to ask the 3.12 and 3.13 release managers for their opinion on the backports. |
It seems like we lost our momentum... I see there is an unmerged PR to fix this -- any way I can help to move this forward? Thanks again! |
Hi @gvanrossum, hope you have been well!
Here's a crude test that will raise a Edit: Updated to include @pskopnik suggestion!
class TestServer2(unittest.IsolatedAsyncioTestCase):
# ... existing tests
async def test_close_with_hanging_client(self):
# Synchronize server cancellation only after the socket connection is
# accepted and this event is set
conn_event = asyncio.Event()
class Proto(asyncio.Protocol):
def connection_made(self, transport):
conn_event.set()
loop = asyncio.get_running_loop()
srv = await loop.create_server(Proto, socket_helper.HOSTv4, 0)
# Start the server
serve_forever_task = asyncio.create_task(srv.serve_forever())
await asyncio.sleep(0)
# Create a connection to server
addr = srv.sockets[0].getsockname()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(addr)
self.addCleanup(sock.close)
# Send a CancelledError into the server to emulate a Ctrl+C
# KeyboardInterrupt whilst the server is handling a hanging client
await conn_event.wait()
serve_forever_task.cancel()
# Ensure the client is closed within a timeout
async with asyncio.timeout(0.5):
await srv.wait_closed()
self.assertFalse(srv.is_serving()) |
@ordinary-jamie, thanks for putting that together :) |
Bug report
Bug description:
Consider this code, slightly simplifying the documentation's TCPServer example code:
(My code is closer to a
while True: await reader.readline(); ...
, but the above probably suffices as a demonstration)Running this results in a Serving forever, and hitting Ctrl+C, results in a Cancelled by Ctrl+C, and a normal exit.
However, if in another window we
nc 127.0.0.1 8888
, and leave the connection open, Ctrl+C (SIGINT) does nothing, and a second Ctrl+C is required to terminate. (This however breaks out of the asyncio loop by raisingKeyboardInterrupt()
, as documented).So basically clients can prevent the server from cleanly exiting by just keeping their connection open.
This is a regression: this fails with 3.12.5 and 3.13.0-rc1 but works with 3.11.9.
This is because (TTBOMK) of this code in
base_events.py
:I believe this to be related to the
wait_closed()
changes, 5d09d11, 2655369 etc. (Cc @gvanrossum). Related issues #104344 and #113538.Per @gvanrossum in #113538 (comment): "In 3.10 and before, server.wait_closed() was a no-op, unless you called it before server.close(), in a task (asyncio.create_task(server.wait_closed())). The unclosed connection was just getting abandoned."
CancelledError()
is caught here, which spawnswait_closed()
before re-raising the exception. In 3.12+,wait_closed()
... actually waits for the connection to close, as intended. However, while this prevents the reader task from being abandoned, it does not allow neither the callers ofreader.read()
orserve_forever()
to catchCancelledError()
and clean up (such as actually closing the connection, potentially after e.g. a signaling to the client a server close through whatever protocol is implemented here).Basically no user code is executed until the client across the network drops the connection.
As far as I know, it's currently impossible to handle SIGINTs cleanly with clients blocked in a
read()
without messing with deep asyncio/selector internals, which seems like a pretty serious limitation? Have I missed something?CPython versions tested on:
3.11, 3.12, 3.13
Operating systems tested on:
Linux
Linked PRs
The text was updated successfully, but these errors were encountered: