hivemind-websocket-protocol 0.2.8a2__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.8a2 → hivemind_websocket_protocol-0.2.8a3}/PKG-INFO +1 -1
  2. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/__init__.py +60 -9
  3. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/version.py +1 -1
  4. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
  5. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/SOURCES.txt +1 -0
  6. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_close_diagnostics.py +33 -8
  7. hivemind_websocket_protocol-0.2.8a3/tests/test_receive_logging.py +149 -0
  8. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/LICENSE.md +0 -0
  9. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/README.md +0 -0
  10. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/_client_ip.py +0 -0
  11. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol/health.py +0 -0
  12. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
  13. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
  14. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
  15. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
  16. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/pyproject.toml +0 -0
  17. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/setup.cfg +0 -0
  18. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_client_ip.py +0 -0
  19. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_decode_auth.py +0 -0
  20. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_health.py +0 -0
  21. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_password_strength_cache.py +0 -0
  22. {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a3}/tests/test_protocol_unit.py +0 -0
  23. {hivemind_websocket_protocol-0.2.8a2 → 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.8a2
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
@@ -106,6 +106,51 @@ def _password_handshake(password: str,
106
106
 
107
107
  # Already validated above; min_bits=0 skips the duplicate zxcvbn run.
108
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
109
154
 
110
155
 
111
156
  def _split_csv(value: Any) -> Tuple[str, ...]:
@@ -374,14 +419,18 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
374
419
  def _handle_inbound_message(self, message: str) -> None:
375
420
  message = self.client.decode(message)
376
421
  peer = self._peer_label(self.client.peer)
422
+ log = _receive_logger()
377
423
  if (
378
424
  message.msg_type == HiveMessageType.BUS
379
425
  and message.payload.msg_type == "recognizer_loop:b64_audio"
380
426
  ):
381
- LOG.debug(f"Received {peer} sent base64 audio for STT")
427
+ log.debug("Received %s sent base64 audio for STT", peer)
382
428
  else:
383
- LOG.info("Received %s message: %s", peer, message.msg_type)
384
- 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)
385
434
  self.hm_protocol.handle_message(message, self.client)
386
435
 
387
436
  def _peer_label(self, peer: str) -> str:
@@ -414,7 +463,8 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
414
463
  )
415
464
  self.close(code=1008, reason="invalid authorization")
416
465
  return
417
- 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)
418
468
 
419
469
  def do_send(payload: str, is_bin: bool):
420
470
  def _write():
@@ -508,9 +558,9 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
508
558
  def on_close(self):
509
559
  client = getattr(self, "client", None)
510
560
  if client is None:
511
- LOG.debug(
512
- f"closing unauthenticated websocket from {self.request.remote_ip} "
513
- 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
514
564
  )
515
565
  return
516
566
  # The age of the last pong cannot tell a ping timeout apart from a
@@ -524,13 +574,14 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
524
574
  since_pong = (
525
575
  time.monotonic() - self.last_pong if self.last_pong is not None else None
526
576
  )
527
- LOG.info(
577
+ log = _receive_logger()
578
+ log.info(
528
579
  "disconnecting client: %s (close_code=%s, close_reason=%s, "
529
580
  "seconds_since_last_pong=%s)",
530
581
  self._peer_label(client.peer), self.close_code, self.close_reason,
531
582
  f"{since_pong:.1f}" if since_pong is not None else "unknown",
532
583
  )
533
- LOG.debug(f"disconnecting client: {self._peer_label(client.peer)}")
584
+ log.debug("disconnecting client: %s", self._peer_label(client.peer))
534
585
  self.hm_protocol.handle_client_disconnected(client)
535
586
 
536
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 = 2
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.8a2
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
@@ -17,4 +17,5 @@ tests/test_decode_auth.py
17
17
  tests/test_health.py
18
18
  tests/test_password_strength_cache.py
19
19
  tests/test_protocol_unit.py
20
+ tests/test_receive_logging.py
20
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,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