Skip to content

homie: inbound /set async dispatch, thread-safe and promoted to the root - #23

Merged
dcj merged 1 commit into
mainfrom
feat/inbound-set-async-loop
Aug 3, 2026
Merged

homie: inbound /set async dispatch, thread-safe and promoted to the root#23
dcj merged 1 commit into
mainfrom
feat/inbound-set-async-loop

Conversation

@dcj

@dcj dcj commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #15. The inbound half of the async-host story.

What it does

  • Thread-safe dispatch. Inbound /set arrives on the transport's network-loop thread. An async (coroutine) set_callback is now scheduled onto the consumer's event loop with asyncio.run_coroutine_threadsafe instead of asyncio.ensure_future (which is not safe to call from a thread other than the loop's own). The branch is on the callback's actual return (asyncio.iscoroutine), not on async_loop's presence, so a synchronous callback keeps running inline even when a tree-wide loop is set. An exception raised inside an async callback is logged (the discarded scheduling Future would otherwise swallow it).
  • Promoted to the root. async_loop is now a Device(async_loop=...) param that propagates to every property via add_node() / Node.add_property() (mirroring _qos) and is inherited by child devices. A tree with N settable properties sets the loop once, not N times. The per-Property async_loop still works and is preserved when the device sets none.
  • Default fix. Optional[...] = False -> Optional[asyncio.AbstractEventLoop] = None (a bool against the loop annotation, surfaced by py.typed).
  • Node mutable default. properties: dict = {} -> Optional[dict] = None (a shared-dict footgun the new propagation reads/writes through).

Review

Ran a 2-lens adversarial review (correctness/thread-safety + compat/edge-cases) with a per-finding refutation pass; 4 confirmed, all fixed. The load-bearing one: a device-level loop was forcing sync callbacks onto the async path (run_coroutine_threadsafe(None, loop) -> TypeError on every /set), which the iscoroutine branch fixes.

Tests / docs

+10 tests (dispatch, propagation, child inheritance, override precedence, backward-compat, sync-inline-under-device-loop, async-exception logging). 543 total; the 3.10-3.13 matrix runs on this PR; ruff clean. CHANGELOG [Unreleased] and a one-line README note added.

🤖 Generated with Claude Code

…oot (#15)

- Dispatch: schedule an async /set callback onto the consumer's loop with
  asyncio.run_coroutine_threadsafe (ensure_future is not safe from paho's
  network thread, where /set arrives). Branch on the callback's actual return
  (iscoroutine), not async_loop's presence, so a SYNC callback runs inline even
  when a device-level loop is set. Surface exceptions the discarded scheduling
  Future would otherwise swallow, matching the sync path's logging.
- Promotion: async_loop is now Device(async_loop=), propagated to every property
  via add_node()/Node.add_property() (mirroring _qos), children inheriting the
  root's loop. The per-Property async_loop still works and is kept when the
  device sets none.
- Fix the Optional[...] = False default to = None (bool vs loop annotation);
  widen to AbstractEventLoop.
- Fix Node's mutable default properties: dict = {} (shared-dict footgun the new
  propagation reads/writes through).

docs: CHANGELOG [Unreleased] (Added Device(async_loop=), Fixed dispatch/defaults);
README BYO note on async /set. Reviewed adversarially; 4 findings fixed. +10 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dcj
dcj merged commit 28dc569 into main Aug 3, 2026
5 checks passed
@dcj
dcj deleted the feat/inbound-set-async-loop branch August 3, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inbound /set dispatch: use run_coroutine_threadsafe, promote async_loop to the root, fix the =False default

1 participant