motorcortex-python 1.0.3__py3-none-any.whl → 1.2.1__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.
- motorcortex/__init__.py +28 -3
- motorcortex/_request_utils.py +103 -2
- motorcortex/_sp_stream.py +331 -0
- motorcortex/motorcortex_hash.json +17 -1
- motorcortex/motorcortex_pb2.py +69 -61
- motorcortex/motorcortex_pb2.pyi +161 -1
- motorcortex/request.py +64 -4
- motorcortex/subscribe.py +374 -173
- motorcortex/subscription.py +82 -6
- motorcortex/version.py +1 -1
- {motorcortex_python-1.0.3.dist-info → motorcortex_python-1.2.1.dist-info}/METADATA +19 -1
- {motorcortex_python-1.0.3.dist-info → motorcortex_python-1.2.1.dist-info}/RECORD +15 -14
- {motorcortex_python-1.0.3.dist-info → motorcortex_python-1.2.1.dist-info}/WHEEL +1 -1
- {motorcortex_python-1.0.3.dist-info → motorcortex_python-1.2.1.dist-info}/LICENSE +0 -0
- {motorcortex_python-1.0.3.dist-info → motorcortex_python-1.2.1.dist-info}/top_level.txt +0 -0
motorcortex/__init__.py
CHANGED
|
@@ -41,6 +41,7 @@ from motorcortex.exceptions import (
|
|
|
41
41
|
McxTimeout,
|
|
42
42
|
)
|
|
43
43
|
from motorcortex.motorcortex_pb2 import StatusCode as _PbStatusCode
|
|
44
|
+
from motorcortex import _request_utils
|
|
44
45
|
from motorcortex.setup_logger import logger # not re-exported — ``__all__`` gates package surface
|
|
45
46
|
|
|
46
47
|
# Build an ``IntEnum`` facade over the protobuf ``StatusCode`` enum so
|
|
@@ -286,7 +287,7 @@ def connect(
|
|
|
286
287
|
state_update (Callable, optional): Custom callback for connection state changes.
|
|
287
288
|
If provided, disables the built-in reconnect logic.
|
|
288
289
|
req_number_of_threads (int, optional): Thread pool size for request connection. Defaults to 2.
|
|
289
|
-
sub_number_of_threads (int, optional): Thread pool size for subscribe connection. Defaults to 2.
|
|
290
|
+
sub_number_of_threads (int, optional): Thread pool size for subscribe connection. Defaults to 2, clamped up to the minimum of 4 that ``Subscribe`` requires (connect worker, recv loop, reconnect loop, subscription updaters).
|
|
290
291
|
|
|
291
292
|
Returns:
|
|
292
293
|
tuple: (req, sub)
|
|
@@ -312,7 +313,15 @@ def connect(
|
|
|
312
313
|
|
|
313
314
|
if reconnect and not kwargs.get("state_update"):
|
|
314
315
|
|
|
316
|
+
# The initial CONNECTION_OK edge can land after connect() returns
|
|
317
|
+
# (the cookie probe + hello finish in the background), so it must
|
|
318
|
+
# not be mistaken for a reconnect that needs a re-login.
|
|
319
|
+
initial_ok_seen = [False]
|
|
320
|
+
|
|
315
321
|
def stateUpdate(req, sub, state):
|
|
322
|
+
if state == ConnectionState.CONNECTION_OK and not initial_ok_seen[0]:
|
|
323
|
+
initial_ok_seen[0] = True
|
|
324
|
+
return
|
|
316
325
|
_reconnect_state_update(
|
|
317
326
|
req, sub, state,
|
|
318
327
|
motorcortex_types=motorcortex_types,
|
|
@@ -342,7 +351,12 @@ def connect(
|
|
|
342
351
|
)
|
|
343
352
|
# Open subscribe connection
|
|
344
353
|
sub = Subscribe(req, motorcortex_types, kwargs.get("sub_number_of_threads", 2))
|
|
345
|
-
|
|
354
|
+
sub_reply = sub.connect(makeUrl(sub_address, sub_port), **kwargs)
|
|
355
|
+
# Wait for the dial only: the cookie probe and hello carry on in
|
|
356
|
+
# the background (up to 2 s on an old server that never answers)
|
|
357
|
+
# while login and a cached tree load go ahead.
|
|
358
|
+
dialed = getattr(sub, "_dialed", None)
|
|
359
|
+
if not (dialed.result() if dialed is not None else sub_reply.get()):
|
|
346
360
|
raise McxConnectionError(
|
|
347
361
|
"Failed to establish subscribe connection: {}:{}".format(sub_address, sub_port)
|
|
348
362
|
)
|
|
@@ -360,7 +374,18 @@ def connect(
|
|
|
360
374
|
)
|
|
361
375
|
|
|
362
376
|
# Requesting a parameter tree
|
|
363
|
-
|
|
377
|
+
# On a cache miss, wait for the cookie-probe verdict so an old
|
|
378
|
+
# server (no compressed tree handler, silently drops the request)
|
|
379
|
+
# gets the plain tree straight away instead of waiting on a reply
|
|
380
|
+
# that never comes. A cache hit never calls this.
|
|
381
|
+
def compressed_hint():
|
|
382
|
+
try:
|
|
383
|
+
sub_reply.get(timeout_ms=_request_utils.CAPABILITY_PROBE_TIMEOUT_MS + 3000)
|
|
384
|
+
except Exception:
|
|
385
|
+
return None
|
|
386
|
+
return getattr(sub, "_server_has_routing", None)
|
|
387
|
+
|
|
388
|
+
param_tree_reply = req.getParameterTree(compressed=compressed_hint)
|
|
364
389
|
tree = param_tree_reply.get()
|
|
365
390
|
param_tree.load(tree)
|
|
366
391
|
except Exception:
|
motorcortex/_request_utils.py
CHANGED
|
@@ -19,8 +19,12 @@ import hashlib
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
21
|
import tempfile
|
|
22
|
+
import zlib
|
|
22
23
|
from threading import Event
|
|
23
|
-
from typing import Any, Callable, Optional, Tuple
|
|
24
|
+
from typing import Any, Callable, Optional, Tuple, Union
|
|
25
|
+
|
|
26
|
+
from pynng._nng import lib # type: ignore[import-untyped]
|
|
27
|
+
from pynng.exceptions import check_err # type: ignore[import-untyped]
|
|
24
28
|
|
|
25
29
|
from motorcortex.exceptions import McxConnectionError
|
|
26
30
|
from motorcortex.setup_logger import logger
|
|
@@ -28,6 +32,12 @@ from motorcortex.setup_logger import logger
|
|
|
28
32
|
|
|
29
33
|
_CACHE_PREFIX = "mcx-python-pt"
|
|
30
34
|
|
|
35
|
+
#: Old servers silently drop unknown request hashes — cap how long a
|
|
36
|
+
#: capability probe (routing cookie, compressed tree) may wait so a
|
|
37
|
+
#: pre-2.36 server costs at most 2 s per probe. Mirrors
|
|
38
|
+
#: CAPABILITY_PROBE_TIMEOUT_MS in motorcortex-cpp and motorcortex-js.
|
|
39
|
+
CAPABILITY_PROBE_TIMEOUT_MS = 2000
|
|
40
|
+
|
|
31
41
|
|
|
32
42
|
def parse_connect_kwargs(
|
|
33
43
|
conn_timeout_ms: int = 0,
|
|
@@ -113,6 +123,7 @@ def fetch_parameter_tree(
|
|
|
113
123
|
protobuf_types: Any,
|
|
114
124
|
socket: Any,
|
|
115
125
|
url: Optional[str] = None,
|
|
126
|
+
compressed: Union[Optional[bool], Callable[[], Optional[bool]]] = None,
|
|
116
127
|
) -> Any:
|
|
117
128
|
"""Fetch the parameter tree, hitting the local cache first.
|
|
118
129
|
|
|
@@ -131,10 +142,43 @@ def fetch_parameter_tree(
|
|
|
131
142
|
:func:`send_and_recv`.
|
|
132
143
|
url: Connection URL — hashed into the cache filename so two
|
|
133
144
|
engines with the same ``tree_hash`` don't collide.
|
|
145
|
+
compressed: Server-capability hint from the routing-cookie probe
|
|
146
|
+
(same scheme as motorcortex-js). The cookie handler and the
|
|
147
|
+
compressed tree handler both landed in core 2.36, so:
|
|
148
|
+
``False`` — the server did not hand out a cookie, so it has no
|
|
149
|
+
compressed handler: skip straight to the plain request.
|
|
150
|
+
``True`` — the server is >= 2.36: request compressed with the
|
|
151
|
+
socket's own timeout, so a big reply on a slow link never
|
|
152
|
+
races a short cap.
|
|
153
|
+
``None`` — unknown (no Subscribe, raw ``Request`` use): try
|
|
154
|
+
compressed, capped at ``CAPABILITY_PROBE_TIMEOUT_MS``.
|
|
155
|
+
May also be a callable returning one of those; it is only
|
|
156
|
+
called on a cache miss, so a still-running probe never delays
|
|
157
|
+
a cached tree load.
|
|
134
158
|
|
|
135
159
|
Returns:
|
|
136
160
|
A ``ParameterTreeMsg`` — either the cached one or the freshly
|
|
137
161
|
fetched one (also persisted to the cache).
|
|
162
|
+
|
|
163
|
+
On a cache miss, a compressed tree is tried first
|
|
164
|
+
(``motorcortex-core >= 2.36.0``): a single zlib/deflate blob,
|
|
165
|
+
typically 7-13x smaller than the plain reply. The server deflates
|
|
166
|
+
the *full wire reply* — a 4-byte little-endian type-hash prefix
|
|
167
|
+
followed by the ``ParameterTreeMsg`` body, same as any other RPC
|
|
168
|
+
reply — so the inflated blob goes through the canonical
|
|
169
|
+
``MessageTypes.decode()``, which strips the prefix and resolves the
|
|
170
|
+
type by hash. Any failure — timeout on an old server that silently
|
|
171
|
+
drops the unrecognised request hash, a non-OK status from a
|
|
172
|
+
2.36.1-2.38 core, an unknown or unexpected type hash, or an
|
|
173
|
+
inflate/parse error — falls through to the plain
|
|
174
|
+
``GetParameterTreeMsg`` request. Both paths decode to the same
|
|
175
|
+
``ParameterTreeMsg`` type, so callers (and the cache write below)
|
|
176
|
+
can't tell the difference. Tried for every URL scheme (ipc, tcp,
|
|
177
|
+
ws, tls+tcp, wss) — the request is just bytes over
|
|
178
|
+
:func:`send_and_recv`. Old servers never answer the unknown hash, and
|
|
179
|
+
the socket's ``recv_timeout`` is infinite by default, so an uncapped
|
|
180
|
+
attempt would hang the connect forever — hence the ``compressed``
|
|
181
|
+
hint and the per-request probe cap.
|
|
138
182
|
"""
|
|
139
183
|
path = parameter_tree_cache_path(url, tree_hash)
|
|
140
184
|
cached = load_parameter_tree_file(path, protobuf_types)
|
|
@@ -143,6 +187,51 @@ def fetch_parameter_tree(
|
|
|
143
187
|
return cached
|
|
144
188
|
logger.debug("[REQUEST] Failed to find parameter tree in the cache")
|
|
145
189
|
|
|
190
|
+
if callable(compressed):
|
|
191
|
+
compressed = compressed()
|
|
192
|
+
if compressed is False:
|
|
193
|
+
logger.debug("[REQUEST] server predates the compressed tree — requesting plain")
|
|
194
|
+
request_msg = protobuf_types.createType("motorcortex.GetParameterTreeMsg")
|
|
195
|
+
handle = send_and_recv(socket, protobuf_types.encode(request_msg), protobuf_types)
|
|
196
|
+
return save_parameter_tree_file(path, handle)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
compressed_request = protobuf_types.createType(
|
|
200
|
+
"motorcortex.GetParameterTreeCompressedMsg")
|
|
201
|
+
compressed_reply = send_and_recv(
|
|
202
|
+
socket, protobuf_types.encode(compressed_request), protobuf_types,
|
|
203
|
+
timeout_ms=None if compressed else CAPABILITY_PROBE_TIMEOUT_MS)
|
|
204
|
+
if compressed_reply is not None \
|
|
205
|
+
and getattr(compressed_reply, "status", 1) == 0 \
|
|
206
|
+
and getattr(compressed_reply, "compressed", b""):
|
|
207
|
+
# The server deflates the *full wire reply* here, not a bare
|
|
208
|
+
# ParameterTreeMsg: inflating yields the same hash-prefixed
|
|
209
|
+
# framing every other RPC reply uses (matches the message's
|
|
210
|
+
# own docstring: "the exact bytes a GetParameterTreeMsg reply
|
|
211
|
+
# carries"). Run it through the canonical decode() — which
|
|
212
|
+
# strips the type-hash prefix and resolves the type by hash —
|
|
213
|
+
# instead of ParseFromString on the raw blob; parsing the blob
|
|
214
|
+
# directly is exactly the bug this branch used to have: the
|
|
215
|
+
# prefix corrupted every parse and silently forced the plain
|
|
216
|
+
# fallback. An unknown hash (KeyError in decode) or a decode
|
|
217
|
+
# to some other type falls through to the plain request rather
|
|
218
|
+
# than caching garbage as a ParameterTreeMsg.
|
|
219
|
+
tree_msg = protobuf_types.decode(
|
|
220
|
+
zlib.decompress(compressed_reply.compressed))
|
|
221
|
+
decoded_name = getattr(
|
|
222
|
+
getattr(tree_msg, "DESCRIPTOR", None), "full_name", None)
|
|
223
|
+
if decoded_name != "motorcortex.ParameterTreeMsg":
|
|
224
|
+
raise ValueError(
|
|
225
|
+
"compressed tree decoded to unexpected type "
|
|
226
|
+
f"{decoded_name or type(tree_msg).__name__}")
|
|
227
|
+
logger.debug("[REQUEST] parameter tree fetched compressed")
|
|
228
|
+
return save_parameter_tree_file(path, tree_msg)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.debug(
|
|
231
|
+
"[REQUEST] compressed tree unavailable (%s: %s) — falling back to plain",
|
|
232
|
+
type(e).__name__, e,
|
|
233
|
+
)
|
|
234
|
+
|
|
146
235
|
request_msg = protobuf_types.createType("motorcortex.GetParameterTreeMsg")
|
|
147
236
|
handle = send_and_recv(socket, protobuf_types.encode(request_msg), protobuf_types)
|
|
148
237
|
return save_parameter_tree_file(path, handle)
|
|
@@ -152,6 +241,7 @@ def send_and_recv(
|
|
|
152
241
|
socket: Any,
|
|
153
242
|
encoded_msg: Any,
|
|
154
243
|
protobuf_types: Optional[Any],
|
|
244
|
+
timeout_ms: Optional[int] = None,
|
|
155
245
|
) -> Any:
|
|
156
246
|
"""One-shot synchronous request/reply over an nng Req0 socket.
|
|
157
247
|
|
|
@@ -168,6 +258,12 @@ def send_and_recv(
|
|
|
168
258
|
encoded_msg: Bytes to send.
|
|
169
259
|
protobuf_types: If truthy, decoded wire reply is returned.
|
|
170
260
|
Otherwise the raw buffer is returned.
|
|
261
|
+
timeout_ms: Receive timeout for this one request, set on the nng
|
|
262
|
+
context so the worker thread is released when it expires
|
|
263
|
+
(``pynng.Timeout`` is raised). ``None`` keeps the socket's
|
|
264
|
+
``recv_timeout``. A ``Reply.get(timeout_ms=...)`` alone only
|
|
265
|
+
stops the caller waiting — the worker stays blocked in
|
|
266
|
+
``recv()`` for as long as the socket timeout allows.
|
|
171
267
|
|
|
172
268
|
Returns:
|
|
173
269
|
Decoded reply (when ``protobuf_types`` supplied and buffer is
|
|
@@ -175,6 +271,8 @@ def send_and_recv(
|
|
|
175
271
|
"""
|
|
176
272
|
ctx = socket.new_context()
|
|
177
273
|
try:
|
|
274
|
+
if timeout_ms is not None:
|
|
275
|
+
check_err(lib.nng_ctx_set_ms(ctx.context, b"recv-timeout", timeout_ms))
|
|
178
276
|
ctx.send(encoded_msg)
|
|
179
277
|
buffer = ctx.recv()
|
|
180
278
|
if buffer:
|
|
@@ -182,7 +280,10 @@ def send_and_recv(
|
|
|
182
280
|
return protobuf_types.decode(buffer)
|
|
183
281
|
return buffer
|
|
184
282
|
except Exception as e:
|
|
185
|
-
|
|
283
|
+
# A capped request timing out is an expected probe outcome on old
|
|
284
|
+
# servers, not an error worth shouting about.
|
|
285
|
+
log = logger.debug if timeout_ms is not None else logger.error
|
|
286
|
+
log("[SEND] Error during send/recv: %s: %s", type(e).__name__, e)
|
|
186
287
|
raise
|
|
187
288
|
finally:
|
|
188
289
|
ctx.close()
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
#
|
|
3
|
+
# Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
|
|
4
|
+
# All rights reserved. Copyright (c) 2016-2026 VECTIONEER.
|
|
5
|
+
#
|
|
6
|
+
"""Stream-based SP (nanomsg) pub/sub client transport.
|
|
7
|
+
|
|
8
|
+
Replaces the pynng ``Sub0`` socket for the subscribe data channel so the
|
|
9
|
+
client can send the routing hello as the first upstream message — a raw
|
|
10
|
+
``nng_stream`` has no protocol layer, so we speak the SP wire format
|
|
11
|
+
ourselves. Uses only pynng's bundled cffi bindings (``pynng._nng``): the
|
|
12
|
+
full public ``nng_stream_* / nng_aio_*`` API ships in every pynng wheel.
|
|
13
|
+
|
|
14
|
+
Wire format per scheme (mirrors motorcortex-cpp src/sp_stream.cpp, which
|
|
15
|
+
is the proven reference — see its comments for the full rationale):
|
|
16
|
+
|
|
17
|
+
- ws/wss: subprotocol ``pub.sp.nanomsg.org`` on the HTTP upgrade;
|
|
18
|
+
message mode — one send/recv is one SP message, no length
|
|
19
|
+
prefix, no byte handshake.
|
|
20
|
+
- tcp/tls: 8-byte handshake ``\\x00SP\\x00`` + u16-BE proto + u16-BE 0
|
|
21
|
+
(we announce 33/sub, require peer 32/pub), then each message
|
|
22
|
+
is a u64-BE length prefix + body.
|
|
23
|
+
- ipc: same handshake, but a 9-byte per-message header: type byte
|
|
24
|
+
``0x01`` + u64-BE length. The type byte is load-bearing.
|
|
25
|
+
|
|
26
|
+
nng 1.11.0 caveat (learned in cpp): never ``nng_aio_cancel`` an aio on a
|
|
27
|
+
raw TLS stream — it crashes ``nng_stream_close`` intermittently. This
|
|
28
|
+
implementation never cancels: every aio is owned by exactly one blocking
|
|
29
|
+
call which waits for it to finish (success, error, or timeout) before
|
|
30
|
+
freeing it. Closing the stream from another thread makes a pending recv
|
|
31
|
+
finish with an error, which is the supported wakeup path.
|
|
32
|
+
|
|
33
|
+
Stream lifetime vs. concurrent close(): a bare local capture of
|
|
34
|
+
``self._stream`` before an nng call is not enough — close() both nulls
|
|
35
|
+
the attribute *and* frees the underlying stream, so a racing capture can
|
|
36
|
+
still end up handing a freed pointer to nng between close()'s two steps.
|
|
37
|
+
Every blocking nng call therefore goes through ``_issue()``, which
|
|
38
|
+
captures the stream, increments ``_io_inflight``, AND actually calls
|
|
39
|
+
``nng_stream_send``/``nng_stream_recv`` — all under ``_io_cond`` in one
|
|
40
|
+
atomic step (not just the capture: an earlier version issued the nng
|
|
41
|
+
call *after* releasing the lock, which left a window where
|
|
42
|
+
``nng_stream_close`` could run first and the op would then be issued
|
|
43
|
+
against an already-closed stream — nng accepts that silently and never
|
|
44
|
+
completes it, hanging ``nng_aio_wait`` forever when the aio has no
|
|
45
|
+
timeout of its own, i.e. exactly ``recv_msg()``'s default). close() sets
|
|
46
|
+
``_closing``/nulls ``self._stream`` under the same lock, calls
|
|
47
|
+
``nng_stream_close`` (which wakes any pending aio — pending meaning
|
|
48
|
+
already issued, which ``_issue()`` now guarantees), and only frees the
|
|
49
|
+
stream once ``_io_inflight`` drops back to zero. That ordering — close,
|
|
50
|
+
then wait for in-flight callers to finish with the local pointer they
|
|
51
|
+
already captured, then free — is what makes the free race-free without
|
|
52
|
+
ever touching ``nng_aio_cancel``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import struct
|
|
56
|
+
import threading
|
|
57
|
+
from typing import Optional
|
|
58
|
+
|
|
59
|
+
from pynng._nng import ffi, lib # type: ignore[import-untyped]
|
|
60
|
+
from pynng.exceptions import check_err # type: ignore[import-untyped]
|
|
61
|
+
|
|
62
|
+
from motorcortex.setup_logger import logger
|
|
63
|
+
|
|
64
|
+
_SP_PROTO_SUB = 33
|
|
65
|
+
_SP_PROTO_PUB = 32
|
|
66
|
+
_HANDSHAKE_LEN = 8
|
|
67
|
+
|
|
68
|
+
#: One recv buffer per stream in ws message mode. nng's ws transport caps
|
|
69
|
+
#: read-ahead at one frame and the server pipe buffer is 1 MiB, so a
|
|
70
|
+
#: larger message cannot arrive whole (it would be truncated server-side
|
|
71
|
+
#: first). Mirrors WS_MAX_MSG in cpp sp_stream.h.
|
|
72
|
+
WS_MAX_MSG = 1 << 20
|
|
73
|
+
|
|
74
|
+
#: Refuse absurd length prefixes on byte-stream schemes (64 MiB).
|
|
75
|
+
_MAX_BODY = 1 << 26
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SpStreamError(Exception):
|
|
79
|
+
"""Transport-level failure on the SP stream."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SpProtocolError(SpStreamError):
|
|
83
|
+
"""Peer completed the byte handshake but is not an SP pub (32)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _tls_pointer(tls_config):
|
|
87
|
+
"""Raw ``nng_tls_config *`` out of a pynng TLSConfig.
|
|
88
|
+
|
|
89
|
+
``TLSConfig._tls_config`` is private but stable across pynng 0.9.x;
|
|
90
|
+
isolate the access here so a pynng bump touches one line.
|
|
91
|
+
"""
|
|
92
|
+
return tls_config._tls_config
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _alloc_aio(timeout_ms: int):
|
|
96
|
+
ap = ffi.new("nng_aio **")
|
|
97
|
+
check_err(lib.nng_aio_alloc(ap, ffi.NULL, ffi.NULL))
|
|
98
|
+
aio = ap[0]
|
|
99
|
+
lib.nng_aio_set_timeout(aio, timeout_ms if timeout_ms > 0 else -1)
|
|
100
|
+
return aio
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SpStream:
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
self._dialer = None
|
|
106
|
+
self._stream = None
|
|
107
|
+
self._ws_mode = False
|
|
108
|
+
self._ipc_mode = False
|
|
109
|
+
self._recv_buf = None # allocated lazily in Task 2
|
|
110
|
+
# Guards the stream-lifetime TOCTOU between a live nng call and a
|
|
111
|
+
# concurrent close(); see the module docstring.
|
|
112
|
+
self._io_cond = threading.Condition()
|
|
113
|
+
self._io_inflight = 0
|
|
114
|
+
self._closing = False
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def connected(self) -> bool:
|
|
118
|
+
return self._stream is not None
|
|
119
|
+
|
|
120
|
+
def connect(self, url: str, tls_config=None, timeout_ms: int = 5000) -> None:
|
|
121
|
+
"""Dial + (non-ws) SP handshake. Raises on any failure; the
|
|
122
|
+
stream is fully closed again before an exception propagates."""
|
|
123
|
+
self.close()
|
|
124
|
+
# Re-arm under _io_cond, only after close() fully completed, so
|
|
125
|
+
# the flag flip is atomic against a concurrent close()'s own
|
|
126
|
+
# locked ``_closing = True`` transition (a close() racing the
|
|
127
|
+
# reopen after this point is caught by Subscribe's ``_closed``
|
|
128
|
+
# re-checks, which close any stream dialed past it).
|
|
129
|
+
with self._io_cond:
|
|
130
|
+
self._closing = False
|
|
131
|
+
self._ws_mode = url.startswith("ws://") or url.startswith("wss://")
|
|
132
|
+
self._ipc_mode = url.startswith("ipc://")
|
|
133
|
+
|
|
134
|
+
dp = ffi.new("nng_stream_dialer **")
|
|
135
|
+
check_err(lib.nng_stream_dialer_alloc(dp, url.encode()))
|
|
136
|
+
self._dialer = dp[0]
|
|
137
|
+
try:
|
|
138
|
+
if self._ws_mode:
|
|
139
|
+
# mcxpub0 keeps stock pub0's SP wire name, so every
|
|
140
|
+
# existing client's subprotocol offer is accepted.
|
|
141
|
+
check_err(lib.nng_stream_dialer_set_string(
|
|
142
|
+
self._dialer, b"ws:protocol", b"pub.sp.nanomsg.org"))
|
|
143
|
+
if tls_config is not None:
|
|
144
|
+
check_err(lib.nng_stream_dialer_set_ptr(
|
|
145
|
+
self._dialer, b"tls-config", _tls_pointer(tls_config)))
|
|
146
|
+
|
|
147
|
+
aio = _alloc_aio(timeout_ms)
|
|
148
|
+
lib.nng_stream_dialer_dial(self._dialer, aio)
|
|
149
|
+
lib.nng_aio_wait(aio)
|
|
150
|
+
rv = lib.nng_aio_result(aio)
|
|
151
|
+
if rv == 0:
|
|
152
|
+
self._stream = ffi.cast(
|
|
153
|
+
"nng_stream *", lib.nng_aio_get_output(aio, 0))
|
|
154
|
+
lib.nng_aio_free(aio)
|
|
155
|
+
check_err(rv)
|
|
156
|
+
|
|
157
|
+
if not self._ws_mode:
|
|
158
|
+
self._handshake(timeout_ms)
|
|
159
|
+
except Exception:
|
|
160
|
+
self.close()
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
def _handshake(self, timeout_ms: int) -> None:
|
|
164
|
+
ours = b"\x00SP\x00" + struct.pack(">H", _SP_PROTO_SUB) + b"\x00\x00"
|
|
165
|
+
self._io(True, ours, timeout_ms)
|
|
166
|
+
peer = self._io(False, _HANDSHAKE_LEN, timeout_ms)
|
|
167
|
+
expected = b"\x00SP\x00" + struct.pack(">H", _SP_PROTO_PUB) + b"\x00\x00"
|
|
168
|
+
if peer[:6] != expected[:6]:
|
|
169
|
+
raise SpProtocolError(
|
|
170
|
+
f"peer is not an SP pub socket (got {peer!r})")
|
|
171
|
+
|
|
172
|
+
def send_msg(self, data: bytes, timeout_ms: int = 2000) -> None:
|
|
173
|
+
if self._stream is None:
|
|
174
|
+
raise SpStreamError("send on a closed SpStream")
|
|
175
|
+
if self._ws_mode:
|
|
176
|
+
self._io(True, data, timeout_ms) # message mode: 1 send = 1 msg
|
|
177
|
+
return
|
|
178
|
+
prefix = struct.pack(">Q", len(data))
|
|
179
|
+
if self._ipc_mode:
|
|
180
|
+
prefix = b"\x01" + prefix # load-bearing type byte
|
|
181
|
+
self._io(True, prefix, timeout_ms)
|
|
182
|
+
self._io(True, data, timeout_ms)
|
|
183
|
+
|
|
184
|
+
def _issue(self, op, buf, offset: int, length: int, timeout_ms: int):
|
|
185
|
+
"""Allocate an aio, set its iov, and hand it to ``op`` (a raw
|
|
186
|
+
``nng_stream_send``/``nng_stream_recv`` — the *call*, not just
|
|
187
|
+
the registration) — all atomically under ``_io_cond``.
|
|
188
|
+
|
|
189
|
+
This has to be one critical section, not "capture the stream,
|
|
190
|
+
release the lock, then call ``op``" (the original shape): a
|
|
191
|
+
concurrent ``close()`` also serializes on ``_io_cond`` for its
|
|
192
|
+
own ``_closing = True`` transition, so whichever of the two
|
|
193
|
+
critical sections runs first fully determines a safe outcome —
|
|
194
|
+
either the op is issued while the stream is still known-open
|
|
195
|
+
(and ``close()``'s later ``nng_stream_close()`` correctly wakes
|
|
196
|
+
it, per nng's contract for *already-registered* ops), or
|
|
197
|
+
``close()`` has already flipped ``_closing`` and this call bails
|
|
198
|
+
out before ever touching nng. The two-step version left a gap
|
|
199
|
+
between "session granted" / "op actually issued" that
|
|
200
|
+
``close()`` could land in: ``nng_stream_close()`` running before
|
|
201
|
+
``nng_stream_recv()``/``nng_stream_send()`` for that same aio
|
|
202
|
+
leaves it waiting forever — with a finite ``timeout_ms`` the
|
|
203
|
+
aio's own timer eventually rescues it, but ``recv_msg()``'s
|
|
204
|
+
default infinite timeout (the receive loop's steady state) has
|
|
205
|
+
no such backstop, so this used to hang the process instead of
|
|
206
|
+
surfacing the intended "stream closed" `None`.
|
|
207
|
+
|
|
208
|
+
Raises ``SpStreamError`` (stream already closing/closed) or lets
|
|
209
|
+
an ``nng_aio_set_iov`` failure propagate — in both cases nothing
|
|
210
|
+
was issued and ``_io_inflight`` was never incremented, so the
|
|
211
|
+
caller has nothing to undo. On success returns the aio with
|
|
212
|
+
``_io_inflight`` already bumped; the caller owns freeing it and
|
|
213
|
+
decrementing the counter once the op settles.
|
|
214
|
+
"""
|
|
215
|
+
with self._io_cond:
|
|
216
|
+
if self._closing or self._stream is None:
|
|
217
|
+
raise SpStreamError("stream closed mid-transfer")
|
|
218
|
+
stream = self._stream
|
|
219
|
+
aio = _alloc_aio(timeout_ms)
|
|
220
|
+
try:
|
|
221
|
+
iov = ffi.new("nng_iov *")
|
|
222
|
+
iov.iov_buf = buf + offset
|
|
223
|
+
iov.iov_len = length
|
|
224
|
+
check_err(lib.nng_aio_set_iov(aio, 1, iov))
|
|
225
|
+
except Exception:
|
|
226
|
+
lib.nng_aio_free(aio)
|
|
227
|
+
raise
|
|
228
|
+
self._io_inflight += 1
|
|
229
|
+
op(stream, aio)
|
|
230
|
+
return aio
|
|
231
|
+
|
|
232
|
+
def _settle(self, aio):
|
|
233
|
+
"""Wait for an ``_issue()``-returned aio outside the lock (the
|
|
234
|
+
blocking part must never hold ``_io_cond`` — that would stall
|
|
235
|
+
``close()``'s own attempt to grab it), then release the
|
|
236
|
+
in-flight slot. Returns ``(rv, got)``."""
|
|
237
|
+
try:
|
|
238
|
+
lib.nng_aio_wait(aio)
|
|
239
|
+
rv = lib.nng_aio_result(aio)
|
|
240
|
+
got = lib.nng_aio_count(aio) if rv == 0 else 0
|
|
241
|
+
finally:
|
|
242
|
+
lib.nng_aio_free(aio)
|
|
243
|
+
with self._io_cond:
|
|
244
|
+
self._io_inflight -= 1
|
|
245
|
+
self._io_cond.notify_all()
|
|
246
|
+
return rv, got
|
|
247
|
+
|
|
248
|
+
def _io(self, is_send: bool, data_or_len, timeout_ms: int):
|
|
249
|
+
"""Blocking send of ``bytes`` / recv of exactly N bytes.
|
|
250
|
+
One aio per call; always waited on, never cancelled. Every nng
|
|
251
|
+
call is issued via ``_issue()`` so it can't race a concurrent
|
|
252
|
+
close() into hanging (see its docstring)."""
|
|
253
|
+
if is_send:
|
|
254
|
+
buf = ffi.new("uint8_t[]", bytes(data_or_len))
|
|
255
|
+
need = len(data_or_len)
|
|
256
|
+
else:
|
|
257
|
+
need = int(data_or_len)
|
|
258
|
+
buf = ffi.new("uint8_t[]", need)
|
|
259
|
+
op = lib.nng_stream_send if is_send else lib.nng_stream_recv
|
|
260
|
+
done = 0
|
|
261
|
+
while done < need:
|
|
262
|
+
aio = self._issue(op, buf, done, need - done, timeout_ms)
|
|
263
|
+
rv, got = self._settle(aio)
|
|
264
|
+
if rv != 0:
|
|
265
|
+
check_err(rv)
|
|
266
|
+
if got == 0:
|
|
267
|
+
raise SpStreamError("stream closed mid-transfer")
|
|
268
|
+
done += got
|
|
269
|
+
return None if is_send else bytes(ffi.buffer(buf, need))
|
|
270
|
+
|
|
271
|
+
def recv_msg(self, timeout_ms: int = -1) -> Optional[bytes]:
|
|
272
|
+
"""Block for one whole SP message.
|
|
273
|
+
|
|
274
|
+
Returns ``None`` when the stream is closed/dead (remote close,
|
|
275
|
+
local close() from another thread, desync) — the receive loop's
|
|
276
|
+
exit signal. The ws invariant (nng caps ws read-ahead at one
|
|
277
|
+
frame) means one recv into a WS_MAX_MSG buffer is exactly one
|
|
278
|
+
message; byte-stream schemes reassemble via the length prefix.
|
|
279
|
+
"""
|
|
280
|
+
if self._stream is None:
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
if self._ws_mode:
|
|
284
|
+
return self._recv_ws(timeout_ms)
|
|
285
|
+
hdr_len = 9 if self._ipc_mode else 8
|
|
286
|
+
hdr = self._io(False, hdr_len, timeout_ms)
|
|
287
|
+
if self._ipc_mode:
|
|
288
|
+
if hdr[0] != 0x01:
|
|
289
|
+
logger.error("[SPSTREAM] ipc type-byte desync (0x%02x)", hdr[0])
|
|
290
|
+
return None
|
|
291
|
+
hdr = hdr[1:]
|
|
292
|
+
(length,) = struct.unpack(">Q", hdr)
|
|
293
|
+
if length == 0 or length > _MAX_BODY:
|
|
294
|
+
logger.error("[SPSTREAM] absurd frame length %d — desync", length)
|
|
295
|
+
return None
|
|
296
|
+
return self._io(False, length, timeout_ms)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.debug("[SPSTREAM] recv_msg terminating: %s", e)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def _recv_ws(self, timeout_ms: int) -> Optional[bytes]:
|
|
302
|
+
if self._recv_buf is None:
|
|
303
|
+
self._recv_buf = ffi.new("uint8_t[]", WS_MAX_MSG)
|
|
304
|
+
try:
|
|
305
|
+
aio = self._issue(lib.nng_stream_recv, self._recv_buf, 0, WS_MAX_MSG, timeout_ms)
|
|
306
|
+
except Exception:
|
|
307
|
+
return None
|
|
308
|
+
rv, got = self._settle(aio)
|
|
309
|
+
if rv != 0 or got == 0:
|
|
310
|
+
return None
|
|
311
|
+
return bytes(ffi.buffer(self._recv_buf, got))
|
|
312
|
+
|
|
313
|
+
def close(self) -> None:
|
|
314
|
+
with self._io_cond:
|
|
315
|
+
self._closing = True
|
|
316
|
+
stream, self._stream = self._stream, None
|
|
317
|
+
dialer, self._dialer = self._dialer, None
|
|
318
|
+
if stream is not None:
|
|
319
|
+
lib.nng_stream_close(stream) # wakes any pending recv/send
|
|
320
|
+
# Wait for every in-flight _issue()/_settle() caller to
|
|
321
|
+
# finish with the (now-closed, error-returning) stream
|
|
322
|
+
# pointer it already captured before we free it — this is
|
|
323
|
+
# what makes the free race-free; see module docstring and
|
|
324
|
+
# _issue()'s docstring for why issuing the op is folded into
|
|
325
|
+
# the same critical section as this ``_closing`` flip.
|
|
326
|
+
with self._io_cond:
|
|
327
|
+
while self._io_inflight > 0:
|
|
328
|
+
self._io_cond.wait()
|
|
329
|
+
lib.nng_stream_free(stream)
|
|
330
|
+
if dialer is not None:
|
|
331
|
+
lib.nng_stream_dialer_free(dialer)
|
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
"type": "motorcortex.SessionTokenMsg",
|
|
44
44
|
"hash": "0x7c3e91ab"
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
"type": "motorcortex.GetSubCookieMsg",
|
|
48
|
+
"hash": "0x5505c0b0"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "motorcortex.SubCookieMsg",
|
|
52
|
+
"hash": "0x90ff084b"
|
|
53
|
+
},
|
|
46
54
|
{
|
|
47
55
|
"type": "motorcortex.RestoreSessionMsg",
|
|
48
56
|
"hash": "0x8aef888a"
|
|
@@ -67,6 +75,14 @@
|
|
|
67
75
|
"type": "motorcortex.ParameterTreeHashMsg",
|
|
68
76
|
"hash": "0xc8cef26b"
|
|
69
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"type": "motorcortex.GetParameterTreeCompressedMsg",
|
|
80
|
+
"hash": "0x2347001b"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "motorcortex.CompressedParameterTreeMsg",
|
|
84
|
+
"hash": "0x769fb6e8"
|
|
85
|
+
},
|
|
70
86
|
{
|
|
71
87
|
"type": "motorcortex.CreateGroupMsg",
|
|
72
88
|
"hash": "0x9eab1b83"
|
|
@@ -149,7 +165,7 @@
|
|
|
149
165
|
},
|
|
150
166
|
{
|
|
151
167
|
"type": "motorcortex.StatusCode",
|
|
152
|
-
"hash": "
|
|
168
|
+
"hash": "0x6a1f3a02"
|
|
153
169
|
},
|
|
154
170
|
{
|
|
155
171
|
"type": "motorcortex.ErrorLevel",
|