perf(relay): run every herdr call off the event loop
Every herdr call is a subprocess the relay awaited inline, so for its whole duration it served no other client, ran no poll tick and sent no broadcast. Locally that is a few ms, but a scrollback read costs seconds and an SSH call can run to the 15s timeout. Route the 25 call sites reachable from async code through asyncio.to_thread. Measured with eight clients each reading a different pane at the same instant, against a herdr stand-in whose every read costs 0.5s: 4007ms of wall clock before, 505ms after -- the eighth client used to wait four seconds for a half-second call. The staircase is there on real local reads too, just cheap; it is the SSH and scrollback paths that make it hurt. Two of the sites were not herdr calls at all: zc.unregister_service submits a coroutine to zeroconf's own loop and waits on .result(), so called from this loop it deadlocks against itself until zeroconf gives up at _LOADED_SYSTEM_TIMEOUT -- 10.4s of a shutdown that should be instant, on every restart. register_service was already on its own thread; the teardown beside it never was. send_web_push is the same shape and a call-graph sweep misses it, because pywebpush is imported inside the function: it is requests underneath, so one push endpoint that hangs costs its connect timeout once per subscription, on the loop. Split into _deliver_push behind a thread, which then has to drop dead subscriptions BY VALUE rather than by index -- a push_subscribe arriving while those POSTs are in flight invalidates an index computed before it and would pop somebody else's subscription. A behavioural test holds the boundary, because the failure is silent: everything still works, the relay just stops answering anyone else while it runs. It puts a 0.3s subprocess under _poll_once and counts how often a 5ms ticker got scheduled. Verified red against the previous commit at exactly 0 ticks -- the loop was not merely slow, it was stopped. One site is deliberately left: the session_switch handler calls apply_session_switch inline, and moving it needs that function's blocking validation separated from its loop-thread mutation (reset_pane_state drains an asyncio.Queue and bumps POLL_GENERATION, neither safe off the loop). That is a signature change, so it comes separately, together with the structural guard that keeps new handlers from reintroducing this.
N
Nick007 committed
f2586d5483b2cd1779c314751c8d68060893ab57
Parent: 33b3eee