From c3e75d8f53bd3eef1c82d93cfda6ca20006b2021 Mon Sep 17 00:00:00 2001 From: tech-zjf <907572438@qq.com> Date: Fri, 10 Apr 2026 12:41:00 +0800 Subject: [PATCH] Fix admin transport reporting and broaden transport docs --- src/socketio/admin.py | 26 +++++++++++++++------ src/socketio/async_admin.py | 26 +++++++++++++++------ src/socketio/async_client.py | 13 ++++++----- src/socketio/async_server.py | 10 +++++---- src/socketio/async_simple_client.py | 19 ++++++++++------ src/socketio/base_client.py | 5 +++-- src/socketio/base_server.py | 5 +++-- src/socketio/client.py | 13 ++++++----- src/socketio/server.py | 9 ++++---- src/socketio/simple_client.py | 19 ++++++++++------ tests/async/test_admin.py | 35 +++++++++++++++++++++++++++++ tests/common/test_admin.py | 35 +++++++++++++++++++++++++++++ 12 files changed, 165 insertions(+), 50 deletions(-) diff --git a/src/socketio/admin.py b/src/socketio/admin.py index e59da8a..c9f9607 100644 --- a/src/socketio/admin.py +++ b/src/socketio/admin.py @@ -208,7 +208,7 @@ class InstrumentedServer: serialized_socket, datetime.fromtimestamp(t, timezone.utc).isoformat(), ), namespace=self.admin_namespace) - if not self.sio.eio._get_socket(eio_sid).upgraded: + if self._get_transport(eio_sid) == 'polling': self.sio.start_background_task( self._check_for_upgrade, eio_sid, sid, namespace) elif event == 'disconnect': @@ -233,11 +233,12 @@ class InstrumentedServer: for _ in range(5): self.sio.sleep(5) try: - if self.sio.eio._get_socket(eio_sid).upgraded: + transport = self._get_transport(eio_sid) + if transport != 'polling': self.sio.emit('socket_updated', { 'id': sid, 'nsp': namespace, - 'transport': 'websocket', + 'transport': transport, }, namespace=self.admin_namespace) break except KeyError: @@ -345,12 +346,23 @@ class InstrumentedServer: ), namespace=self.admin_namespace) return socket.__send_ping() + def _get_transport(self, eio_sid, default=None): + transport = getattr(self.sio.eio, 'transport', None) + try: + if callable(transport): + return transport(eio_sid) + socket = self.sio.eio._get_socket(eio_sid) + except KeyError: + return default + return 'websocket' if socket.upgraded else 'polling' + def _emit_server_stats(self): start_time = time.time() namespaces = list(self.sio.handlers.keys()) namespaces.sort() while not self.stop_stats_event.is_set(): self.sio.sleep(self.server_stats_interval) + eio_sids = list(self.sio.eio.sockets) self.sio.emit('server_stats', { 'serverId': self.server_id, 'hostname': HOSTNAME, @@ -358,8 +370,9 @@ class InstrumentedServer: 'uptime': time.time() - start_time, 'clientsCount': len(self.sio.eio.sockets), 'pollingClientsCount': len( - [s for s in self.sio.eio.sockets.values() - if not s.upgraded]), + [eio_sid for eio_sid in eio_sids + if self._get_transport( + eio_sid, default=None) == 'polling']), 'aggregatedEvents': self.event_buffer.get_and_clear(), 'namespaces': [{ 'name': nsp, @@ -371,14 +384,13 @@ class InstrumentedServer: def serialize_socket(self, sid, namespace, eio_sid=None): if eio_sid is None: # pragma: no cover eio_sid = self.sio.manager.eio_sid_from_sid(sid) - socket = self.sio.eio._get_socket(eio_sid) environ = self.sio.environ.get(eio_sid, {}) tm = self.sio.manager._timestamps[sid] if sid in \ self.sio.manager._timestamps else 0 return { 'id': sid, 'clientId': eio_sid, - 'transport': 'websocket' if socket.upgraded else 'polling', + 'transport': self._get_transport(eio_sid), 'nsp': namespace, 'data': {}, 'handshake': { diff --git a/src/socketio/async_admin.py b/src/socketio/async_admin.py index 9d0fc9f..ecc6388 100644 --- a/src/socketio/async_admin.py +++ b/src/socketio/async_admin.py @@ -194,7 +194,7 @@ class InstrumentedAsyncServer: serialized_socket, datetime.fromtimestamp(t, timezone.utc).isoformat(), ), namespace=self.admin_namespace) - if not self.sio.eio._get_socket(eio_sid).upgraded: + if self._get_transport(eio_sid) == 'polling': self.sio.start_background_task( self._check_for_upgrade, eio_sid, sid, namespace) elif event == 'disconnect': @@ -220,11 +220,12 @@ class InstrumentedAsyncServer: for _ in range(5): await self.sio.sleep(5) try: - if self.sio.eio._get_socket(eio_sid).upgraded: + transport = self._get_transport(eio_sid) + if transport != 'polling': await self.sio.emit('socket_updated', { 'id': sid, 'nsp': namespace, - 'transport': 'websocket', + 'transport': transport, }, namespace=self.admin_namespace) break except KeyError: @@ -332,12 +333,23 @@ class InstrumentedAsyncServer: ), namespace=self.admin_namespace) return await socket.__send_ping() + def _get_transport(self, eio_sid, default=None): + transport = getattr(self.sio.eio, 'transport', None) + try: + if callable(transport): + return transport(eio_sid) + socket = self.sio.eio._get_socket(eio_sid) + except KeyError: + return default + return 'websocket' if socket.upgraded else 'polling' + async def _emit_server_stats(self): start_time = time.time() namespaces = list(self.sio.handlers.keys()) namespaces.sort() while not self.stop_stats_event.is_set(): await self.sio.sleep(self.server_stats_interval) + eio_sids = list(self.sio.eio.sockets) await self.sio.emit('server_stats', { 'serverId': self.server_id, 'hostname': HOSTNAME, @@ -345,8 +357,9 @@ class InstrumentedAsyncServer: 'uptime': time.time() - start_time, 'clientsCount': len(self.sio.eio.sockets), 'pollingClientsCount': len( - [s for s in self.sio.eio.sockets.values() - if not s.upgraded]), + [eio_sid for eio_sid in eio_sids + if self._get_transport( + eio_sid, default=None) == 'polling']), 'aggregatedEvents': self.event_buffer.get_and_clear(), 'namespaces': [{ 'name': nsp, @@ -362,14 +375,13 @@ class InstrumentedAsyncServer: def serialize_socket(self, sid, namespace, eio_sid=None): if eio_sid is None: # pragma: no cover eio_sid = self.sio.manager.eio_sid_from_sid(sid) - socket = self.sio.eio._get_socket(eio_sid) environ = self.sio.environ.get(eio_sid, {}) tm = self.sio.manager._timestamps[sid] if sid in \ self.sio.manager._timestamps else 0 return { 'id': sid, 'clientId': eio_sid, - 'transport': 'websocket' if socket.upgraded else 'polling', + 'transport': self._get_transport(eio_sid), 'nsp': namespace, 'data': {}, 'handshake': { diff --git a/src/socketio/async_client.py b/src/socketio/async_client.py index cdbcc1b..3116a0c 100644 --- a/src/socketio/async_client.py +++ b/src/socketio/async_client.py @@ -16,7 +16,7 @@ class AsyncClient(base_client.BaseClient): """A Socket.IO client for asyncio. This class implements a fully compliant Socket.IO web client with support - for websocket and long-polling transports. + for Engine.IO transports, including websocket and long-polling. :param reconnection: ``True`` if the client should automatically attempt to reconnect to the server after an interruption, or @@ -91,10 +91,13 @@ class AsyncClient(base_client.BaseClient): more string key/value pairs. If a function is provided, the client will invoke it to obtain the authentication data each time a connection or reconnection is attempted. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. If not - given, the polling transport is connected first, - then an upgrade to websocket is attempted. + :param transports: The list of allowed transports. Commonly + ``'polling'`` and ``'websocket'`` are available. + Additional transports (for example + ``'webtransport'``) may be available when + supported by the underlying Engine.IO client. If + not given, the polling transport is connected + first, then an upgrade to websocket is attempted. :param namespaces: The namespaces to connect as a string or list of strings. If not given, the namespaces that have registered event handlers are connected. diff --git a/src/socketio/async_server.py b/src/socketio/async_server.py index 733d6e9..376fb29 100644 --- a/src/socketio/async_server.py +++ b/src/socketio/async_server.py @@ -18,7 +18,8 @@ class AsyncServer(base_server.BaseServer): """A Socket.IO server for asyncio. This class implements a fully compliant Socket.IO web server with support - for websocket and long-polling transports, compatible with the asyncio + for Engine.IO transports, including websocket and long-polling, compatible + with the asyncio framework. :param client_manager: The client manager instance that will manage the @@ -104,9 +105,10 @@ class AsyncServer(base_server.BaseServer): inactive clients are closed. Set to ``False`` to disable the monitoring task (not recommended). The default is ``True``. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. Defaults to - ``['polling', 'websocket']``. + :param transports: The list of allowed transports. By default this is + ``['polling', 'websocket']``. Additional transports + (for example ``'webtransport'``) may be available when + supported by the underlying Engine.IO server. :param engineio_logger: To enable Engine.IO logging set to ``True`` or pass a logger object to use. To disable logging set to ``False``. The default is ``False``. Note that diff --git a/src/socketio/async_simple_client.py b/src/socketio/async_simple_client.py index a4d0928..dd48be2 100644 --- a/src/socketio/async_simple_client.py +++ b/src/socketio/async_simple_client.py @@ -7,7 +7,8 @@ class AsyncSimpleClient: """A Socket.IO client. This class implements a simple, yet fully compliant Socket.IO web client - with support for websocket and long-polling transports. + with support for Engine.IO transports, including websocket and + long-polling. The positional and keyword arguments given in the constructor are passed to the underlying :func:`socketio.AsyncClient` object. @@ -43,10 +44,13 @@ class AsyncSimpleClient: more string key/value pairs. If a function is provided, the client will invoke it to obtain the authentication data each time a connection or reconnection is attempted. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. If not - given, the polling transport is connected first, - then an upgrade to websocket is attempted. + :param transports: The list of allowed transports. Commonly + ``'polling'`` and ``'websocket'`` are available. + Additional transports (for example + ``'webtransport'``) may be available when + supported by the underlying Engine.IO client. If + not given, the polling transport is connected + first, then an upgrade to websocket is attempted. :param namespace: The namespace to connect to as a string. If not given, the default namespace ``/`` is used. :param socketio_path: The endpoint where the Socket.IO server is @@ -102,8 +106,9 @@ class AsyncSimpleClient: def transport(self): """The name of the transport currently in use. - The transport is returned as a string and can be one of ``polling`` - and ``websocket``. + Common values are ``'polling'`` and ``'websocket'``. Additional + values may be possible when supported by the underlying Engine.IO + implementation. """ return self.client.transport() if self.client else '' diff --git a/src/socketio/base_client.py b/src/socketio/base_client.py index 0232dca..874ac54 100644 --- a/src/socketio/base_client.py +++ b/src/socketio/base_client.py @@ -229,8 +229,9 @@ class BaseClient: def transport(self): """Return the name of the transport used by the client. - The two possible values returned by this function are ``'polling'`` - and ``'websocket'``. + Common values returned by this function are ``'polling'`` and + ``'websocket'``. Additional values may be possible when supported by + the underlying Engine.IO implementation. """ return self.eio.transport() diff --git a/src/socketio/base_server.py b/src/socketio/base_server.py index d134eba..28a1565 100644 --- a/src/socketio/base_server.py +++ b/src/socketio/base_server.py @@ -193,8 +193,9 @@ class BaseServer: def transport(self, sid, namespace=None): """Return the name of the transport used by the client. - The two possible values returned by this function are ``'polling'`` - and ``'websocket'``. + Common values returned by this function are ``'polling'`` and + ``'websocket'``. Additional values may be possible when supported by + the underlying Engine.IO implementation. :param sid: The session of the client. :param namespace: The Socket.IO namespace. If this argument is omitted diff --git a/src/socketio/client.py b/src/socketio/client.py index 8f38316..e8661f1 100644 --- a/src/socketio/client.py +++ b/src/socketio/client.py @@ -11,7 +11,7 @@ class Client(base_client.BaseClient): """A Socket.IO client. This class implements a fully compliant Socket.IO web client with support - for websocket and long-polling transports. + for Engine.IO transports, including websocket and long-polling. :param reconnection: ``True`` if the client should automatically attempt to reconnect to the server after an interruption, or @@ -90,10 +90,13 @@ class Client(base_client.BaseClient): more string key/value pairs. If a function is provided, the client will invoke it to obtain the authentication data each time a connection or reconnection is attempted. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. If not - given, the polling transport is connected first, - then an upgrade to websocket is attempted. + :param transports: The list of allowed transports. Commonly + ``'polling'`` and ``'websocket'`` are available. + Additional transports (for example + ``'webtransport'``) may be available when + supported by the underlying Engine.IO client. If + not given, the polling transport is connected + first, then an upgrade to websocket is attempted. :param namespaces: The namespaces to connect as a string or list of strings. If not given, the namespaces that have registered event handlers are connected. diff --git a/src/socketio/server.py b/src/socketio/server.py index 6a1a202..9776f6c 100644 --- a/src/socketio/server.py +++ b/src/socketio/server.py @@ -13,7 +13,7 @@ class Server(base_server.BaseServer): """A Socket.IO server. This class implements a fully compliant Socket.IO web server with support - for websocket and long-polling transports. + for Engine.IO transports, including websocket and long-polling. :param client_manager: The client manager instance that will manage the client list. When this is omitted, the client list @@ -107,9 +107,10 @@ class Server(base_server.BaseServer): inactive clients are closed. Set to ``False`` to disable the monitoring task (not recommended). The default is ``True``. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. Defaults to - ``['polling', 'websocket']``. + :param transports: The list of allowed transports. By default this is + ``['polling', 'websocket']``. Additional transports + (for example ``'webtransport'``) may be available when + supported by the underlying Engine.IO server. :param engineio_logger: To enable Engine.IO logging set to ``True`` or pass a logger object to use. To disable logging set to ``False``. The default is ``False``. Note that diff --git a/src/socketio/simple_client.py b/src/socketio/simple_client.py index 7e1a8a5..c0c6241 100644 --- a/src/socketio/simple_client.py +++ b/src/socketio/simple_client.py @@ -7,7 +7,8 @@ class SimpleClient: """A Socket.IO client. This class implements a simple, yet fully compliant Socket.IO web client - with support for websocket and long-polling transports. + with support for Engine.IO transports, including websocket and + long-polling. The positional and keyword arguments given in the constructor are passed to the underlying :func:`socketio.Client` object. @@ -42,10 +43,13 @@ class SimpleClient: more string key/value pairs. If a function is provided, the client will invoke it to obtain the authentication data each time a connection or reconnection is attempted. - :param transports: The list of allowed transports. Valid transports - are ``'polling'`` and ``'websocket'``. If not - given, the polling transport is connected first, - then an upgrade to websocket is attempted. + :param transports: The list of allowed transports. Commonly + ``'polling'`` and ``'websocket'`` are available. + Additional transports (for example + ``'webtransport'``) may be available when + supported by the underlying Engine.IO client. If + not given, the polling transport is connected + first, then an upgrade to websocket is attempted. :param namespace: The namespace to connect to as a string. If not given, the default namespace ``/`` is used. :param socketio_path: The endpoint where the Socket.IO server is @@ -100,8 +104,9 @@ class SimpleClient: def transport(self): """The name of the transport currently in use. - The transport is returned as a string and can be one of ``polling`` - and ``websocket``. + Common values are ``'polling'`` and ``'websocket'``. Additional + values may be possible when supported by the underlying Engine.IO + implementation. """ return self.client.transport() if self.client else '' diff --git a/tests/async/test_admin.py b/tests/async/test_admin.py index 62806cb..99e2dcd 100644 --- a/tests/async/test_admin.py +++ b/tests/async/test_admin.py @@ -249,6 +249,41 @@ class TestAsyncAdmin: elif socket['id'] == sid3: assert socket['rooms'] == [sid3] + @with_instrumented_server() + def test_admin_websocket_only_client_transport(self): + with socketio.SimpleClient(reconnection=False) as admin_client: + admin_client.connect( + 'http://localhost:8900', namespace='/admin', + transports=['websocket']) + events = self._expect({'config': 1, 'all_sockets': 1, + 'server_stats': 2}, admin_client) + + assert events['all_sockets'][0]['transport'] == 'websocket' + assert events['server_stats']['pollingClientsCount'] == 0 + + @with_instrumented_server() + def test_admin_polling_count_ignores_websocket_only_clients(self): + with socketio.SimpleClient(reconnection=False) as ws_client, \ + socketio.SimpleClient(reconnection=False) as polling_client, \ + socketio.SimpleClient(reconnection=False) as admin_client: + ws_client.connect('http://localhost:8900', + transports=['websocket']) + + saved_check_for_upgrade = self.isvr._check_for_upgrade + self.isvr._check_for_upgrade = mock.AsyncMock() + polling_client.connect('http://localhost:8900', + transports=['polling']) + self.isvr._check_for_upgrade = saved_check_for_upgrade + + admin_client.connect( + 'http://localhost:8900', namespace='/admin', + transports=['websocket']) + events = self._expect({'config': 1, 'all_sockets': 1, + 'server_stats': 2}, admin_client) + + assert events['server_stats']['clientsCount'] == 3 + assert events['server_stats']['pollingClientsCount'] == 1 + @with_instrumented_server(mode='production', read_only=True) def test_admin_connect_production(self): with socketio.SimpleClient(reconnection=False) as admin_client: diff --git a/tests/common/test_admin.py b/tests/common/test_admin.py index a384789..4fa65d8 100644 --- a/tests/common/test_admin.py +++ b/tests/common/test_admin.py @@ -225,6 +225,41 @@ class TestAdmin: elif socket['id'] == sid3: assert socket['rooms'] == [sid3] + @with_instrumented_server() + def test_admin_websocket_only_client_transport(self): + with socketio.SimpleClient(reconnection=False) as admin_client: + admin_client.connect( + 'http://localhost:8900', namespace='/admin', + transports=['websocket']) + events = self._expect({'config': 1, 'all_sockets': 1, + 'server_stats': 2}, admin_client) + + assert events['all_sockets'][0]['transport'] == 'websocket' + assert events['server_stats']['pollingClientsCount'] == 0 + + @with_instrumented_server() + def test_admin_polling_count_ignores_websocket_only_clients(self): + with socketio.SimpleClient(reconnection=False) as ws_client, \ + socketio.SimpleClient(reconnection=False) as polling_client, \ + socketio.SimpleClient(reconnection=False) as admin_client: + ws_client.connect('http://localhost:8900', + transports=['websocket']) + + saved_check_for_upgrade = self.isvr._check_for_upgrade + self.isvr._check_for_upgrade = mock.MagicMock() + polling_client.connect('http://localhost:8900', + transports=['polling']) + self.isvr._check_for_upgrade = saved_check_for_upgrade + + admin_client.connect( + 'http://localhost:8900', namespace='/admin', + transports=['websocket']) + events = self._expect({'config': 1, 'all_sockets': 1, + 'server_stats': 2}, admin_client) + + assert events['server_stats']['clientsCount'] == 3 + assert events['server_stats']['pollingClientsCount'] == 1 + @with_instrumented_server(mode='production', read_only=True) def test_admin_connect_production(self): with socketio.SimpleClient(reconnection=False) as admin_client: