168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""Multiplex isolation for the lark_oapi WS client (issue #73779).
|
|
|
|
``lark_oapi.ws.client`` keeps the loop used by ``Client.start()`` in a
|
|
module-level global and Hermes monkey-patches ``websockets.connect`` on the
|
|
shared module. With N profile WS threads the globals were last-write-wins:
|
|
"Future attached to a different loop" crashes or a client bound to a
|
|
sibling's loop that never hears anything again.
|
|
"""
|
|
|
|
import asyncio
|
|
import sys
|
|
import threading
|
|
import types
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
from plugins.platforms.feishu import adapter as feishu_adapter
|
|
|
|
|
|
def _inject_fake_lark_module(monkeypatch, connect=None):
|
|
"""Make ``import lark_oapi.ws.client`` resolve to a module with the SDK's
|
|
global layout (``loop`` + ``websockets.connect``)."""
|
|
if connect is None:
|
|
connect = MagicMock(name="real-connect")
|
|
lark = types.ModuleType("lark_oapi")
|
|
lark_ws = types.ModuleType("lark_oapi.ws")
|
|
client_mod = types.ModuleType("lark_oapi.ws.client")
|
|
client_mod.loop = SimpleNamespace(name="sdk-default-loop")
|
|
client_mod.websockets = SimpleNamespace(connect=connect)
|
|
lark.ws = lark_ws
|
|
lark_ws.client = client_mod
|
|
monkeypatch.setitem(sys.modules, "lark_oapi", lark)
|
|
monkeypatch.setitem(sys.modules, "lark_oapi.ws", lark_ws)
|
|
monkeypatch.setitem(sys.modules, "lark_oapi.ws.client", client_mod)
|
|
monkeypatch.setattr(feishu_adapter, "_WS_ISOLATION_INSTALLED", False)
|
|
return client_mod
|
|
|
|
|
|
def _adapter_stub(**overrides):
|
|
stub = SimpleNamespace(
|
|
_ws_thread_loop=None,
|
|
_ws_reconnect_nonce=None,
|
|
_ws_reconnect_interval=None,
|
|
_ws_ping_interval=None,
|
|
_ws_ping_timeout=None,
|
|
)
|
|
for key, value in overrides.items():
|
|
setattr(stub, key, value)
|
|
return stub
|
|
|
|
|
|
def test_two_concurrent_clients_each_use_their_own_loop_and_overrides(monkeypatch):
|
|
"""Two profiles start() concurrently through the module global: each must
|
|
run on its own loop, and websockets.connect must receive only the
|
|
calling profile's ping overrides. On main both are last-write-wins."""
|
|
real_connect = MagicMock(name="real-connect")
|
|
client_mod = _inject_fake_lark_module(monkeypatch, connect=real_connect)
|
|
|
|
results = {}
|
|
barrier = threading.Barrier(2)
|
|
|
|
class FakeClient:
|
|
def __init__(self, name):
|
|
self._name = name
|
|
|
|
def start(self):
|
|
barrier.wait(timeout=10) # both threads past the global "assign"
|
|
|
|
async def probe():
|
|
await asyncio.sleep(0.02)
|
|
return id(asyncio.get_running_loop())
|
|
|
|
results[self._name] = client_mod.loop.run_until_complete(probe())
|
|
client_mod.websockets.connect(f"wss://{self._name}")
|
|
|
|
pings = {"p0": 10, "p1": 20}
|
|
|
|
def run(name):
|
|
feishu_adapter._run_official_feishu_ws_client(
|
|
FakeClient(name), _adapter_stub(_ws_ping_interval=pings[name])
|
|
)
|
|
|
|
threads = [threading.Thread(target=run, args=(f"p{i}",)) for i in range(2)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=15)
|
|
assert not t.is_alive()
|
|
|
|
assert results["p0"] != results["p1"]
|
|
calls = {c.args[0]: c.kwargs for c in real_connect.call_args_list}
|
|
assert calls == {"wss://p0": {"ping_interval": 10}, "wss://p1": {"ping_interval": 20}}
|
|
# Thread-local registrations are cleared for the pooled executor thread.
|
|
assert getattr(feishu_adapter._ws_isolation_state, "loop", None) is None
|
|
assert getattr(feishu_adapter._ws_isolation_state, "connect_kwargs", None) is None
|
|
|
|
|
|
def _supervisor_stub():
|
|
stub = SimpleNamespace(
|
|
_running=True,
|
|
_ws_future=None,
|
|
_ws_client=object(),
|
|
_ws_restart_backoff=0.01,
|
|
connect_calls=0,
|
|
connect_should_fail=0,
|
|
)
|
|
|
|
async def _connect_websocket():
|
|
stub.connect_calls += 1
|
|
if stub.connect_should_fail > 0:
|
|
stub.connect_should_fail -= 1
|
|
raise RuntimeError("simulated restart failure")
|
|
fut = asyncio.get_running_loop().create_future()
|
|
fut.set_result(None) # new thread dies immediately too
|
|
stub._ws_future = fut
|
|
|
|
stub._connect_websocket = _connect_websocket
|
|
return stub
|
|
|
|
|
|
def test_supervisor_restarts_a_dead_ws_thread_with_backoff():
|
|
"""A dead WS thread used to leave the profile silently deaf (the future
|
|
was awaited only by disconnect()). The supervisor must rebuild the client
|
|
and survive a failed restart without hot-looping."""
|
|
|
|
async def scenario():
|
|
stub = _supervisor_stub()
|
|
stub.connect_should_fail = 1
|
|
fut = asyncio.get_running_loop().create_future()
|
|
fut.set_result(None) # the WS "thread" is already dead
|
|
stub._ws_future = fut
|
|
|
|
task = asyncio.ensure_future(
|
|
feishu_adapter.FeishuAdapter._supervise_websocket_thread(stub)
|
|
)
|
|
for _ in range(300):
|
|
await asyncio.sleep(0.01)
|
|
if stub.connect_calls >= 2:
|
|
break
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
return stub.connect_calls
|
|
|
|
assert asyncio.run(scenario()) == 2 # failed restart, then a successful one
|
|
|
|
|
|
def test_supervisor_stops_when_disconnect_nils_the_client():
|
|
async def scenario():
|
|
stub = _supervisor_stub()
|
|
fut = asyncio.get_running_loop().create_future() # thread "alive"
|
|
stub._ws_future = fut
|
|
|
|
task = asyncio.ensure_future(
|
|
feishu_adapter.FeishuAdapter._supervise_websocket_thread(stub)
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
stub._ws_client = None # deliberate disconnect ...
|
|
fut.set_result(None) # ... then the thread exits
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
|
return stub, task
|
|
|
|
stub, task = asyncio.run(scenario())
|
|
assert task.done()
|
|
assert stub.connect_calls == 0
|