jsocket 1.9.3__py3-none-any.whl → 1.9.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jsocket/__init__.py +72 -71
- jsocket/_version.py +3 -0
- jsocket/jsocket_base.py +214 -192
- jsocket/tserver.py +357 -189
- jsocket-1.9.6.dist-info/METADATA +166 -0
- jsocket-1.9.6.dist-info/RECORD +9 -0
- {jsocket-1.9.3.dist-info → jsocket-1.9.6.dist-info}/WHEEL +1 -1
- jsocket-1.9.6.dist-info/licenses/LICENSE +201 -0
- jsocket-1.9.3.dist-info/LICENSE +0 -21
- jsocket-1.9.3.dist-info/METADATA +0 -24
- jsocket-1.9.3.dist-info/RECORD +0 -8
- {jsocket-1.9.3.dist-info → jsocket-1.9.6.dist-info}/top_level.txt +0 -0
jsocket/tserver.py
CHANGED
|
@@ -1,204 +1,372 @@
|
|
|
1
1
|
""" @namespace tserver
|
|
2
|
-
|
|
2
|
+
Contains ThreadedServer, ServerFactoryThread and ServerFactory implementations.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__author__
|
|
6
|
-
__email__
|
|
5
|
+
__author__ = "Christopher Piekarski"
|
|
6
|
+
__email__ = "chris@cpiekarski.com"
|
|
7
7
|
__copyright__= """
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Christopher Piekarski <chris@cpiekarski.com>
|
|
8
|
+
Copyright (C) 2011 by
|
|
9
|
+
Christopher Piekarski <chris@cpiekarski.com>
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
(at your option) any later version.
|
|
11
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
12
|
+
you may not use this file except in compliance with the License.
|
|
13
|
+
You may obtain a copy of the License at
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
19
|
-
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
20
|
-
GNU General Public License for more details.
|
|
15
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
Unless required by applicable law or agreed to in writing, software
|
|
18
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
19
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
20
|
+
See the License for the specific language governing permissions and
|
|
21
|
+
limitations under the License.
|
|
22
|
+
"""
|
|
27
23
|
import threading
|
|
28
24
|
import socket
|
|
29
25
|
import time
|
|
30
26
|
import logging
|
|
27
|
+
import abc
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from jsocket import jsocket_base
|
|
31
|
+
from ._version import __version__
|
|
31
32
|
|
|
32
33
|
logger = logging.getLogger("jsocket.tserver")
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
35
|
+
|
|
36
|
+
def _response_summary(resp_obj) -> str:
|
|
37
|
+
if isinstance(resp_obj, dict):
|
|
38
|
+
return f"type=dict keys={len(resp_obj)}"
|
|
39
|
+
if isinstance(resp_obj, (list, tuple)):
|
|
40
|
+
return f"type={type(resp_obj).__name__} items={len(resp_obj)}"
|
|
41
|
+
return f"type={type(resp_obj).__name__}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_client_id(addr) -> str:
|
|
45
|
+
try:
|
|
46
|
+
host, port = addr[0], addr[1]
|
|
47
|
+
if ":" in host:
|
|
48
|
+
return f"[{host}]:{port}"
|
|
49
|
+
return f"{host}:{port}"
|
|
50
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.ABCMeta):
|
|
55
|
+
"""Single-threaded server that accepts one connection and processes messages in its thread."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, **kwargs):
|
|
58
|
+
threading.Thread.__init__(self)
|
|
59
|
+
jsocket_base.JsonServer.__init__(self, **kwargs)
|
|
60
|
+
self._is_alive = False
|
|
61
|
+
self._stats_lock = threading.Lock()
|
|
62
|
+
self._client_started_at = None
|
|
63
|
+
self._client_id = None
|
|
64
|
+
|
|
65
|
+
@abc.abstractmethod
|
|
66
|
+
def _process_message(self, obj) -> Optional[dict]:
|
|
67
|
+
"""Pure Virtual Method
|
|
68
|
+
|
|
69
|
+
This method is called every time a JSON object is received from a client
|
|
70
|
+
|
|
71
|
+
@param obj JSON "key: value" object received from client
|
|
72
|
+
@retval None or a response object
|
|
73
|
+
"""
|
|
74
|
+
# Return None in the base class to satisfy linters; subclasses should override.
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _record_client_start(self):
|
|
78
|
+
addr = getattr(self, "_last_client_addr", None)
|
|
79
|
+
if addr is None:
|
|
80
|
+
try:
|
|
81
|
+
addr = self.conn.getpeername()
|
|
82
|
+
except OSError:
|
|
83
|
+
addr = None
|
|
84
|
+
with self._stats_lock:
|
|
85
|
+
self._client_started_at = time.monotonic()
|
|
86
|
+
self._client_id = _format_client_id(addr)
|
|
87
|
+
|
|
88
|
+
def _clear_client_stats(self):
|
|
89
|
+
with self._stats_lock:
|
|
90
|
+
self._client_started_at = None
|
|
91
|
+
self._client_id = None
|
|
92
|
+
|
|
93
|
+
def get_client_stats(self) -> dict:
|
|
94
|
+
"""Return connected client count and per-client durations in seconds."""
|
|
95
|
+
with self._stats_lock:
|
|
96
|
+
started_at = self._client_started_at
|
|
97
|
+
client_id = self._client_id
|
|
98
|
+
if not started_at or not client_id or not self.connected:
|
|
99
|
+
return {"connected_clients": 0, "clients": {}}
|
|
100
|
+
duration = time.monotonic() - started_at
|
|
101
|
+
return {"connected_clients": 1, "clients": {client_id: duration}}
|
|
102
|
+
|
|
103
|
+
def _accept_client(self) -> bool:
|
|
104
|
+
"""Accept an incoming connection; return True when a client connects."""
|
|
105
|
+
try:
|
|
106
|
+
self.accept_connection()
|
|
107
|
+
except socket.timeout as e:
|
|
108
|
+
logger.debug("accept timeout on %s:%s: %s", self.address, self.port, e)
|
|
109
|
+
return False
|
|
110
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
111
|
+
# Avoid noisy error logs during normal shutdown/sequencing
|
|
112
|
+
if self._is_alive:
|
|
113
|
+
logger.debug("accept error on %s:%s: %s", self.address, self.port, e)
|
|
114
|
+
return False
|
|
115
|
+
logger.debug("server stopping; accept loop exiting (%s:%s)", self.address, self.port)
|
|
116
|
+
self._is_alive = False
|
|
117
|
+
return False
|
|
118
|
+
self._record_client_start()
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def _handle_client_messages(self):
|
|
122
|
+
"""Read, process, and respond to client messages until disconnect."""
|
|
123
|
+
while self._is_alive:
|
|
124
|
+
try:
|
|
125
|
+
obj = self.read_obj()
|
|
126
|
+
resp_obj = self._process_message(obj)
|
|
127
|
+
if resp_obj is not None:
|
|
128
|
+
logger.debug("sending response (%s)", _response_summary(resp_obj))
|
|
129
|
+
self.send_obj(resp_obj)
|
|
130
|
+
except socket.timeout as e:
|
|
131
|
+
logger.debug("read timeout waiting for client data: %s", e)
|
|
132
|
+
continue
|
|
133
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
134
|
+
# Treat client disconnects as normal; keep logs at info/debug
|
|
135
|
+
msg = str(e)
|
|
136
|
+
if isinstance(e, RuntimeError) and 'socket connection broken' in msg:
|
|
137
|
+
logger.info("client connection broken, closing connection")
|
|
138
|
+
else:
|
|
139
|
+
logger.debug("handler error (%s): %s", type(e).__name__, e)
|
|
140
|
+
self._close_connection()
|
|
141
|
+
break
|
|
142
|
+
self._clear_client_stats()
|
|
143
|
+
|
|
144
|
+
def run(self):
|
|
145
|
+
# Ensure the run loop is active even when run() is invoked directly
|
|
146
|
+
# (tests may call run() in a separate thread without invoking start()).
|
|
147
|
+
if not self._is_alive:
|
|
148
|
+
self._is_alive = True
|
|
149
|
+
while self._is_alive:
|
|
150
|
+
if not self._accept_client():
|
|
151
|
+
continue
|
|
152
|
+
self._handle_client_messages()
|
|
153
|
+
# Ensure sockets are cleaned up when the server stops
|
|
154
|
+
try:
|
|
155
|
+
self.close()
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def start(self):
|
|
160
|
+
""" Starts the threaded server.
|
|
161
|
+
The newly living know nothing of the dead
|
|
162
|
+
|
|
163
|
+
@retval None
|
|
164
|
+
"""
|
|
165
|
+
self._is_alive = True
|
|
166
|
+
super().start()
|
|
167
|
+
logger.debug("Threaded Server started on %s:%s", self.address, self.port)
|
|
168
|
+
|
|
169
|
+
def stop(self):
|
|
170
|
+
""" Stops the threaded server.
|
|
171
|
+
The life of the dead is in the memory of the living
|
|
172
|
+
|
|
173
|
+
@retval None
|
|
174
|
+
"""
|
|
175
|
+
self._is_alive = False
|
|
176
|
+
self._clear_client_stats()
|
|
177
|
+
logger.debug("Threaded Server stopped on %s:%s", self.address, self.port)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=abc.ABCMeta):
|
|
181
|
+
"""Per-connection worker thread used by ServerFactory."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, **kwargs):
|
|
184
|
+
threading.Thread.__init__(self, **kwargs)
|
|
185
|
+
self.socket = None
|
|
186
|
+
self.conn = None
|
|
187
|
+
jsocket_base.JsonSocket.__init__(self, **kwargs)
|
|
188
|
+
self._is_alive = False
|
|
189
|
+
self._client_started_at = None
|
|
190
|
+
self._client_id = None
|
|
191
|
+
|
|
192
|
+
def swap_socket(self, new_sock):
|
|
193
|
+
""" Swaps the existing socket with a new one. Useful for setting socket after a new connection.
|
|
194
|
+
|
|
195
|
+
@param new_sock socket to replace the existing default jsocket.JsonSocket object
|
|
196
|
+
@retval None
|
|
197
|
+
"""
|
|
198
|
+
self.socket = new_sock
|
|
199
|
+
self.conn = self.socket
|
|
200
|
+
try:
|
|
201
|
+
addr = new_sock.getpeername()
|
|
202
|
+
except OSError:
|
|
203
|
+
addr = None
|
|
204
|
+
self._client_id = _format_client_id(addr)
|
|
205
|
+
self._client_started_at = time.monotonic()
|
|
206
|
+
|
|
207
|
+
def run(self):
|
|
208
|
+
""" Should exit when client closes socket conn.
|
|
209
|
+
Can force an exit with force_stop.
|
|
210
|
+
"""
|
|
211
|
+
while self._is_alive:
|
|
212
|
+
try:
|
|
213
|
+
obj = self.read_obj()
|
|
214
|
+
resp_obj = self._process_message(obj)
|
|
215
|
+
if resp_obj is not None:
|
|
216
|
+
logger.debug("sending response (%s)", _response_summary(resp_obj))
|
|
217
|
+
self.send_obj(resp_obj)
|
|
218
|
+
except socket.timeout as e:
|
|
219
|
+
logger.debug("worker read timeout waiting for data: %s", e)
|
|
220
|
+
continue
|
|
221
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
222
|
+
logger.info("client connection broken, closing connection: %s", e)
|
|
223
|
+
self._is_alive = False
|
|
224
|
+
break
|
|
225
|
+
self._close_connection()
|
|
226
|
+
if hasattr(self, "socket"):
|
|
227
|
+
self._close_socket()
|
|
228
|
+
|
|
229
|
+
@abc.abstractmethod
|
|
230
|
+
def _process_message(self, obj) -> Optional[dict]:
|
|
231
|
+
"""Pure Virtual Method - Implementer must define protocol
|
|
232
|
+
|
|
233
|
+
@param obj JSON "key: value" object received from client
|
|
234
|
+
@retval None or a response object
|
|
235
|
+
"""
|
|
236
|
+
# Return None in the base class to satisfy linters; subclasses should override.
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def start(self):
|
|
240
|
+
""" Starts the factory thread.
|
|
241
|
+
The newly living know nothing of the dead
|
|
242
|
+
|
|
243
|
+
@retval None
|
|
244
|
+
"""
|
|
245
|
+
self._is_alive = True
|
|
246
|
+
super().start()
|
|
247
|
+
logger.debug("ServerFactoryThread started (%s)", self.name)
|
|
248
|
+
|
|
249
|
+
def force_stop(self):
|
|
250
|
+
""" Force stops the factory thread.
|
|
251
|
+
Should exit when client socket is closed under normal conditions.
|
|
252
|
+
The life of the dead is in the memory of the living.
|
|
253
|
+
|
|
254
|
+
@retval None
|
|
255
|
+
"""
|
|
256
|
+
self._is_alive = False
|
|
257
|
+
logger.debug("ServerFactoryThread stopped (%s)", self.name)
|
|
258
|
+
|
|
259
|
+
|
|
153
260
|
class ServerFactory(ThreadedServer):
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
261
|
+
"""Accepts clients and spawns a ServerFactoryThread per connection."""
|
|
262
|
+
def __init__(self, server_thread, **kwargs):
|
|
263
|
+
ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port'])
|
|
264
|
+
if not issubclass(server_thread, ServerFactoryThread):
|
|
265
|
+
raise TypeError("serverThread not of type", ServerFactoryThread)
|
|
266
|
+
self._thread_type = server_thread
|
|
267
|
+
self._threads = []
|
|
268
|
+
self._threads_lock = threading.Lock()
|
|
269
|
+
self._thread_args = kwargs
|
|
270
|
+
self._thread_args.pop('address', None)
|
|
271
|
+
self._thread_args.pop('port', None)
|
|
272
|
+
|
|
273
|
+
def _process_message(self, obj) -> Optional[dict]:
|
|
274
|
+
"""ServerFactory does not process messages itself."""
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def run(self):
|
|
278
|
+
# Ensure the run loop is active even when run() is invoked directly
|
|
279
|
+
# (tests may call run() in a separate thread without invoking start()).
|
|
280
|
+
if not self._is_alive:
|
|
281
|
+
self._is_alive = True
|
|
282
|
+
while self._is_alive:
|
|
283
|
+
tmp = self._thread_type(**self._thread_args)
|
|
284
|
+
self._purge_threads()
|
|
285
|
+
while not self.connected and self._is_alive:
|
|
286
|
+
try:
|
|
287
|
+
self.accept_connection()
|
|
288
|
+
except socket.timeout as e:
|
|
289
|
+
logger.debug("factory accept timeout on %s:%s: %s", self.address, self.port, e)
|
|
290
|
+
continue
|
|
291
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
292
|
+
logger.exception("accept error: %s", e)
|
|
293
|
+
continue
|
|
294
|
+
else:
|
|
295
|
+
# Hand off the accepted connection to the worker
|
|
296
|
+
accepted_conn = self.conn
|
|
297
|
+
# Reset server connection reference so we can accept again
|
|
298
|
+
self._reset_connection_ref()
|
|
299
|
+
if not self._is_alive:
|
|
300
|
+
# Server is stopping; close the accepted connection without spawning a worker.
|
|
301
|
+
try:
|
|
302
|
+
accepted_conn.shutdown(socket.SHUT_RDWR)
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
try:
|
|
306
|
+
accepted_conn.close()
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
break
|
|
310
|
+
tmp.swap_socket(accepted_conn)
|
|
311
|
+
tmp.start()
|
|
312
|
+
with self._threads_lock:
|
|
313
|
+
self._threads.append(tmp)
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
self._wait_to_exit()
|
|
317
|
+
self.close()
|
|
318
|
+
|
|
319
|
+
def stop_all(self):
|
|
320
|
+
"""Stop and join all active worker threads."""
|
|
321
|
+
while True:
|
|
322
|
+
with self._threads_lock:
|
|
323
|
+
threads = [t for t in self._threads if t.is_alive()]
|
|
324
|
+
if not threads:
|
|
325
|
+
break
|
|
326
|
+
for t in threads:
|
|
327
|
+
t.force_stop()
|
|
328
|
+
t.join()
|
|
329
|
+
self._purge_threads()
|
|
330
|
+
|
|
331
|
+
def _purge_threads(self):
|
|
332
|
+
# Rebuild list to avoid mutating while iterating
|
|
333
|
+
with self._threads_lock:
|
|
334
|
+
self._threads = [t for t in self._threads if t.is_alive()]
|
|
335
|
+
|
|
336
|
+
def stop(self):
|
|
337
|
+
# Stop accepting and stop all workers
|
|
338
|
+
self._is_alive = False
|
|
339
|
+
try:
|
|
340
|
+
self.stop_all()
|
|
341
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
342
|
+
pass
|
|
343
|
+
logger.debug("ServerFactory stopped on %s:%s", self.address, self.port)
|
|
344
|
+
|
|
345
|
+
def _wait_to_exit(self):
|
|
346
|
+
"""Block until all worker threads have finished."""
|
|
347
|
+
while self._get_num_of_active_threads():
|
|
348
|
+
time.sleep(0.2)
|
|
349
|
+
|
|
350
|
+
def _get_num_of_active_threads(self):
|
|
351
|
+
with self._threads_lock:
|
|
352
|
+
threads = list(self._threads)
|
|
353
|
+
return len([True for x in threads if x.is_alive()])
|
|
354
|
+
|
|
355
|
+
def get_client_stats(self) -> dict:
|
|
356
|
+
"""Return connected client count and per-client durations in seconds."""
|
|
357
|
+
with self._threads_lock:
|
|
358
|
+
threads = list(self._threads)
|
|
359
|
+
now = time.monotonic()
|
|
360
|
+
clients = {}
|
|
361
|
+
active = 0
|
|
362
|
+
for t in threads:
|
|
363
|
+
if not t.is_alive():
|
|
364
|
+
continue
|
|
365
|
+
active += 1
|
|
366
|
+
started_at = getattr(t, "_client_started_at", None)
|
|
367
|
+
client_id = getattr(t, "_client_id", None) or f"thread-{t.name}"
|
|
368
|
+
duration = now - started_at if started_at else 0.0
|
|
369
|
+
clients[client_id] = duration
|
|
370
|
+
return {"connected_clients": active, "clients": clients}
|
|
371
|
+
|
|
372
|
+
active = property(_get_num_of_active_threads, doc="number of active threads")
|