fix: abandon uncancellable handler instead of holding lock
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.pyto verify the bus does not hang on a blocking handler. - Known limitation: the blocking thread may persist; prefer
anyio.to_thread.run_syncoverrun_in_executorfor cancellable sync work.