-
-
Notifications
You must be signed in to change notification settings - Fork 33.2k
GH-109564: add asyncio.Server state machine #131009
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
base: main
Are you sure you want to change the base?
GH-109564: add asyncio.Server state machine #131009
Conversation
|
Hi @1st1 and @ZeroIntensity ! Very sorry for the major delay on this! I had to go on leave for personal reasons. I've created a new PR for #126660 which implements a state machine as @1st1 suggested. This change is a bit bigger than what I'm used to (and qualified for 😅 ) so please let me know if there needs to be any changes. There was some areas I wasn't quite sure about, which I will mark up with comments |
| INITIALIZED = "initialized" | ||
| SERVING = "serving" | ||
| CLOSED = "closed" | ||
| SHUTDOWN = "shutdown" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about the name SHUTDOWN here, but needed something more "definitive" than closed.
If we do use SHUTDOWN should I also rename Server._wakeup -> Server._shutdown?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do use SHUTDOWN should I also rename Server._wakeup -> Server._shutdown?
I think that makes sense.
I'm more worried about INITIALIZED here; nothing is really initialized, other than the Python object, which isn't particularly useful knowledge. Let's call it something like NOT_SERVING or NOT_YET_STARTED.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 07129e5
Lib/asyncio/base_events.py
Outdated
| self._wakeup() | ||
|
|
||
| def _wakeup(self): | ||
| match self._state: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if PEP 636 match-case statements are permitted; can change to if-else statement if needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're permitted, but it's not particularly useful to use match here (no pattern is being matched).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's stick to if else as i am not so used to match case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 8e409b7
Lib/asyncio/selector_events.py
Outdated
| if self._server is not None: | ||
| self._server._attach(self) | ||
| if self._server.is_serving(): | ||
| self._server._attach(self) | ||
| else: | ||
| self.abort() | ||
| return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a functional change over main.. In my head, if a transport was constructed with server != None, then the transport should not be created/serviced if its corresponding server was shutdown.
Used transport.abort over transport.close to force clear the buffer, since no server exists to service that.
A return statement was added to the else block to prevent the transport getting tracked in loop._transports
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking a lot better! I'll do a thorough review sometime soon :)
Lib/asyncio/base_events.py
Outdated
| self._wakeup() | ||
|
|
||
| def _wakeup(self): | ||
| match self._state: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're permitted, but it's not particularly useful to use match here (no pattern is being matched).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for the delay!
| if self._state == _ServerState.CLOSED or self._state == _ServerState.SHUTDOWN: | ||
| return | ||
| else: | ||
| self._state = _ServerState.CLOSED |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if something magically goes wrong after this has been set? Is the server still alive while thinking it's closed? It might be worth adding a try/except to undo this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah great point! Although I'm not sure how that would look from a user's point of view. Should we give them a warning to let them know they must try to close the server again? Alternatively, should we expose something like a force kwarg/flag?
I pushed an initial fix with a simple try/except to recover the previous state on fail in 48a3c0d
| INITIALIZED = "initialized" | ||
| SERVING = "serving" | ||
| CLOSED = "closed" | ||
| SHUTDOWN = "shutdown" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do use SHUTDOWN should I also rename Server._wakeup -> Server._shutdown?
I think that makes sense.
I'm more worried about INITIALIZED here; nothing is really initialized, other than the Python object, which isn't particularly useful knowledge. Let's call it something like NOT_SERVING or NOT_YET_STARTED.
Lib/asyncio/base_events.py
Outdated
| # Server._detach() by the last connected client. | ||
| return | ||
| else: | ||
| raise RuntimeError(f"server {self!r} can only wakeup waiters after closing") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This error message isn't making much sense to me. We'll never reach here "after closing," right? This can only be triggered when the state is SERVING or INITIALIZED.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry this is bad wording, I was trying to say that the Server must be closed before it can be shutdown (i.e. we cannot shutdown from the SERVING state, nor the INITIALIZED/NOT_STARTED state)
Made a fix in: 7f3481b
Lib/asyncio/proactor_events.py
Outdated
| if self._server.is_serving(): | ||
| self._server._attach(self) | ||
| else: | ||
| self.abort() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why abort the whole transport? This is seemingly a change in behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yup sorry, my misunderstanding! Addressed in 5832655
I commented here my original thinking:
This is a functional change over main.. In my head, if a transport was constructed with server != None, then the transport should not be created/serviced if its corresponding server was shutdown.
But I re-read the docs for Server.close and see I was thinking about it incorrectly:
https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.Server.close
The sockets that represent existing incoming client connections are left open.
Co-authored-by: Peter Bierma <[email protected]>
Co-authored-by: Peter Bierma <[email protected]>
No worries; I'm really grateful for the review! Sorry for my delay on responding too, had a busy week and just got around to this 🙏 I've tried to address all your comments in each of their threads and linked the respective commits. Please let me know if there's anything else that needs addressing/fixing. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm really happy to report that I can confirm this fixes the original reproducer reported in #109564, without ResourceWarning spam.
Thanks, @ordinary-jamie!
Lib/asyncio/base_events.py
Outdated
| return | ||
| self._serving = True | ||
| else: | ||
| raise RuntimeError(f'server {self!r} was already started and then closed') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the existing message was clear enough, it would avoid changing tests too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 6c8d3df :)
Co-authored-by: Kumar Aditya <[email protected]>
Co-authored-by: Kumar Aditya <[email protected]>
| await asyncio.sleep(0) | ||
| self.assertTrue(task.done()) | ||
|
|
||
| with self.assertRaisesRegex(RuntimeError, r'is closed'): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add new tests for the race mentioned, it would be nice to check if this addresses #123720 and similar issue and add regression tests for them
Supersedes #126660 and implements a state machine as suggested by this comment: #126660 (review)
Analysis
Addressed issues
This addresses two issues raised in GH-109564:
AssertionError(changed to aRuntimeErrorhere)BaseSelectorEventLoop._accept_connectionbut is not handled inBaseSelectorEventLoop._accept_connection2, then it will fail to correctly construct and raise an"unclosed transport"ResourceWarningon__del__; this will raise an error as it will callServer._detach->Server._wakeupafter close and attempt to access theServer._waiters=Noneattribute.These errors can be seen in the new test
test_close_before_transport_attachagainstmain: