hivemind-websocket-protocol 0.2.8a1__tar.gz → 0.2.8a3__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.
Files changed (23) hide show
  1. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/PKG-INFO +1 -1
  2. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/__init__.py +114 -11
  3. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/version.py +1 -1
  4. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
  5. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/SOURCES.txt +2 -0
  6. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_close_diagnostics.py +33 -8
  7. hivemind_websocket_protocol-0.2.8a3/tests/test_password_strength_cache.py +105 -0
  8. hivemind_websocket_protocol-0.2.8a3/tests/test_receive_logging.py +149 -0
  9. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/LICENSE.md +0 -0
  10. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/README.md +0 -0
  11. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/_client_ip.py +0 -0
  12. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/health.py +0 -0
  13. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
  14. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
  15. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
  16. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
  17. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/pyproject.toml +0 -0
  18. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/setup.cfg +0 -0
  19. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_client_ip.py +0 -0
  20. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_decode_auth.py +0 -0
  21. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_health.py +0 -0
  22. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_protocol_unit.py +0 -0
  23. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a3}/tests/test_trusted_proxy_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-websocket-protocol
3
- Version: 0.2.8a1
3
+ Version: 0.2.8a3
4
4
  Summary: websocket network protocol for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License-Expression: Apache-2.0
@@ -1,11 +1,13 @@
1
1
  import asyncio
2
2
  import dataclasses
3
+ import hashlib
3
4
  import math
4
5
  import os
5
6
  import os.path
6
7
  import random
7
8
  import threading
8
9
  import time
10
+ from collections import OrderedDict
9
11
  from os import makedirs
10
12
  from os.path import exists, join
11
13
  from socket import gethostname
@@ -18,7 +20,7 @@ from OpenSSL import crypto
18
20
  from ovos_bus_client.session import Session
19
21
  from ovos_utils.log import LOG
20
22
  from ovos_utils.xdg_utils import xdg_data_home
21
- from poorman_handshake import PasswordHandShake
23
+ from poorman_handshake import PasswordHandShake, check_password_strength
22
24
  from tornado import ioloop
23
25
  from tornado import web
24
26
  from tornado.platform.asyncio import AnyThreadEventLoopPolicy
@@ -56,6 +58,101 @@ DEFAULT_WEBSOCKET_PING_INTERVAL = 30.0
56
58
  DEFAULT_WEBSOCKET_PING_TIMEOUT = 20.0
57
59
 
58
60
 
61
+ #: Passwords already checked against a given policy, most recent last.
62
+ #:
63
+ #: ``PasswordHandShake(password, min_bits=N)`` runs the credential through
64
+ #: zxcvbn on construction. That is the right thing to do, but it is ~2.2 ms
65
+ #: and Core builds one per admission on the single Tornado IOLoop, so a fleet
66
+ #: reconnecting at once serialises behind it: 400 satellites is ~0.87 s of
67
+ #: event loop spent re-deciding that the same handful of passwords are still
68
+ #: strong.
69
+ #:
70
+ #: Entries are keyed on a *keyed* blake2s digest and the policy that accepted
71
+ #: it. The key is per-process and never persisted, so this is an LRU lookup
72
+ #: key, not a stored password hash; rotating a password or tightening
73
+ #: ``min_bits`` misses the cache and re-validates.
74
+ _PASSWORD_STRENGTH_LOCK = threading.Lock()
75
+ _PASSWORD_STRENGTH_CACHE: "OrderedDict[Tuple[bytes, float], None]" = OrderedDict()
76
+ _PASSWORD_STRENGTH_CACHE_KEY = os.urandom(32)
77
+ _PASSWORD_STRENGTH_CACHE_SIZE = 4096
78
+
79
+
80
+ def _password_handshake(password: str,
81
+ min_bits: Optional[float] = None) -> PasswordHandShake:
82
+ """Build a PasswordHandShake, validating each password once per policy.
83
+
84
+ Raises ``WeakPasswordError`` exactly as the plain constructor does -- a
85
+ weak password is never cached, so it is rejected on every attempt.
86
+ """
87
+ if min_bits is None:
88
+ min_bits = runtime_password_min_bits()
89
+
90
+ if min_bits > 0:
91
+ digest = hashlib.blake2s(
92
+ password.encode("utf-8"),
93
+ key=_PASSWORD_STRENGTH_CACHE_KEY,
94
+ ).digest()
95
+ cache_key = (digest, min_bits)
96
+ with _PASSWORD_STRENGTH_LOCK:
97
+ if cache_key in _PASSWORD_STRENGTH_CACHE:
98
+ _PASSWORD_STRENGTH_CACHE.move_to_end(cache_key)
99
+ else:
100
+ # Outside the cache-hit branch on purpose: a rejection must
101
+ # propagate and must not be remembered as a pass.
102
+ check_password_strength(password, min_bits=min_bits)
103
+ _PASSWORD_STRENGTH_CACHE[cache_key] = None
104
+ while len(_PASSWORD_STRENGTH_CACHE) > _PASSWORD_STRENGTH_CACHE_SIZE:
105
+ _PASSWORD_STRENGTH_CACHE.popitem(last=False)
106
+
107
+ # Already validated above; min_bits=0 skips the duplicate zxcvbn run.
108
+ return PasswordHandShake(password, min_bits=0)
109
+ #: Connection hot-path logger, resolved once.
110
+ #:
111
+ #: ``LOG.debug``/``LOG.info`` resolve the calling module, function and line
112
+ #: with ``inspect.stack()`` on *every* call, before the level is checked, so a
113
+ #: discarded DEBUG record costs the same as an emitted one. Admission, receive
114
+ #: and disconnect all run on Tornado's single IOLoop that serves every
115
+ #: connected satellite, so that cost is paid per connection and per inbound
116
+ #: frame, and delays every other peer on the node.
117
+ #:
118
+ #: ``LOG.create_logger`` returns the same OVOS-configured logger those calls
119
+ #: would have used -- same formatter, stdout and rotating-file handlers -- and
120
+ #: registers it in ``LOG._loggers``, so a later ``LOG.init``/``LOG.set_level``
121
+ #: still retargets its level. Only the per-call stack walk is dropped. It is
122
+ #: resolved lazily because ``LOG.init`` usually runs after this import.
123
+ _RECEIVE_LOGGER = None
124
+ _RECEIVE_LOGGER_KEY = None
125
+ _RECEIVE_LOGGER_LOCK = threading.Lock()
126
+
127
+
128
+ def _receive_logger():
129
+ """Return the cached hot-path logger, rebuilding when LOG rewires.
130
+
131
+ Cached against ``(LOG.name, LOG.base_path)``: ``LOG.init()`` normally runs
132
+ after import, and a logger created before it would carry only the stdout
133
+ handler -- configured file logging would silently vanish from this path,
134
+ because init does not rebuild handlers on existing loggers. When the
135
+ fingerprint changes, the stale entry and its handlers are dropped so
136
+ ``create_logger`` rebuilds against the live config. The lock keeps two
137
+ racing first frames from attaching duplicate handlers to the same
138
+ process-wide ``logging.getLogger`` name.
139
+ """
140
+ global _RECEIVE_LOGGER, _RECEIVE_LOGGER_KEY
141
+ key = (LOG.name, LOG.base_path)
142
+ if _RECEIVE_LOGGER is None or _RECEIVE_LOGGER_KEY != key:
143
+ with _RECEIVE_LOGGER_LOCK:
144
+ if _RECEIVE_LOGGER is None or _RECEIVE_LOGGER_KEY != key:
145
+ name = f"{LOG.name} - {__name__}"
146
+ stale = LOG._loggers.pop(name, None)
147
+ if stale is not None:
148
+ for handler in list(stale.handlers):
149
+ stale.removeHandler(handler)
150
+ handler.close()
151
+ _RECEIVE_LOGGER = LOG.create_logger(name)
152
+ _RECEIVE_LOGGER_KEY = key
153
+ return _RECEIVE_LOGGER
154
+
155
+
59
156
  def _split_csv(value: Any) -> Tuple[str, ...]:
60
157
  if not value:
61
158
  return ()
@@ -322,14 +419,18 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
322
419
  def _handle_inbound_message(self, message: str) -> None:
323
420
  message = self.client.decode(message)
324
421
  peer = self._peer_label(self.client.peer)
422
+ log = _receive_logger()
325
423
  if (
326
424
  message.msg_type == HiveMessageType.BUS
327
425
  and message.payload.msg_type == "recognizer_loop:b64_audio"
328
426
  ):
329
- LOG.debug(f"Received {peer} sent base64 audio for STT")
427
+ log.debug("Received %s sent base64 audio for STT", peer)
330
428
  else:
331
- LOG.info("Received %s message: %s", peer, message.msg_type)
332
- LOG.debug(f"Received {peer} message: {message}")
429
+ log.info("Received %s message: %s", peer, message.msg_type)
430
+ # Lazy args, never an f-string: ``HiveMessage.__str__`` serializes
431
+ # the whole envelope to JSON, and that must not run when DEBUG is
432
+ # off. It also keeps a user's transcribed speech out of the cost.
433
+ log.debug("Received %s message: %s", peer, message)
333
434
  self.hm_protocol.handle_message(message, self.client)
334
435
 
335
436
  def _peer_label(self, peer: str) -> str:
@@ -362,7 +463,8 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
362
463
  )
363
464
  self.close(code=1008, reason="invalid authorization")
364
465
  return
365
- LOG.debug(f"Authorizing client from {self.source_ip or 'unknown'} - {useragent}")
466
+ _receive_logger().debug("Authorizing client from %s - %s",
467
+ self.source_ip or "unknown", useragent)
366
468
 
367
469
  def do_send(payload: str, is_bin: bool):
368
470
  def _write():
@@ -430,7 +532,7 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
430
532
  self.client.is_admin = user.is_admin
431
533
  if user.password:
432
534
  # pre-shared password to derive aes_key
433
- self.client.pswd_handshake = PasswordHandShake(user.password, min_bits=runtime_password_min_bits())
535
+ self.client.pswd_handshake = _password_handshake(user.password)
434
536
 
435
537
  self.client.node_type = HiveMindNodeType.NODE # TODO . placeholder
436
538
 
@@ -456,9 +558,9 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
456
558
  def on_close(self):
457
559
  client = getattr(self, "client", None)
458
560
  if client is None:
