Skip to content

Commit 2a945cc

Browse files
author
Maksym Shtembuliak
committed
test: isolate shared listener lock across HA event loops
1 parent 3b03a98 commit 2a945cc

2 files changed

Lines changed: 48 additions & 0 deletions

File tree

‎tests_ha/conftest.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from __future__ import annotations
1414

15+
import asyncio
1516
from pathlib import Path
1617
import sys
1718
from typing import Any
@@ -33,6 +34,25 @@
3334
)
3435

3536

37+
@pytest.fixture(autouse=True)
38+
def isolated_listener_loop(monkeypatch):
39+
"""Model a fresh HA process's lock for each isolated test event loop.
40+
41+
Production HA uses one loop. Pytest creates a new one for each case while
42+
retaining imported modules. A contended module-level asyncio.Lock otherwise
43+
stays bound to a previous case's loop. Keep the real lock and registry
44+
behavior; do not clear leaked listeners or suppress task errors.
45+
"""
46+
from custom_components.eybond_local.collector.transport import listener
47+
48+
assert not listener._LISTENERS, "previous HA test leaked a shared listener"
49+
lock = asyncio.Lock()
50+
monkeypatch.setattr(listener, "_LISTENERS_LOCK", lock)
51+
yield
52+
assert not lock.locked(), "HA test leaked the shared-listener lock"
53+
assert not listener._LISTENERS, "HA test leaked a shared listener"
54+
55+
3656
@pytest.fixture(autouse=True)
3757
def auto_enable_custom_integrations(enable_custom_integrations):
3858
"""Let Home Assistant load `custom_components/eybond_local` for real."""
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Each isolated HA test owns its event-loop-bound listener lock."""
2+
import asyncio
3+
4+
import pytest
5+
6+
from custom_components.eybond_local.collector.transport import listener
7+
8+
9+
@pytest.mark.parametrize("separate_ha_loop", [1, 2])
10+
async def test_listener_lock_contention_is_local_to_each_ha_loop(hass, separate_ha_loop):
11+
# Both parametrized cases must actually contend. An uncontended Lock can
12+
# appear to work on a different loop, hiding cross-test state until CI.
13+
lock = listener._LISTENERS_LOCK
14+
queued = asyncio.Event()
15+
acquired = asyncio.Event()
16+
17+
async def contender():
18+
queued.set()
19+
async with lock:
20+
acquired.set()
21+
22+
async with lock:
23+
task = asyncio.create_task(contender())
24+
await queued.wait()
25+
assert not acquired.is_set()
26+
await task
27+
assert acquired.is_set()
28+
assert not lock.locked()

0 commit comments

Comments
 (0)