Files
pikl/docs/lessons/dyn-safe-hook-traits.md

2.2 KiB

Lesson: async fn in traits vs dyn-compatibility

The problem

The HookHandler trait needs to be stored as Arc<dyn HookHandler> so the menu engine can hold a type-erased handler without knowing whether it's an exec hook, a handler hook, or a composite. But async fn in traits (even with edition 2024's native support) makes the trait not dyn-compatible. The compiler can't build a vtable for an async method because the return type (an opaque impl Future) varies per implementation.

What we tried

Official documentation of edition 2024 supports async fn in traits natively, async-trait is NOT needed." That's true for static dispatch (generics), but wrong for dyn dispatch.

The fix

Made handle() a regular sync fn. The trait is deliberately synchronous:

pub trait HookHandler: Send + Sync {
    fn handle(&self, event: HookEvent)
        -> Result<Vec<HookResponse>, PiklError>;
}

This works because handlers don't actually need to await anything at the call site:

  • Exec hooks spawn a subprocess via tokio::spawn and return immediately. The process runs in the background.
  • Handler hooks write to an internal channel via try_send (non-blocking) and return. A background task handles the actual I/O.

Both return Ok(vec![]) instantly. Responses from handler hooks flow back through the action channel asynchronously.

Takeaway

async fn in trait (edition 2024) is for generic/static dispatch. If you need dyn Trait, you still need either:

  1. A sync method (if the work can be spawned internally)
  2. A boxed future return type
  3. The async-trait crate

Option 1 is the simplest when the handler doesn't need to return data from async work. If responses are needed, they go through a separate channel rather than the return value.

Tests that verify debounce timing need tokio::time::pause and tokio::time::advance, which require the test-util feature on tokio in dev-dependencies. Use #[tokio::test(start_paused = true)] and tokio::time::sleep() (which auto-advances in paused mode) rather than manual pause() + advance() + yield_now() chains, which are fragile with spawned tasks.