Skip to content

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

ordinary-jamie
Copy link
Contributor

Supersedes #126660 and implements a state machine as suggested by this comment: #126660 (review)

Analysis

image

Addressed issues

This addresses two issues raised in GH-109564:

  1. A transport will first check if a server is closed before attempting to attach. This will prevent raising an AssertionError (changed to a RuntimeError here)
  2. When a connection is accepted by BaseSelectorEventLoop._accept_connection but is not handled in BaseSelectorEventLoop._accept_connection2, then it will fail to correctly construct and raise an "unclosed transport" ResourceWarning on __del__; this will raise an error as it will call Server._detach -> Server._wakeup after close and attempt to access the Server._waiters=None attribute.

These errors can be seen in the new test test_close_before_transport_attach against main:

0:00:00 load avg: 2.05 [1/1] test_asyncio.test_server
Error on transport creation for incoming connection
handle_traceback: Handle created at (most recent call last):
  File "cpython/Lib/unittest/async_case.py", line 120, in _callMaybeAsync
    return self._asyncioRunner.run(
  File "cpython/Lib/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
  File "cpython/Lib/asyncio/base_events.py", line 706, in run_until_complete
    self.run_forever()
  File "cpython/Lib/asyncio/base_events.py", line 677, in run_forever
    self._run_once()
  File "cpython/Lib/asyncio/base_events.py", line 2029, in _run_once
    handle._run()
  File "cpython/Lib/asyncio/events.py", line 98, in _run
    self._context.run(self._callback, *self._args)
  File "cpython/Lib/asyncio/selector_events.py", line 215, in _accept_connection
    self.create_task(accept)
  File "cpython/Lib/asyncio/base_events.py", line 470, in create_task
    task = tasks.Task(coro, loop=self, **kwargs)
protocol: <Mock id='4354243424'>
Traceback (most recent call last):
  File "cpython/Lib/asyncio/selector_events.py", line 234, in _accept_connection2
    transport = self._make_socket_transport(
        conn, protocol, waiter=waiter, extra=extra,
        server=server)
  File "cpython/Lib/asyncio/selector_events.py", line 72, in _make_socket_transport
    return _SelectorSocketTransport(self, sock, protocol, waiter,
                                    extra, server)
  File "cpython/Lib/asyncio/selector_events.py", line 941, in __init__
    super().__init__(loop, sock, protocol, extra, server)
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "cpython/Lib/asyncio/selector_events.py", line 799, in __init__
    self._server._attach(self)
    ~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "cpython/Lib/asyncio/base_events.py", line 297, in _attach
    assert self._sockets is not None
           ^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
Warning -- Unraisable exception
Exception ignored while calling deallocator <function _SelectorTransport.__del__ at 0x1037d1900>:
Traceback (most recent call last):
  File "cpython/Lib/asyncio/selector_events.py", line 881, in __del__
    self._server._detach(self)
  File "cpython/Lib/asyncio/base_events.py", line 303, in _detach
    self._wakeup()
  File "cpython/Lib/asyncio/base_events.py", line 308, in _wakeup
    for waiter in waiters:
TypeError: 'NoneType' object is not iterable
test test_asyncio.test_server failed -- Traceback (most recent call last):
  File "cpython/Lib/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "cpython/Lib/asyncio/base_events.py", line 719, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "cpython/Lib/test/test_asyncio/test_server.py", line 293, in test_close_before_transport_attach
    proto.connection_lost.assert_called()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "cpython/Lib/unittest/mock.py", line 949, in assert_called
    raise AssertionError(msg)
AssertionError: Expected 'connection_lost' to have been called.

0:00:00 load avg: 2.05 [1/1/1] test_asyncio.test_server failed (1 failure)

@ordinary-jamie
Copy link
Contributor Author

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"
Copy link
Contributor Author

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?

Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07129e5

self._wakeup()

def _wakeup(self):
match self._state:
Copy link
Contributor Author

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.

Copy link
Member

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).

Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8e409b7

Comment on lines 797 to 802
if self._server is not None:
self._server._attach(self)
if self._server.is_serving():
self._server._attach(self)
else:
self.abort()
return
Copy link
Contributor Author

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

Copy link
Member

@ZeroIntensity ZeroIntensity left a 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 :)

self._wakeup()

def _wakeup(self):
match self._state:
Copy link
Member

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).

@ZeroIntensity ZeroIntensity self-requested a review March 10, 2025 11:06
@kumaraditya303
Copy link
Contributor

I'll try to review by this week.

Copy link
Member

@ZeroIntensity ZeroIntensity left a 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
Copy link
Member

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.

Copy link
Contributor Author

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"
Copy link
Member

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.

# Server._detach() by the last connected client.
return
else:
raise RuntimeError(f"server {self!r} can only wakeup waiters after closing")
Copy link
Member

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.

Copy link
Contributor Author

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

if self._server.is_serving():
self._server._attach(self)
else:
self.abort()
Copy link
Member

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.

Copy link
Contributor Author

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.

@ordinary-jamie
Copy link
Contributor Author

Sorry for the delay!

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.

Copy link
Member

@ZeroIntensity ZeroIntensity left a 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants