SIGN IN SIGN UP

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