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 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
- if not sub.connect(makeUrl(sub_address, sub_port), **kwargs).get():
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
- param_tree_reply = req.getParameterTree()
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:
@@ -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
- logger.error("[SEND] Error during send/recv: %s: %s", type(e).__name__, e)
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": "0xd785e9fa"
168
+ "hash": "0x6a1f3a02"
153
169
  },
154
170
  {
155
171
  "type": "motorcortex.ErrorLevel",