fix(feishu): isolate lark_oapi WS globals per profile and supervise the client thread - #84165
Conversation
…he client thread
lark_oapi.ws.client keeps the asyncio loop used by Client.start() and all of its coroutines in a module-level global, and Hermes monkey-patches websockets.connect on the shared websockets module. In multiplex mode every profile runs its own WS client on a dedicated thread, so the N threads overwrite each other's module globals (last-write-wins): a client schedules tasks on a sibling profile's loop ('Future attached to a different loop' crashes) or binds to the wrong loop at construction time and goes deaf from the start (NousResearch#73779). Install process-wide thread-dispatching shims once: the module loop becomes a proxy forwarding to the calling thread's registered loop (all SDK reads happen on the loop's owning thread), and websockets.connect becomes a dispatcher merging per-thread ping overrides. Falls back to the legacy direct-assignment path if the shims cannot install. Also add a supervisor: the executor future was previously awaited only by disconnect(), so a dead WS thread left the profile silently deaf; now the adapter watches it and rebuilds the client with capped exponential backoff while it is supposed to be connected.
|
Soak test on a real 7-profile multiplex gateway (as offered in #73779) Ran the exact code from this branch as a live gateway for ~35 minutes against a production-configured Hermes install that runs 7 Feishu profiles in one process (multiplex mode) — the topology this fix targets. Setup
Results
Caveat
Rollback afterwards: production gateway restored, 7/7 profiles reconnected cleanly. Environment: lark-oapi 1.6.8, Python 3.12, Ubuntu (WSL2). |
fix(feishu): isolate lark_oapi WS globals per profile and supervise the client thread
|
What does this PR do?
Fixes the multiplex-mode failure where Feishu profiles either crash with
Future attached to a different loopor go deaf from the start because all profiles fight over two process-wide globals, and adds a supervisor so a dead WS client thread no longer leaves a profile silently deaf until a gateway restart.Root cause (verified against
lark-oapi 1.6.8,lark_oapi/ws/client.py):Client.start()and every coroutine it spawns (_ping_loop,_receive_message_loop,_handle_message, reconnects) in a module-level global (loop, created at import time)._run_official_feishu_ws_clienttherefore assignsws_client_module.loop = <own loop>per profile thread and monkey-patchesws_client_module.websockets.connect(which patches the sharedwebsocketsmodule) to inject per-adapter ping settings.In multiplex mode every profile runs its own WS client on a dedicated thread, so the N threads overwrite each other's globals (last-write-wins):
Future attached to a different loopcrashes, andThe fix installs process-wide, thread-dispatching shims exactly once:
ws_client_module.loopbecomes a proxy that forwards every attribute access to the loop registered by the calling thread. This is sound because all SDK reads of the global happen on the thread that owns the loop (start()blocks inrun_until_complete; everycreate_taskcallback runs on the loop's own thread). Threads that never registered one (single-profile installs, CLI) fall back to the SDK's original module loop — behavior is unchanged there.websockets.connectbecomes a single dispatcher merging the per-thread ping overrides registered by the calling profile, so profiles no longer race over the global patch or restore each other's hooks while a sibling is still connected.__wrapped__keepsinspect.signature(websockets.connect)honest for the SDK's_ws_connect_kwargs()websockets-15proxyprobe.Supervised reconnect:
lark_oapi'sstart()blocks forever on a healthy connection and only returns on fatal errors; until now the executor future was awaited solely bydisconnect(), so a dead thread left the profile silently deaf. The adapter now watches the future and, on unexpected exit while it is supposed to be connected, rebuilds the client with capped exponential backoff (5s → 60s).Related Issue
Fixes #73779
Related: #64247, #53477 (supervised-reconnect alone only covers the crash variant; this PR covers both variants plus the silent-death case).
Type of Change
Changes Made
plugins/platforms/feishu/adapter.py:_ThreadLocalLoopProxy+_install_lark_ws_isolation()(idempotent, lock-guarded, per-threadthreading.localregistry)_run_official_feishu_ws_client(): register per-thread loop + connect overrides instead of assigning shared globals; legacy fallback retained_supervise_websocket_thread(): watches_ws_future, restarts the client with capped backoff on unexpected thread exit; spawned byconnect()in websocket mode, cancelled bydisconnect()tests/gateway/test_feishu_ws_multiplex_isolation.py: 12 tests (per-thread loop dispatch under concurrency, fallback, idempotency, connect-dispatcher semantics incl. signature probe, isolated/legacy run paths with cleanup, supervised restart incl. deliberate-disconnect and failed-restart backoff)How to Test
pytest tests/gateway/test_feishu_ws_multiplex_isolation.py -q— 12 passed.pytest tests/gateway/ -k feishu -q— 216 passed, 1 skipped (no regressions);ruff checkclean.Future attached to a different loopor never receive events; after the fix each thread schedules on its own loop. I will additionally run this against a real 7-profile gateway (offered in the issue thread).Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
Failure signatures this PR eliminates (live multiplex gateway, one process, 7 profiles):