hivemind-websocket-protocol 0.2.8a2__tar.gz → 0.2.8a4__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.
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol/__init__.py +60 -9
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol/version.py +1 -1
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/SOURCES.txt +1 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_close_diagnostics.py +33 -8
- hivemind_websocket_protocol-0.2.8a4/tests/test_receive_logging.py +149 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/LICENSE.md +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/README.md +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol/_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol/health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/pyproject.toml +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/setup.cfg +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_decode_auth.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_password_strength_cache.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_protocol_unit.py +0 -0
- {hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_trusted_proxy_config.py +0 -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
|
-
|
|
427
|
+
log.debug("Received %s sent base64 audio for STT", peer)
|
|
382
428
|
else:
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
16
|
-
and assert on the calls
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_client_ip.py
RENAMED
|
File without changes
|
|
File without changes
|
{hivemind_websocket_protocol-0.2.8a2 → hivemind_websocket_protocol-0.2.8a4}/tests/test_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|