ADR-016: Adapter Lifecycle Protocol¶
Status¶
Accepted Date: 2026-02-26 | Amended Date: 2026-10-01
Context¶
cosalette adapters often need initialisation and cleanup — opening serial ports,
connecting to databases, warming up hardware. The existing lifespan= hook handles
this, but for the common case of "enter adapter on startup, exit on shutdown" the
user must write boilerplate:
@asynccontextmanager
async def lifespan(ctx: cosalette.AppContext) -> AsyncIterator[None]:
meter = ctx.adapter(GasMeterPort)
meter.connect(ctx.settings.serial_port)
yield
meter.close()
This is ceremony. The adapter already knows how to manage its own lifecycle — it
just needs the framework to call __aenter__ and __aexit__ at the right time.
Python's async context manager protocol (__aenter__/__aexit__) is the standard
mechanism for paired resource management. Many libraries already implement it (e.g.
aiosqlite, aiohttp.ClientSession, serial_asyncio).
Decision¶
Auto-manage adapters implementing __aenter__/__aexit__ using
contextlib.AsyncExitStack, because this eliminates boilerplate for the common case
while preserving the lifespan= hook for advanced orchestration.
Detection¶
The framework uses duck-typing to detect lifecycle adapters:
def _is_async_context_manager(obj: object) -> bool:
return hasattr(obj, "__aenter__") and hasattr(obj, "__aexit__")
This is intentionally hasattr-based rather than isinstance(..., AbstractAsyncContextManager) — the ABC requires explicit registration, while duck-typing
is more inclusive and Pythonic.
Execution order¶
MQTT Connect
↓
Enter lifecycle adapters (AsyncExitStack) ← NEW
↓
Enter lifespan (user code before yield)
↓
Device tasks run
↓
Exit lifespan (user code after yield)
↓
Exit lifecycle adapters (LIFO via AsyncExitStack) ← NEW
↓
MQTT Disconnect
Adapters are entered before the lifespan and exited after it. This means:
- Lifespan code can safely use entered adapters (e.g. run queries on an already-connected database adapter)
- Adapter cleanup runs after lifespan teardown, so lifespan shutdown code can still use adapter resources
Only async context manager protocol¶
The framework detects only __aenter__/__aexit__. It does not look for named
methods like connect()/close() or start()/stop(). This keeps detection
simple and aligns with Python's standard protocol.
Decision Drivers¶
- Reducing boilerplate for the most common adapter lifecycle pattern
- Aligning with Python's standard resource management protocol (PEP 343)
- Preserving backward compatibility — existing apps with
lifespan=continue working unchanged - Exception safety via
AsyncExitStack(LIFO ordering, guaranteed cleanup)
Considered Options¶
Option 1: Named lifecycle methods (connect/close)¶
Detect connect()/close() or start()/stop() methods on adapters and call
them automatically.
- Advantages: Works with existing synchronous adapters. No protocol changes needed.
- Disadvantages: Ambiguous — many classes have
close()methods that shouldn't be called by the framework. No standard for which method names to detect. Synchronous methods block the event loop.
Option 2: Marker base class or decorator¶
Require adapters to inherit from a LifecycleAdapter base or apply a @managed
decorator.
- Advantages: Explicit opt-in. No false positives.
- Disadvantages: Inheritance conflicts with protocol-based architecture (ADR-006). Adds framework coupling to adapter implementations.
Option 3: Async context manager protocol (chosen)¶
Detect __aenter__/__aexit__ and manage via AsyncExitStack.
- Advantages: Standard Python protocol. Many libraries already implement it.
AsyncExitStackprovides LIFO ordering and exception safety. No framework coupling. Duck-typing detection aligns with the protocol-based architecture. - Disadvantages: Sync-only adapters need wrapping. Implicit — adding
__aenter__to an adapter changes its startup behavior.
Consequences¶
Positive¶
- The common case (adapter with paired init/cleanup) needs no
lifespan=hook at all - Adapters that already implement
__aenter__/__aexit__(e.g.aiosqlite,aiohttp.ClientSession) work automatically AsyncExitStackguarantees LIFO cleanup ordering and handles exceptions in individual adapter teardowns without blocking others- Fully backward compatible — existing
lifespan=hooks work identically
Negative¶
- Two lifecycle mechanisms to document and understand (adapter protocol vs. lifespan hook)
- Implicit behavior — adding
__aenter__/__aexit__to an adapter silently changes when it is entered/exited - No control over adapter entry order (dict iteration order, which is insertion order)
Amendment (2026-10-01) — Additive¶
Rationale: The documented execution order starts with 'MQTT Connect', but MqttClient.start() only schedules the connection loop and run_lifespan_and_devices launched entity tasks immediately. I/O-free telemetry handlers (computed values, timers) therefore published before CONNACK on every start, and each failed publish surfaced as an ERROR log, a WARNING with traceback from the error publisher, an error-topic publish attempt and an 'error' health status (reported by early adopter wiz2mqtt). This amendment restores the documented order with a bounded barrier.
Additional Sub-Decision: Bounded First-Connect Barrier¶
Entity startup in the run phase waits for the first MQTT connect before launching device, telemetry, periodic and stream tasks together. The barrier is an asyncio.Event set by a connect callback registered through the existing MqttConnectAware.add_connect_callback immediately after the ADR-012 reannounce callback; callbacks run sequentially in registration order, so the gate opens only after availability, registry and heartbeat have been announced. No new port or protocol is introduced (ADR-006).
The wait is bounded by App(startup_connect_timeout: float | None = 10.0), measured with the injected ClockPort. On timeout one WARNING is logged and tasks start anyway, so handlers with local side effects keep working during a broker outage; shutdown ends the wait early without a warning. None disables the barrier, and values <= 0 are rejected like the other interval kwargs. Adapters that are not connect-aware (MockMqttClient, AppHarness, NullMqttClient) are always considered connected and skip the barrier.
MQTT start (connection scheduled)
↓
Enter lifecycle adapters → enter lifespan → startup health checks
↓
Wait for first connect + reannounce (≤ startup_connect_timeout)
↓
Device / telemetry / periodic / stream tasks run
Additional Positive Consequences¶
- I/O-free handlers no longer race the broker connection on every start; the first state publish follows the availability announce.
- Existing unit tests are unaffected because the in-memory doubles are not connect-aware.
Additional Negative Consequences¶
- Startup with an unreachable broker is delayed by up to startup_connect_timeout (10 s by default) before handlers run.
- A telemetry tick missed during a later reconnect is still caught up only on the next interval; waking sleepers on reconnect is tracked separately.