Skip to content

Commit df729fa

Browse files
lesnik512claude
andcommitted
feat(bulkhead): detect-and-raise on cross-event-loop AsyncBulkhead reuse
AsyncBulkhead's asyncio.Semaphore binds to whichever loop first awaits it; sharing one instance across loops in different threads silently deadlocks because cross-loop wake-ups aren't thread-safe. Capture the running loop on first acquire and raise a clear RuntimeError on mismatch — preferable to the silent hang the audit identified. The mismatch check uses double-check-with-lock to make the cold-path first capture race-free across threads. Same-loop fast path is lock-free. Module docstring grows a paragraph mirroring the sync Bulkhead's per-world warning; class docstring cross-links it. Three tests added: - first_acquire_captures_running_loop (capture happens) - same_loop_succeeds_across_multiple_acquires (no false positive) - cross_loop_acquire_raises_runtimeerror (two asyncio.run() calls deterministically) Closes Medium #M3 + #M4 in planning/audit/2026-06-07-deep-audit.md (bundled per the audit's "keep this fix and the docstring fix in the same PR" note). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b18d0c8 commit df729fa

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

src/httpware/middleware/resilience/bulkhead.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
1010
AsyncBulkhead is the sharable unit — pass the same instance to multiple
1111
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
12+
13+
AsyncBulkhead is single-event-loop: the underlying asyncio.Semaphore binds
14+
to whichever loop first awaits it, and cross-loop wake-ups are not thread
15+
safe. A single instance acquired from a second event loop (e.g. another
16+
thread running asyncio.run) raises RuntimeError on entry rather than
17+
deadlocking silently. To cap a sync+async or cross-thread workload, use
18+
a Bulkhead and an AsyncBulkhead with matching max_concurrent.
1219
"""
1320

1421
import asyncio
@@ -24,6 +31,11 @@
2431

2532
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
2633
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
34+
_ASYNCBULKHEAD_CROSS_LOOP_MSG = (
35+
"AsyncBulkhead is bound to a single event loop. First seen on {first!r}; "
36+
"current request is on {current!r}. Use one AsyncBulkhead per loop; "
37+
"cross-thread sharing requires the sync Bulkhead primitive."
38+
)
2739

2840
_LOGGER = logging.getLogger("httpware.bulkhead")
2941

@@ -42,7 +54,8 @@ class AsyncBulkhead:
4254
Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
4355
``>= 0`` (or ``None``).
4456
45-
See the module docstring for the algorithm and middleware-ordering guidance.
57+
See the module docstring for the algorithm, middleware-ordering guidance,
58+
and the single-event-loop constraint.
4659
4760
"""
4861

@@ -59,9 +72,29 @@ def __init__(
5972
self._max_concurrent = max_concurrent
6073
self._acquire_timeout = acquire_timeout
6174
self._sem = asyncio.Semaphore(max_concurrent)
75+
self._loop: asyncio.AbstractEventLoop | None = None
76+
self._loop_lock = threading.Lock()
77+
78+
def _check_loop(self) -> None:
79+
current = asyncio.get_running_loop()
80+
cached = self._loop
81+
if cached is current:
82+
return
83+
if cached is not None:
84+
raise RuntimeError(
85+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=cached, current=current),
86+
)
87+
with self._loop_lock:
88+
if self._loop is None:
89+
self._loop = current
90+
elif self._loop is not current:
91+
raise RuntimeError(
92+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=self._loop, current=current),
93+
)
6294

6395
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
6496
"""Acquire a slot (bounded by acquire_timeout), invoke next, release."""
97+
self._check_loop()
6598
try:
6699
if self._acquire_timeout is None:
67100
await self._sem.acquire()

tests/test_bulkhead.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,45 @@ async def slow_handler(request: httpx2.Request) -> httpx2.Response:
431431
assert record.acquire_timeout == 0.0 # ty: ignore[unresolved-attribute]
432432
assert record.method == "GET" # ty: ignore[unresolved-attribute]
433433
assert "example.test/y" in record.url # ty: ignore[unresolved-attribute]
434+
435+
436+
# ───── Single-event-loop guard ──────────────────────────────────────────────
437+
438+
439+
async def test_first_acquire_captures_running_loop() -> None:
440+
"""AsyncBulkhead binds to whichever loop first acquires a slot."""
441+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1)
442+
assert bulkhead._loop is None # noqa: SLF001
443+
handler = _SlowHandler(delay=0.0)
444+
async with _client(handler, bulkhead=bulkhead) as client:
445+
await client.get("https://example.test/x")
446+
assert bulkhead._loop is asyncio.get_running_loop() # noqa: SLF001
447+
448+
449+
async def test_same_loop_succeeds_across_multiple_acquires() -> None:
450+
"""Repeated acquires on the same loop never trigger the cross-loop guard."""
451+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_2)
452+
handler = _SlowHandler(delay=0.0)
453+
async with _client(handler, bulkhead=bulkhead) as client:
454+
for _ in range(5):
455+
response = await client.get("https://example.test/x")
456+
assert response.status_code == HTTPStatus.OK
457+
458+
459+
def test_cross_loop_acquire_raises_runtimeerror() -> None:
460+
"""A bulkhead first used on one loop, then reused on another, raises RuntimeError.
461+
462+
Each asyncio.run() call creates a fresh event loop and tears it down on
463+
exit. Sharing one AsyncBulkhead instance across two asyncio.run() calls
464+
is the cross-loop case the guard prevents.
465+
"""
466+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1)
467+
handler = _SlowHandler(delay=0.0)
468+
469+
async def _run_once() -> None:
470+
async with _client(handler, bulkhead=bulkhead) as client:
471+
await client.get("https://example.test/x")
472+
473+
asyncio.run(_run_once()) # captures loop L1, then L1 closes
474+
with pytest.raises(RuntimeError, match="AsyncBulkhead is bound to a single event loop"):
475+
asyncio.run(_run_once())

0 commit comments

Comments
 (0)