459
- LOG.debug(
460
- f"closing unauthenticated websocket from {self.request.remote_ip} "
461
- f"(no client was ever attached)"
561
+ _receive_logger().debug(
562
+ "closing unauthenticated websocket from %s "
563
+ "(no client was ever attached)", self.request.remote_ip
462
564
  )
463
565
  return
464
566
  # The age of the last pong cannot tell a ping timeout apart from a
@@ -472,13 +574,14 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
472
574
  since_pong = (
473
575
  time.monotonic() - self.last_pong if self.last_pong is not None else None
474
576
  )
475
- LOG.info(
577
+ log = _receive_logger()
578
+ log.info(
476
579
  "disconnecting client: %s (close_code=%s, close_reason=%s, "
477
580
  "seconds_since_last_pong=%s)",
478
581
  self._peer_label(client.peer), self.close_code, self.close_reason,
479
582
  f"{since_pong:.1f}" if since_pong is not None else "unknown",
480
583
  )
481
- LOG.debug(f"disconnecting client: {self._peer_label(client.peer)}")
584
+ log.debug("disconnecting client: %s", self._peer_label(client.peer))
482
585
  self.hm_protocol.handle_client_disconnected(client)
483
586
 
484
587
  def check_origin(self, origin) -> bool:
@@ -2,7 +2,7 @@
2
2
  VERSION_MAJOR = 0
3
3
  VERSION_MINOR = 2
4
4
  VERSION_BUILD = 8
5
- VERSION_ALPHA = 1
5
+ VERSION_ALPHA = 3
6
6
  # END_VERSION_BLOCK
7
7
 
8
8
  __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-websocket-protocol
3
- Version: 0.2.8a1
3
+ Version: 0.2.8a3
4
4
  Summary: websocket network protocol for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License-Expression: Apache-2.0
@@ -15,5 +15,7 @@ tests/test_client_ip.py
15
15
  tests/test_close_diagnostics.py
16
16
  tests/test_decode_auth.py
17
17
  tests/test_health.py
18
+ tests/test_password_strength_cache.py
18
19
  tests/test_protocol_unit.py
20
+ tests/test_receive_logging.py
19
21
  tests/test_trusted_proxy_config.py
@@ -12,12 +12,16 @@ a peer that timed out echoes nothing.
12
12
 
13
13
  `ovos_utils.log.LOG` sets `propagate = False` on its underlying logger, so
14
14
  `caplog`'s root-logger capture can silently miss records depending on test
15
- order. These tests instead patch `hivemind_websocket_protocol.LOG` directly
16
- and assert on the calls it receives.
15
+ order. These tests instead patch the module's cached hot-path logger
16
+ (`_RECEIVE_LOGGER`, returned by `_receive_logger()`) and assert on the calls
17
+ it receives.
17
18
  """
19
+ import re
18
20
  import time
19
21
  from unittest.mock import MagicMock
20
22
 
23
+ import pytest
24
+
21
25
  from hivemind_core.protocol import HiveMindClientConnection
22
26
  from hivemind_websocket_protocol import (
23
27
  DEFAULT_WEBSOCKET_PING_INTERVAL,
@@ -64,6 +68,13 @@ def _logged(log):
64
68
  return log.info.call_args[0][0] % log.info.call_args[0][1:]
65
69
 
66
70
 
71
+ def _pong_age(message):
72
+ """The `seconds_since_last_pong=` value logged, as a float."""
73
+ match = re.search(r"seconds_since_last_pong=([\d.]+)", message)
74
+ assert match, message
75
+ return float(match.group(1))
76
+
77
+
67
78
  def test_on_pong_stamps_last_pong():
68
79
  handler = object.__new__(HiveMindTornadoWebSocket)
69
80
  handler.last_pong = None
@@ -75,7 +86,9 @@ def test_on_pong_stamps_last_pong():
75
86
 
76
87
  def test_close_reports_code_reason_and_pong_age(monkeypatch):
77
88
  log = MagicMock()
78
- monkeypatch.setattr(hwp, "LOG", log)
89
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
90
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
91
+ (hwp.LOG.name, hwp.LOG.base_path))
79
92
  handler = _handler(
80
93
  last_pong=time.monotonic() - 12,
81
94
  close_code=1006,
@@ -98,7 +111,9 @@ def test_a_real_ping_timeout_is_reported_with_its_pong_age(monkeypatch):
98
111
  is what the log must report - no threshold separates it from an ordinary
99
112
  disconnect."""
100
113
  log = MagicMock()
101
- monkeypatch.setattr(hwp, "LOG", log)
114
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
115
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
116
+ (hwp.LOG.name, hwp.LOG.base_path))
102
117
  round_trip = 0.3
103
118
  age = DEFAULT_WEBSOCKET_PING_INTERVAL + DEFAULT_WEBSOCKET_PING_TIMEOUT - round_trip
104
119
  handler = _handler(last_pong=time.monotonic() - age)
@@ -113,17 +128,25 @@ def test_no_pong_age_ever_triggers_a_verdict(monkeypatch):
113
128
  one factual line, never a guess about the cause."""
114
129
  for age in (0.5, 25, 49.7, 50, 120, 3600):
115
130
  log = MagicMock()
116
- monkeypatch.setattr(hwp, "LOG", log)
131
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
132
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
133
+ (hwp.LOG.name, hwp.LOG.base_path))
117
134
  handler = _handler(last_pong=time.monotonic() - age)
118
135
 
119
136
  handler.on_close()
120
137
 
121
- assert f"seconds_since_last_pong={age:.1f}" in _logged(log)
138
+ # The exact decimal is timing-dependent (wall-clock elapses between
139
+ # `_handler()` stamping `last_pong` and `on_close()` reading it), so
140
+ # only the reported age's ballpark - not its literal formatting - is
141
+ # asserted here.
142
+ assert _pong_age(_logged(log)) == pytest.approx(age, abs=1.0)
122
143
 
123
144
 
124
145
  def test_client_that_never_ponged_reports_unknown_age(monkeypatch):
125
146
  log = MagicMock()
126
- monkeypatch.setattr(hwp, "LOG", log)
147
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
148
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
149
+ (hwp.LOG.name, hwp.LOG.base_path))
127
150
  handler = _handler(last_pong=None)
128
151
 
129
152
  handler.on_close()
@@ -133,7 +156,9 @@ def test_client_that_never_ponged_reports_unknown_age(monkeypatch):
133
156
 
134
157
  def test_on_close_never_logs_payload_or_access_key(monkeypatch):
135
158
  log = MagicMock()
136
- monkeypatch.setattr(hwp, "LOG", log)
159
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER", log)
160
+ monkeypatch.setattr(hwp, "_RECEIVE_LOGGER_KEY",
161
+ (hwp.LOG.name, hwp.LOG.base_path))
137
162
  for last_pong in (time.monotonic() - 1, time.monotonic() - 3600):
138
163
  handler = _handler(last_pong=last_pong)
139
164
  handler.on_close()
@@ -0,0 +1,105 @@
1
+ """Admission must not re-run zxcvbn for every connecting satellite.
2
+
3
+ ``PasswordHandShake`` validates the credential on construction, and Core builds
4
+ one per admission on the single Tornado IOLoop. Validating the same password
5
+ 400 times costs ~0.87 s of event loop that no satellite can use.
6
+ """
7
+ import threading
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+ from poorman_handshake import WeakPasswordError
12
+
13
+ import hivemind_websocket_protocol as hwp
14
+
15
+ STRONG = "correct-horse-battery-staple-9271"
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _clear_cache():
20
+ hwp._PASSWORD_STRENGTH_CACHE.clear()
21
+ yield
22
+ hwp._PASSWORD_STRENGTH_CACHE.clear()
23
+
24
+
25
+ def test_repeated_admissions_validate_once():
26
+ with patch.object(hwp, "check_password_strength") as check:
27
+ for _ in range(400):
28
+ hwp._password_handshake(STRONG, min_bits=64)
29
+
30
+ assert check.call_count == 1, (
31
+ "each admission re-ran the strength check; a reconnecting fleet pays "
32
+ "this on the IOLoop"
33
+ )
34
+
35
+
36
+ def test_handshake_is_not_shared_between_connections():
37
+ """Only the verdict is cached — handshake state is per connection."""
38
+ first = hwp._password_handshake(STRONG, min_bits=64)
39
+ second = hwp._password_handshake(STRONG, min_bits=64)
40
+
41
+ assert first is not second
42
+
43
+
44
+ def test_weak_password_is_rejected_every_time():
45
+ """A rejection must never be remembered as a pass."""
46
+ for _ in range(3):
47
+ with pytest.raises(WeakPasswordError):
48
+ hwp._password_handshake("123456", min_bits=64)
49
+
50
+ assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
51
+
52
+
53
+ def test_a_tightened_policy_revalidates():
54
+ hwp._password_handshake(STRONG, min_bits=40)
55
+
56
+ with patch.object(hwp, "check_password_strength") as check:
57
+ hwp._password_handshake(STRONG, min_bits=64)
58
+
59
+ assert check.call_count == 1, "min_bits is part of the cache key"
60
+
61
+
62
+ def test_a_rotated_password_revalidates():
63
+ hwp._password_handshake(STRONG, min_bits=64)
64
+
65
+ with patch.object(hwp, "check_password_strength") as check:
66
+ hwp._password_handshake(STRONG + "-rotated", min_bits=64)
67
+
68
+ assert check.call_count == 1
69
+
70
+
71
+ def test_disabled_policy_does_not_validate_or_cache():
72
+ with patch.object(hwp, "check_password_strength") as check:
73
+ hwp._password_handshake(STRONG, min_bits=0)
74
+
75
+ assert check.call_count == 0
76
+ assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
77
+
78
+
79
+ def test_cache_is_bounded():
80
+ limit = hwp._PASSWORD_STRENGTH_CACHE_SIZE
81
+ with patch.object(hwp, "check_password_strength"):
82
+ for i in range(limit + 50):
83
+ hwp._password_handshake(f"{STRONG}-{i}", min_bits=64)
84
+
85
+ assert len(hwp._PASSWORD_STRENGTH_CACHE) <= limit
86
+
87
+
88
+ def test_concurrent_admissions_are_safe():
89
+ """Admission runs from Tornado's loop and executor threads alike."""
90
+ errors = []
91
+
92
+ def admit():
93
+ try:
94
+ for _ in range(50):
95
+ hwp._password_handshake(STRONG, min_bits=64)
96
+ except Exception as e: # noqa: BLE001
97
+ errors.append(e)
98
+
99
+ threads = [threading.Thread(target=admit) for _ in range(8)]
100
+ for t in threads:
101
+ t.start()
102
+ for t in threads:
103
+ t.join()
104
+
105
+ assert errors == []
@@ -0,0 +1,149 @@
1
+ """The websocket receive path must not pay for logging it discards.
2
+
3
+ ``LOG.debug``/``LOG.info`` walk ``inspect.stack()`` on every call to label the
4
+ record with the caller, *before* the level is consulted, so a dropped DEBUG
5
+ record costs as much as an emitted one. ``_handle_inbound_message`` runs on the
6
+ single Tornado IOLoop that serves every connected satellite, so anything it
7
+ pays there is paid again by every other peer on the node.
8
+ """
9
+ import logging
10
+ from types import SimpleNamespace
11
+
12
+ import pytest
13
+ from ovos_utils.log import LOG
14
+
15
+ import hivemind_websocket_protocol as hwp
16
+ from hivemind_websocket_protocol import HiveMindTornadoWebSocket
17
+
18
+
19
+ class _CountingMessage:
20
+ """Stands in for a HiveMessage, whose ``__str__`` serializes to JSON."""
21
+
22
+ def __init__(self):
23
+ self.msg_type = "bus"
24
+ self.payload = SimpleNamespace(msg_type="recognizer_loop:utterance")
25
+ self.renders = 0
26
+
27
+ def __str__(self):
28
+ self.renders += 1
29
+ return '{"msg_type": "bus", "payload": "secret utterance"}'
30
+
31
+
32
+ @pytest.fixture
33
+ def handler():
34
+ """A handler wired up just enough to run ``_handle_inbound_message``."""
35
+ h = HiveMindTornadoWebSocket.__new__(HiveMindTornadoWebSocket)
36
+ h.source_ip = "127.0.0.1"
37
+ h.hm_protocol = SimpleNamespace(handle_message=lambda msg, client: None)
38
+ return h
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _reset_receive_logger():
43
+ """Drop the cache and the logging singleton's handlers between tests."""
44
+ def reset():
45
+ name = f"{LOG.name} - {hwp.__name__}"
46
+ stale = logging.getLogger(name)
47
+ for handler in list(stale.handlers):
48
+ stale.removeHandler(handler)
49
+ handler.close()
50
+ LOG._loggers.pop(name, None)
51
+ hwp._RECEIVE_LOGGER = None
52
+ hwp._RECEIVE_LOGGER_KEY = None
53
+ reset()
54
+ yield
55
+ reset()
56
+
57
+
58
+ def _deliver(handler, message):
59
+ handler.client = SimpleNamespace(decode=lambda raw: message, peer="tcp4:peer")
60
+ handler._handle_inbound_message("raw-frame")
61
+
62
+
63
+ def test_payload_not_rendered_when_debug_is_disabled(handler):
64
+ """At INFO the envelope is never serialized — that is the whole point."""
65
+ hwp._receive_logger().setLevel(logging.INFO)
66
+ message = _CountingMessage()
67
+
68
+ _deliver(handler, message)
69
+
70
+ assert message.renders == 0, (
71
+ "the inbound envelope was serialized for a DEBUG record that was "
72
+ "discarded; pass it as a lazy argument instead of formatting it"
73
+ )
74
+
75
+
76
+ def test_payload_is_still_rendered_when_debug_is_enabled(handler):
77
+ """Laziness must not cost the operator the debug output itself."""
78
+ hwp._receive_logger().setLevel(logging.DEBUG)
79
+ message = _CountingMessage()
80
+
81
+ _deliver(handler, message)
82
+
83
+ # ">= 1" not "== 1": every attached handler formats the record, and pytest
84
+ # adds its own capture handlers on top of the stdout one.
85
+ assert message.renders >= 1
86
+
87
+
88
+ def test_b64_audio_frames_are_not_rendered(handler):
89
+ """Audio frames are already special-cased; keep them that way."""
90
+ hwp._receive_logger().setLevel(logging.DEBUG)
91
+ message = _CountingMessage()
92
+ message.payload = SimpleNamespace(msg_type="recognizer_loop:b64_audio")
93
+
94
+ _deliver(handler, message)
95
+
96
+ assert message.renders == 0
97
+
98
+
99
+ def test_receive_logger_is_resolved_once(handler):
100
+ """One stack walk per process, not one per inbound frame."""
101
+ assert hwp._receive_logger() is hwp._receive_logger()
102
+
103
+
104
+ def test_receive_logger_follows_log_set_level():
105
+ """Caching must not pin the level: LOG.init/set_level still retargets it."""
106
+ log = hwp._receive_logger()
107
+ assert log.name in LOG._loggers, (
108
+ "the receive logger must be registered with LOG so set_level reaches it"
109
+ )
110
+ previous = LOG.level
111
+ try:
112
+ LOG.set_level("DEBUG")
113
+ assert log.level == logging.DEBUG
114
+ LOG.set_level("WARNING")
115
+ assert log.level == logging.WARNING
116
+ finally:
117
+ LOG.set_level(previous)
118
+
119
+
120
+ def test_logger_rewires_after_log_init_changes_base_path(tmp_path):
121
+ """init after first use must not strand this path on stdout-only."""
122
+ hwp._receive_logger()
123
+ previous = LOG.base_path
124
+ try:
125
+ LOG.base_path = str(tmp_path)
126
+ kinds = {type(h).__name__ for h in hwp._receive_logger().handlers}
127
+ assert "RotatingFileHandler" in kinds
128
+ finally:
129
+ LOG.base_path = previous
130
+
131
+
132
+ def test_concurrent_first_use_attaches_handlers_once():
133
+ import threading
134
+ gate = threading.Event()
135
+
136
+ def resolve():
137
+ gate.wait()
138
+ hwp._receive_logger()
139
+
140
+ threads = [threading.Thread(target=resolve) for _ in range(8)]
141
+ for t in threads:
142
+ t.start()
143
+ gate.set()
144
+ for t in threads:
145
+ t.join(timeout=10)
146
+
147
+ streams = [h for h in hwp._receive_logger().handlers
148
+ if type(h).__name__ == "StreamHandler"]
149
+ assert len(streams) <= 1