fix: run sync handlers via anyio.to_thread.run_sync for cancellation

Closed
NE
2mo ago
+89-3

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 await point (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_after and await anyio.to_thread.run_sync(..., abandon_on_cancel=True); otherwise await run_sync without a deadline.
  • Cancellation abandons the await; the worker thread may continue, but the bus processes other events.
  • Adds tests/test_handler_timeout_zombie_thread.py to verify a blocking sync handler no longer blocks subsequent events after timeout.
  • Fixes #31.