Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user