esa-livefeed 0.0.1__tar.gz
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.
- esa_livefeed-0.0.1/LICENSE +21 -0
- esa_livefeed-0.0.1/PKG-INFO +57 -0
- esa_livefeed-0.0.1/README.md +39 -0
- esa_livefeed-0.0.1/pyproject.toml +40 -0
- esa_livefeed-0.0.1/setup.cfg +4 -0
- esa_livefeed-0.0.1/src/esa_livefeed/__init__.py +19 -0
- esa_livefeed-0.0.1/src/esa_livefeed/livefeed_client.py +480 -0
- esa_livefeed-0.0.1/src/esa_livefeed/livefeed_client_settings.py +55 -0
- esa_livefeed-0.0.1/src/esa_livefeed/livefeed_server.py +388 -0
- esa_livefeed-0.0.1/src/esa_livefeed/livefeed_server_settings.py +54 -0
- esa_livefeed-0.0.1/src/esa_livefeed/livefeed_session.py +488 -0
- esa_livefeed-0.0.1/src/esa_livefeed/protocol.py +122 -0
- esa_livefeed-0.0.1/src/esa_livefeed.egg-info/PKG-INFO +57 -0
- esa_livefeed-0.0.1/src/esa_livefeed.egg-info/SOURCES.txt +15 -0
- esa_livefeed-0.0.1/src/esa_livefeed.egg-info/dependency_links.txt +1 -0
- esa_livefeed-0.0.1/src/esa_livefeed.egg-info/requires.txt +1 -0
- esa_livefeed-0.0.1/src/esa_livefeed.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
© 2026 Ely-Sandra LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: esa-livefeed
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Low-latency live feed server and managed client over raw TCP with JSONL framing, heartbeats, liveness detection, and unbounded per-client backpressure.
|
|
5
|
+
Author-email: Ely-Sandra <info@ely-sandra.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://esa-livefeed.readthedocs.io/
|
|
8
|
+
Project-URL: Homepage, https://github.com/ely-sandra/esa-livefeed
|
|
9
|
+
Project-URL: Repository, https://github.com/ely-sandra/esa-livefeed.git
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: hanaro>=2.0.1
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# esa-livefeed
|
|
20
|
+
|
|
21
|
+
Low-latency live feed server and managed client over raw TCP with JSONL framing,
|
|
22
|
+
client-driven heartbeats, bidirectional liveness detection, and unbounded
|
|
23
|
+
per-client backpressure. Pure Python stdlib; zero runtime dependencies.
|
|
24
|
+
|
|
25
|
+
- `LivefeedServer` / `LivefeedServerSettings` — accepts clients (one
|
|
26
|
+
`LivefeedSession` per client: dedicated sender/receiver threads and an
|
|
27
|
+
unbounded outbound queue), fans out pre-encoded frames via
|
|
28
|
+
`publish(data: bytes)`, exposes connected clients via the `clients`
|
|
29
|
+
property, delivers client data frames to a registered message callback
|
|
30
|
+
(with the originating session for targeted replies), drops clients only
|
|
31
|
+
on liveness loss, and logs delivery-delay warnings without ever dropping
|
|
32
|
+
data.
|
|
33
|
+
- `LivefeedClient` / `LivefeedClientSettings` — managed bidirectional
|
|
34
|
+
client: `start()` spawns background threads that connect, handshake,
|
|
35
|
+
deliver data frames to a registered message callback, send messages via
|
|
36
|
+
`send_message(dict)`, heartbeat after 3s of inactivity, detect a dead
|
|
37
|
+
server, and auto-reconnect into fresh state (no backlog).
|
|
38
|
+
- Callbacks are registered on the objects themselves via
|
|
39
|
+
`set_message_callback` / `set_connect_callback` / `set_disconnect_callback`
|
|
40
|
+
(settings carry no callables), and every callback receives the peer
|
|
41
|
+
object first so handlers can communicate back.
|
|
42
|
+
|
|
43
|
+
Wire protocol: newline-delimited JSON. Control frames: `hello`, `auth`,
|
|
44
|
+
`heartbeat` (client only), `ack`, `ping`, `pong`. Data frames are
|
|
45
|
+
application-defined (e.g. `{"type":"data",...}`).
|
|
46
|
+
|
|
47
|
+
## Building
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv build # or: pip wheel .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Tests
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m punit
|
|
57
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# esa-livefeed
|
|
2
|
+
|
|
3
|
+
Low-latency live feed server and managed client over raw TCP with JSONL framing,
|
|
4
|
+
client-driven heartbeats, bidirectional liveness detection, and unbounded
|
|
5
|
+
per-client backpressure. Pure Python stdlib; zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
- `LivefeedServer` / `LivefeedServerSettings` — accepts clients (one
|
|
8
|
+
`LivefeedSession` per client: dedicated sender/receiver threads and an
|
|
9
|
+
unbounded outbound queue), fans out pre-encoded frames via
|
|
10
|
+
`publish(data: bytes)`, exposes connected clients via the `clients`
|
|
11
|
+
property, delivers client data frames to a registered message callback
|
|
12
|
+
(with the originating session for targeted replies), drops clients only
|
|
13
|
+
on liveness loss, and logs delivery-delay warnings without ever dropping
|
|
14
|
+
data.
|
|
15
|
+
- `LivefeedClient` / `LivefeedClientSettings` — managed bidirectional
|
|
16
|
+
client: `start()` spawns background threads that connect, handshake,
|
|
17
|
+
deliver data frames to a registered message callback, send messages via
|
|
18
|
+
`send_message(dict)`, heartbeat after 3s of inactivity, detect a dead
|
|
19
|
+
server, and auto-reconnect into fresh state (no backlog).
|
|
20
|
+
- Callbacks are registered on the objects themselves via
|
|
21
|
+
`set_message_callback` / `set_connect_callback` / `set_disconnect_callback`
|
|
22
|
+
(settings carry no callables), and every callback receives the peer
|
|
23
|
+
object first so handlers can communicate back.
|
|
24
|
+
|
|
25
|
+
Wire protocol: newline-delimited JSON. Control frames: `hello`, `auth`,
|
|
26
|
+
`heartbeat` (client only), `ack`, `ping`, `pong`. Data frames are
|
|
27
|
+
application-defined (e.g. `{"type":"data",...}`).
|
|
28
|
+
|
|
29
|
+
## Building
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv build # or: pip wheel .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Tests
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m punit
|
|
39
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "esa-livefeed"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Low-latency live feed server and managed client over raw TCP with JSONL framing, heartbeats, liveness detection, and unbounded per-client backpressure."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name="Ely-Sandra", email="info@ely-sandra.com" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Operating System :: OS Independent",
|
|
13
|
+
"Programming Language :: Python"
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"hanaro (>=2.0.1)"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
"mypy",
|
|
25
|
+
"punit (>=1.14.1)",
|
|
26
|
+
"py-conventional-semver (>=1.3.5)",
|
|
27
|
+
"twine"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Documentation = "https://esa-livefeed.readthedocs.io/"
|
|
32
|
+
Homepage = "https://github.com/ely-sandra/esa-livefeed"
|
|
33
|
+
Repository = "https://github.com/ely-sandra/esa-livefeed.git"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
esa-livefeed = { path = "src/esa-livefeed", editable = true }
|
|
38
|
+
|
|
39
|
+
[tool.uv]
|
|
40
|
+
package = true
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Ely-Sandra LLC
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-Ely-Sandra-Proprietary=https://ely-sandra.com/LICENSE.md
|
|
3
|
+
|
|
4
|
+
from .livefeed_client import LivefeedClient
|
|
5
|
+
from .livefeed_client_settings import LivefeedClientSettings
|
|
6
|
+
from .livefeed_server import LivefeedServer
|
|
7
|
+
from .livefeed_server_settings import LivefeedServerSettings
|
|
8
|
+
from .livefeed_session import LivefeedSession
|
|
9
|
+
|
|
10
|
+
__version__ = '0.0.1'
|
|
11
|
+
__commit__ = 'f80fb34'
|
|
12
|
+
__all__ = [
|
|
13
|
+
'__version__', '__commit__',
|
|
14
|
+
'LivefeedClient',
|
|
15
|
+
'LivefeedClientSettings',
|
|
16
|
+
'LivefeedServer',
|
|
17
|
+
'LivefeedServerSettings',
|
|
18
|
+
'LivefeedSession',
|
|
19
|
+
]
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Ely-Sandra LLC
|
|
2
|
+
# SPDX-License-Identifier: LicenseRef-Ely-Sandra-Proprietary=https://ely-sandra.com/LICENSE.md
|
|
3
|
+
|
|
4
|
+
"""Managed livefeed client.
|
|
5
|
+
|
|
6
|
+
Provides the ``LivefeedClient`` class which maintains a livefeed connection
|
|
7
|
+
for the lifetime of a :meth:`start`/:meth:`stop` pair. A background
|
|
8
|
+
control thread hosts the connect/stream/reconnect cycle; each connection
|
|
9
|
+
is serviced by a dedicated sender thread (blocked on the client's unbounded
|
|
10
|
+
outbound queue, the sole writer to the socket) and a dedicated receiver
|
|
11
|
+
thread (blocked on the socket, the sole reader).
|
|
12
|
+
|
|
13
|
+
The application registers callbacks with :meth:`set_message_callback`,
|
|
14
|
+
:meth:`set_connect_callback`, and :meth:`set_disconnect_callback`, and sends
|
|
15
|
+
messages with :meth:`send_message`. Data frames are delivered to the
|
|
16
|
+
message callback serially, in receive order; the callback receives the
|
|
17
|
+
client first, so it can reply to the server. A message callback that
|
|
18
|
+
raises ends the connection and triggers the normal reconnect cycle.
|
|
19
|
+
|
|
20
|
+
When the connection breaks (server stop, network loss, liveness timeout),
|
|
21
|
+
the client invokes the disconnect callback and automatically reconnects
|
|
22
|
+
into fresh state; no backlog is ever replayed.
|
|
23
|
+
|
|
24
|
+
Usage
|
|
25
|
+
-----
|
|
26
|
+
|
|
27
|
+
settings = LivefeedClientSettings(host='10.0.0.5', port=3001)
|
|
28
|
+
client = LivefeedClient(settings)
|
|
29
|
+
client.set_message_callback(on_bar)
|
|
30
|
+
client.start()
|
|
31
|
+
...
|
|
32
|
+
client.stop()
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from contextlib import suppress
|
|
41
|
+
from queue import Empty, Queue
|
|
42
|
+
import socket
|
|
43
|
+
import time
|
|
44
|
+
from threading import Event, Thread
|
|
45
|
+
|
|
46
|
+
from .livefeed_client_settings import LivefeedClientSettings
|
|
47
|
+
from .protocol import (
|
|
48
|
+
TYPE_AUTH,
|
|
49
|
+
TYPE_AUTHOK,
|
|
50
|
+
TYPE_HEARTBEAT,
|
|
51
|
+
TYPE_HELLO,
|
|
52
|
+
control_types,
|
|
53
|
+
encode_message,
|
|
54
|
+
parse_message,
|
|
55
|
+
split_frames,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
HEARTBEAT_TICK_SECONDS: float = 0.5
|
|
59
|
+
RECV_BUFFER_SIZE: int = 65536
|
|
60
|
+
RECV_TICK_SECONDS: float = 0.5
|
|
61
|
+
STOP_JOIN_TIMEOUT_SECONDS: float = 10.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LivefeedClient:
|
|
65
|
+
"""
|
|
66
|
+
Managed livefeed client with automatic reconnection.
|
|
67
|
+
|
|
68
|
+
The application only calls :meth:`start`, :meth:`stop`, and
|
|
69
|
+
:meth:`send_message`. Everything else — connect, handshake, data
|
|
70
|
+
delivery, heartbeats, liveness detection, and reconnection — is
|
|
71
|
+
maintained by the client's background threads. The sender thread is
|
|
72
|
+
the sole writer to the socket: it drains the unbounded outbound queue
|
|
73
|
+
and emits heartbeats after the configured inactivity interval. The
|
|
74
|
+
receiver thread is the sole reader: it delivers data frames to the
|
|
75
|
+
registered message callback, serially, in receive order. Liveness is
|
|
76
|
+
enforced by the sender thread, which wakes on queue timeouts.
|
|
77
|
+
|
|
78
|
+
All callbacks receive the client as their first argument so the
|
|
79
|
+
application can communicate back to the server. A message callback
|
|
80
|
+
that raises ends the connection and triggers the normal reconnect
|
|
81
|
+
cycle.
|
|
82
|
+
|
|
83
|
+
Usage
|
|
84
|
+
-----
|
|
85
|
+
|
|
86
|
+
.. code-block:: python
|
|
87
|
+
|
|
88
|
+
settings = LivefeedClientSettings(host='10.0.0.5', port=3001)
|
|
89
|
+
client = LivefeedClient(settings)
|
|
90
|
+
client.set_message_callback(on_bar)
|
|
91
|
+
client.start()
|
|
92
|
+
...
|
|
93
|
+
client.stop()
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
__connect_callback: Callable[[LivefeedClient, dict], None] | None
|
|
97
|
+
__disconnect_callback: Callable[[LivefeedClient, str], None] | None
|
|
98
|
+
__end_reason: str
|
|
99
|
+
__last_rx: float
|
|
100
|
+
__last_tx: float
|
|
101
|
+
__logger: logging.Logger
|
|
102
|
+
__message_callback: Callable[[LivefeedClient, dict], None] | None
|
|
103
|
+
__queue: Queue | None
|
|
104
|
+
__receiver_thread: Thread | None
|
|
105
|
+
__running: bool
|
|
106
|
+
__sender_thread: Thread | None
|
|
107
|
+
__settings: LivefeedClientSettings
|
|
108
|
+
__socket: socket.socket | None
|
|
109
|
+
__stopped: Event
|
|
110
|
+
__thread: Thread | None
|
|
111
|
+
|
|
112
|
+
def __init__(self, settings: LivefeedClientSettings) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Initialize the client (inert until :meth:`start` is called).
|
|
115
|
+
|
|
116
|
+
:param settings: The client configuration.
|
|
117
|
+
"""
|
|
118
|
+
self.__connect_callback = None
|
|
119
|
+
self.__disconnect_callback = None
|
|
120
|
+
self.__end_reason = ''
|
|
121
|
+
self.__last_rx = 0.0
|
|
122
|
+
self.__last_tx = 0.0
|
|
123
|
+
self.__logger = logging.getLogger('esa_livefeed.client')
|
|
124
|
+
self.__message_callback = None
|
|
125
|
+
self.__queue = None
|
|
126
|
+
self.__receiver_thread = None
|
|
127
|
+
self.__running = False
|
|
128
|
+
self.__sender_thread = None
|
|
129
|
+
self.__settings = settings
|
|
130
|
+
self.__socket = None
|
|
131
|
+
self.__stopped = Event()
|
|
132
|
+
self.__thread = None
|
|
133
|
+
|
|
134
|
+
def __authenticate(self, sock: socket.socket) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Send the auth frame and wait for the server's authok confirmation.
|
|
137
|
+
|
|
138
|
+
:param sock: The handshake socket (must have a timeout set).
|
|
139
|
+
:raises ConnectionError: When the server rejects the token or the confirmation is malformed.
|
|
140
|
+
:returns: None
|
|
141
|
+
"""
|
|
142
|
+
sock.sendall(encode_message({'type': TYPE_AUTH, 'token': self.__settings.token}))
|
|
143
|
+
try:
|
|
144
|
+
ack = parse_message(self.__read_line(sock))
|
|
145
|
+
except ValueError as ex:
|
|
146
|
+
raise ConnectionError(f'malformed authok frame from server: {ex}') from ex
|
|
147
|
+
if ack.get('type') != TYPE_AUTHOK or ack.get('ok') is not True:
|
|
148
|
+
raise ConnectionError(f'authentication rejected by server: {ack!r}')
|
|
149
|
+
self.__last_rx = time.monotonic()
|
|
150
|
+
|
|
151
|
+
def __check_liveness(self, sock: socket.socket) -> bool:
|
|
152
|
+
"""
|
|
153
|
+
Close the connection when no frame has been received within the liveness window.
|
|
154
|
+
|
|
155
|
+
:param sock: The connection socket to close when stale.
|
|
156
|
+
:returns: True when the connection is still live, False when it was closed.
|
|
157
|
+
"""
|
|
158
|
+
if time.monotonic() - self.__last_rx <= self.__settings.liveness_timeout_seconds:
|
|
159
|
+
return True
|
|
160
|
+
self.__set_end_reason(f'no frame received from server within {self.__settings.liveness_timeout_seconds} seconds')
|
|
161
|
+
with suppress(OSError):
|
|
162
|
+
sock.close()
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def __connect_and_stream(self) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Open one connection: handshake, then stream until the connection ends.
|
|
168
|
+
|
|
169
|
+
Never raises; the end reason is recorded on the client for the
|
|
170
|
+
reconnect cycle.
|
|
171
|
+
|
|
172
|
+
:returns: None
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
sock = socket.create_connection(
|
|
176
|
+
(self.__settings.host, self.__settings.port),
|
|
177
|
+
timeout=self.__settings.connect_timeout_seconds,
|
|
178
|
+
)
|
|
179
|
+
except OSError as ex:
|
|
180
|
+
self.__set_end_reason(str(ex) or ex.__class__.__name__)
|
|
181
|
+
return
|
|
182
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
183
|
+
self.__socket = sock
|
|
184
|
+
self.__queue = Queue()
|
|
185
|
+
self.__last_rx = time.monotonic()
|
|
186
|
+
self.__last_tx = time.monotonic()
|
|
187
|
+
self.__end_reason = ''
|
|
188
|
+
sender = Thread(target=self.__send_loop, name='livefeed-client-sender', daemon=True)
|
|
189
|
+
receiver = Thread(target=self.__recv_loop, name='livefeed-client-receiver', daemon=True)
|
|
190
|
+
started = False
|
|
191
|
+
try:
|
|
192
|
+
hello_line = self.__read_line(sock)
|
|
193
|
+
try:
|
|
194
|
+
hello = parse_message(hello_line)
|
|
195
|
+
except ValueError as ex:
|
|
196
|
+
raise ConnectionError(f'malformed hello frame from server: {ex}') from ex
|
|
197
|
+
if hello.get('type') != TYPE_HELLO:
|
|
198
|
+
raise ConnectionError(f'expected hello frame, got {hello.get("type")!r}')
|
|
199
|
+
if hello.get('auth'):
|
|
200
|
+
if self.__settings.token is None:
|
|
201
|
+
raise ConnectionError('server requires authentication but no token is configured')
|
|
202
|
+
self.__authenticate(sock)
|
|
203
|
+
self.__logger.info(f'livefeed client connected to {self.__settings.host}:{self.__settings.port}')
|
|
204
|
+
self.__invoke(self.__connect_callback, self, hello)
|
|
205
|
+
sock.settimeout(RECV_TICK_SECONDS)
|
|
206
|
+
sender.start()
|
|
207
|
+
receiver.start()
|
|
208
|
+
started = True
|
|
209
|
+
self.__sender_thread = sender
|
|
210
|
+
self.__receiver_thread = receiver
|
|
211
|
+
while self.__running and sender.is_alive() and receiver.is_alive():
|
|
212
|
+
time.sleep(0.05)
|
|
213
|
+
except Exception as ex:
|
|
214
|
+
self.__set_end_reason(str(ex) or ex.__class__.__name__)
|
|
215
|
+
finally:
|
|
216
|
+
with suppress(OSError):
|
|
217
|
+
sock.close()
|
|
218
|
+
if started:
|
|
219
|
+
sender.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
|
220
|
+
receiver.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
|
221
|
+
self.__sender_thread = None
|
|
222
|
+
self.__receiver_thread = None
|
|
223
|
+
self.__queue = None
|
|
224
|
+
self.__socket = None
|
|
225
|
+
|
|
226
|
+
def __invoke(self, callback: Callable | None, *args) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Invoke a registered callback.
|
|
229
|
+
|
|
230
|
+
:param callback: The callback to invoke (None is a no-op).
|
|
231
|
+
:param args: The positional arguments to pass.
|
|
232
|
+
:returns: None
|
|
233
|
+
"""
|
|
234
|
+
if callback is not None:
|
|
235
|
+
callback(*args)
|
|
236
|
+
|
|
237
|
+
def __read_line(self, sock: socket.socket) -> bytes:
|
|
238
|
+
"""
|
|
239
|
+
Read one newline-terminated frame from the socket.
|
|
240
|
+
|
|
241
|
+
:param sock: The socket to read from (must have a timeout set).
|
|
242
|
+
:returns: The raw frame, including the trailing newline.
|
|
243
|
+
:raises ConnectionError: When the connection closes before a complete frame arrives.
|
|
244
|
+
"""
|
|
245
|
+
buffer = bytearray()
|
|
246
|
+
while not buffer.endswith(b'\n'):
|
|
247
|
+
chunk = sock.recv(4096)
|
|
248
|
+
if not chunk:
|
|
249
|
+
raise ConnectionError('server closed the connection during handshake')
|
|
250
|
+
buffer.extend(chunk)
|
|
251
|
+
return bytes(buffer)
|
|
252
|
+
|
|
253
|
+
def __recv_loop(self) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Run the receiver thread: read frames until the connection ends, delivering data frames to the message callback.
|
|
256
|
+
|
|
257
|
+
This thread is the sole reader of the socket. Control frames are
|
|
258
|
+
consumed by the library and are not delivered.
|
|
259
|
+
|
|
260
|
+
:returns: None
|
|
261
|
+
"""
|
|
262
|
+
sock = self.__socket
|
|
263
|
+
if sock is None:
|
|
264
|
+
return
|
|
265
|
+
buffer = bytearray()
|
|
266
|
+
try:
|
|
267
|
+
while self.__running:
|
|
268
|
+
try:
|
|
269
|
+
chunk = sock.recv(RECV_BUFFER_SIZE)
|
|
270
|
+
except socket.timeout:
|
|
271
|
+
continue
|
|
272
|
+
if not chunk:
|
|
273
|
+
self.__set_end_reason('server closed the connection')
|
|
274
|
+
with suppress(OSError):
|
|
275
|
+
sock.close()
|
|
276
|
+
return
|
|
277
|
+
buffer.extend(chunk)
|
|
278
|
+
for frame in split_frames(buffer):
|
|
279
|
+
self.__last_rx = time.monotonic()
|
|
280
|
+
message = parse_message(frame)
|
|
281
|
+
if message.get('type') in control_types():
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
self.__invoke(self.__message_callback, self, message)
|
|
285
|
+
except Exception as ex:
|
|
286
|
+
self.__logger.exception(f'livefeed message callback error: {ex}')
|
|
287
|
+
self.__set_end_reason(f'message callback raised: {ex}')
|
|
288
|
+
with suppress(OSError):
|
|
289
|
+
sock.close()
|
|
290
|
+
return
|
|
291
|
+
except (OSError, ValueError) as ex:
|
|
292
|
+
self.__set_end_reason(str(ex) or ex.__class__.__name__)
|
|
293
|
+
|
|
294
|
+
def __run(self) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Run the connect/stream/reconnect cycle until stopped.
|
|
297
|
+
|
|
298
|
+
:returns: None
|
|
299
|
+
"""
|
|
300
|
+
while not self.__stopped.is_set():
|
|
301
|
+
self.__connect_and_stream()
|
|
302
|
+
if self.__stopped.is_set():
|
|
303
|
+
break
|
|
304
|
+
reason = self.__end_reason or 'connection closed'
|
|
305
|
+
self.__logger.info(
|
|
306
|
+
f'livefeed client disconnected ({reason}), reconnecting in {self.__settings.reconnect_delay_seconds} seconds'
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
self.__invoke(self.__disconnect_callback, self, reason)
|
|
310
|
+
except Exception as ex:
|
|
311
|
+
self.__logger.exception(f'livefeed disconnect callback error: {ex}')
|
|
312
|
+
deadline = time.monotonic() + self.__settings.reconnect_delay_seconds
|
|
313
|
+
while not self.__stopped.is_set() and time.monotonic() < deadline:
|
|
314
|
+
time.sleep(0.05)
|
|
315
|
+
|
|
316
|
+
def __send_loop(self) -> None:
|
|
317
|
+
"""
|
|
318
|
+
Run the sender thread: drain the outbound queue until the connection ends.
|
|
319
|
+
|
|
320
|
+
This thread is the sole writer to the socket. It emits heartbeats
|
|
321
|
+
after the configured inactivity interval and enforces the liveness
|
|
322
|
+
timeout, waking on queue timeouts and after each send.
|
|
323
|
+
|
|
324
|
+
:returns: None
|
|
325
|
+
"""
|
|
326
|
+
sock = self.__socket
|
|
327
|
+
queue = self.__queue
|
|
328
|
+
if sock is None or queue is None:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
while self.__running:
|
|
332
|
+
try:
|
|
333
|
+
data = queue.get(timeout=HEARTBEAT_TICK_SECONDS)
|
|
334
|
+
except Empty:
|
|
335
|
+
if time.monotonic() - self.__last_tx < self.__settings.heartbeat_seconds:
|
|
336
|
+
if not self.__check_liveness(sock):
|
|
337
|
+
return
|
|
338
|
+
continue
|
|
339
|
+
data = encode_message({'type': TYPE_HEARTBEAT, 'ts': time.time_ns()})
|
|
340
|
+
self.__send(sock, data)
|
|
341
|
+
self.__last_tx = time.monotonic()
|
|
342
|
+
if not self.__check_liveness(sock):
|
|
343
|
+
return
|
|
344
|
+
except OSError:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
def __send(self, sock: socket.socket, data: bytes) -> None:
|
|
348
|
+
"""
|
|
349
|
+
Send raw bytes to the server, retrying through socket timeouts.
|
|
350
|
+
|
|
351
|
+
:param sock: The connection socket to write to.
|
|
352
|
+
:param data: The bytes to send.
|
|
353
|
+
:raises OSError: When the send fails.
|
|
354
|
+
"""
|
|
355
|
+
view = memoryview(data)
|
|
356
|
+
while view:
|
|
357
|
+
try:
|
|
358
|
+
sent = sock.send(view)
|
|
359
|
+
except socket.timeout:
|
|
360
|
+
continue
|
|
361
|
+
view = view[sent:]
|
|
362
|
+
|
|
363
|
+
def __set_end_reason(self, reason: str) -> None:
|
|
364
|
+
"""
|
|
365
|
+
Record the connection end reason, keeping the first reason set.
|
|
366
|
+
|
|
367
|
+
:param reason: A short human-readable reason.
|
|
368
|
+
:returns: None
|
|
369
|
+
"""
|
|
370
|
+
if not self.__end_reason:
|
|
371
|
+
self.__end_reason = reason
|
|
372
|
+
|
|
373
|
+
def __thread_main(self) -> None:
|
|
374
|
+
"""
|
|
375
|
+
Run the client's control loop (background thread body).
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
self.__run()
|
|
379
|
+
except Exception as ex:
|
|
380
|
+
self.__logger.exception(f'livefeed client run error: {ex}')
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def is_running(self) -> bool:
|
|
384
|
+
"""
|
|
385
|
+
Determine whether the client has been started and not stopped.
|
|
386
|
+
|
|
387
|
+
:returns: True when the client's background thread is active.
|
|
388
|
+
"""
|
|
389
|
+
return self.__running
|
|
390
|
+
|
|
391
|
+
def send_message(self, message: dict) -> None:
|
|
392
|
+
"""
|
|
393
|
+
Enqueue a message to send to the server.
|
|
394
|
+
|
|
395
|
+
The frame is written to the socket by the client's sender thread.
|
|
396
|
+
A no-op when there is no active connection (during startup,
|
|
397
|
+
shutdown, or a reconnect gap).
|
|
398
|
+
|
|
399
|
+
:param message: The JSON-serializable message to send.
|
|
400
|
+
:returns: None
|
|
401
|
+
"""
|
|
402
|
+
queue = self.__queue
|
|
403
|
+
if queue is None:
|
|
404
|
+
return
|
|
405
|
+
queue.put(encode_message(message))
|
|
406
|
+
|
|
407
|
+
def set_connect_callback(self, callback: Callable[[LivefeedClient, dict], None] | None) -> None:
|
|
408
|
+
"""
|
|
409
|
+
Register the callback invoked when the client finishes connecting.
|
|
410
|
+
|
|
411
|
+
:param callback: The callback to invoke with the client and the server's hello frame, or None to clear.
|
|
412
|
+
:returns: None
|
|
413
|
+
"""
|
|
414
|
+
self.__connect_callback = callback
|
|
415
|
+
|
|
416
|
+
def set_disconnect_callback(self, callback: Callable[[LivefeedClient, str], None] | None) -> None:
|
|
417
|
+
"""
|
|
418
|
+
Register the callback invoked when a connection ends (not on stop).
|
|
419
|
+
|
|
420
|
+
:param callback: The callback to invoke with the client and the end reason, or None to clear.
|
|
421
|
+
:returns: None
|
|
422
|
+
"""
|
|
423
|
+
self.__disconnect_callback = callback
|
|
424
|
+
|
|
425
|
+
def set_message_callback(self, callback: Callable[[LivefeedClient, dict], None] | None) -> None:
|
|
426
|
+
"""
|
|
427
|
+
Register the callback invoked when a data frame is received from the server.
|
|
428
|
+
|
|
429
|
+
The callback receives the client first, so it can reply to the
|
|
430
|
+
server via :meth:`send_message`. Control frames (hello, ack,
|
|
431
|
+
pong, ...) are consumed by the library and are not delivered.
|
|
432
|
+
|
|
433
|
+
:param callback: The callback to invoke with the client and the decoded frame, or None to clear.
|
|
434
|
+
:returns: None
|
|
435
|
+
"""
|
|
436
|
+
self.__message_callback = callback
|
|
437
|
+
|
|
438
|
+
def start(self) -> None:
|
|
439
|
+
"""
|
|
440
|
+
Start the client's background connection thread.
|
|
441
|
+
|
|
442
|
+
Idempotent; a second call while running is a no-op.
|
|
443
|
+
|
|
444
|
+
:returns: None
|
|
445
|
+
"""
|
|
446
|
+
if self.__running:
|
|
447
|
+
return
|
|
448
|
+
self.__running = True
|
|
449
|
+
self.__stopped.clear()
|
|
450
|
+
self.__thread = Thread(target=self.__thread_main, name='livefeed-client', daemon=True)
|
|
451
|
+
self.__thread.start()
|
|
452
|
+
|
|
453
|
+
def stop(self) -> None:
|
|
454
|
+
"""
|
|
455
|
+
Stop the client, closing the current connection and joining the threads.
|
|
456
|
+
|
|
457
|
+
Idempotent; a second call is a no-op. Blocks until the background
|
|
458
|
+
thread has exited (bounded wait).
|
|
459
|
+
|
|
460
|
+
:returns: None
|
|
461
|
+
"""
|
|
462
|
+
if not self.__running:
|
|
463
|
+
return
|
|
464
|
+
self.__running = False
|
|
465
|
+
self.__stopped.set()
|
|
466
|
+
sock = self.__socket
|
|
467
|
+
if sock is not None:
|
|
468
|
+
with suppress(OSError):
|
|
469
|
+
sock.close()
|
|
470
|
+
thread = self.__thread
|
|
471
|
+
if thread is not None:
|
|
472
|
+
thread.join(timeout=STOP_JOIN_TIMEOUT_SECONDS)
|
|
473
|
+
self.__thread = None
|
|
474
|
+
self.__socket = None
|
|
475
|
+
self.__queue = None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
__all__ = [
|
|
479
|
+
'LivefeedClient',
|
|
480
|
+
]
|