fix: abandon uncancellable handler instead of holding lock

Closed
NE
2mo ago
+75-3

Why

When a handler is blocked in synchronous code (via run_in_executor or a C-level call), CancelledError can't be delivered — it only fires at the next await point, which the blocking thread never reaches. The finally block in execute_handler waited only 0.1s then silently abandoned the task, but the global lock was held during that wait.

What this changes

  • Increased the cleanup grace period from 0.1s to 1.0s (gives cancellable handlers time to respond)
  • If the task still isn't done after 1s, logs a warning: "handler could not be cancelled (still running after 1s). It is likely blocked in synchronous code and will be abandoned as a zombie thread."
  • Returns immediately without further waiting — the global lock is released so other events can proceed

Limitation

The zombie thread is still alive (Python can't kill threads blocked in C-level code). This PR stops the bus from hanging; the companion PR (#32) addresses the root cause by recommending anyio.to_thread.run_sync which supports proper cancellation.

Fixes #31.


Summary by cubic

Abandons uncancellable handler tasks without holding the global lock, preventing bus hangs when a handler blocks in synchronous code. Old behavior: waited 0.1s under the lock and silently abandoned; new behavior: wait up to 1s, emit a warning if still running, then release the lock immediately.

  • Cleanup grace period is now 1.0s (was 0.1s) to let cancellable handlers exit.
  • If the task is still running after 1s, log a warning and abandon it; the global lock is released so other events proceed.
  • Adds tests/test_handler_timeout_zombie_thread.py to verify the bus does not hang on a blocking handler.
  • Known limitation: the blocking thread may persist; prefer anyio.to_thread.run_sync over run_in_executor for cancellable sync work.