fix: run sync handlers via anyio.to_thread.run_sync for cancellation
Why
Sync handlers ran directly in the event loop thread (handler(event)), which blocked the loop entirely. If a sync handler hung (e.g. a C-level call), the timeout never fired and no other events could be processed — the entire event bus was frozen.
What this changes
Sync handlers now run via anyio.to_thread.run_sync(handler, event, abandon_on_cancel=True):
- The event loop stays free to enforce timeouts and process other events
- Cancellation is delivered to the
awaitpoint (anyio's mechanism) - The worker thread may still be alive (Python can't kill threads), but the bus is no longer blocked on it
Complement to #32
PR #32 (Option C) addresses the async handler path: when a handler can't be cancelled, abandon it with a warning instead of holding the global lock. This PR (Option A) addresses the sync handler path: run sync handlers in a worker thread so the timeout can actually fire. Both can be merged independently.
Fixes #31.
Summary by cubic
Run synchronous handlers with anyio.to_thread.run_sync so handler timeouts take effect and the event loop remains responsive. Previously, sync handlers ran on the event loop thread, which froze the bus when a handler blocked and prevented timeouts from firing.
- If a timeout is set, wrap the call in
anyio.fail_afterand awaitanyio.to_thread.run_sync(..., abandon_on_cancel=True); otherwise awaitrun_syncwithout a deadline. - Cancellation abandons the await; the worker thread may continue, but the bus processes other events.
- Adds
tests/test_handler_timeout_zombie_thread.pyto verify a blocking sync handler no longer blocks subsequent events after timeout. - Fixes #31.