"""Global hardware-access gate + pacing. Every command that touches the SAS bus — smartctl, sg_ses, zpool, lsblk, ledctl — must run inside this gate. It does two things: 1. Serializes hardware I/O (semaphore, default concurrency 1) so the poller can never fire a thundering herd at fragile SAS expanders. 2. Paces it: after every hardware op it holds the gate for POLL_DRIVE_GAP seconds, so back-to-back ops (gathered SMART, MegaRAID scans, SES, ledctl) are always spaced — not just the per-drive loop. This is a per-process gate; only the poller process calls hardware, so per-process serialization is sufficient. Created lazily so it binds to the running event loop. """ import asyncio import contextlib import logging import os logger = logging.getLogger(__name__) _sem: asyncio.Semaphore | None = None def _semaphore() -> asyncio.Semaphore: global _sem if _sem is None: n = max(1, int(os.environ.get("POLL_CONCURRENCY", "1"))) # Serialized (1) is the safe default. Concurrent hardware I/O can drop # fragile SAS expanders, so >1 is clamped unless explicitly overridden. if n > 1: override = os.environ.get("POLL_ALLOW_UNSAFE_CONCURRENCY", "").lower() in ( "1", "true", "yes", ) if override: logger.warning( "POLL_CONCURRENCY=%d with unsafe override — concurrent " "hardware I/O enabled; backplane must tolerate it", n, ) else: logger.warning( "POLL_CONCURRENCY=%d ignored (clamped to 1 for hardware " "safety); set POLL_ALLOW_UNSAFE_CONCURRENCY=1 to override", n, ) n = 1 _sem = asyncio.Semaphore(n) return _sem @contextlib.asynccontextmanager async def gate(): """Acquire the hardware gate for one op, then hold it for the pacing gap.""" sem = _semaphore() async with sem: try: yield finally: gap = float(os.environ.get("POLL_DRIVE_GAP", "0.5")) if gap > 0: await asyncio.sleep(gap)