fix: bind aiohttp ClientSession to the loop that uses it - #4874
Conversation
An aiohttp.ClientSession is bound to the event loop that created it — its connector and transports live on that loop. Reusing one from a different loop raises at request time. Where the caller catches that and returns normally, the request silently does nothing: the user is told their change was applied while the device was never contacted. Solis, Octopus and Ohme each hold a session across calls. Each component's poll cycle runs on one loop, but entity callbacks (switch/number/select events) can be dispatched from a different one, so a held session is not guaranteed to be used on the loop that made it. Most other components already build a session per request with `async with` and are unaffected. Each of the three now goes through an accessor that rebinds when the running loop differs, and reuses the existing session otherwise — so the common path keeps its connection reuse and only a genuine loop change pays for a new session. A session belonging to another loop is dropped rather than closed, since closing it would touch that loop too. Solis previously created its session eagerly in the first run cycle; it is now created lazily by the same accessor, so it binds to whichever loop actually issues the request. Adds a test that takes a session from two distinct loops and asserts they differ, plus one asserting reuse within a single loop is preserved. Verified by mutation: dropping the loop check fails the first and leaves the second passing.
|
Converting to draft — please don't spend time reviewing this yet. A follow-up review found defects I should have caught, including two I introduced and one that makes the change counterproductive. My verification was invalid. I compared the Confirmed problems:
A per-loop mapping keyed by loop (or closing the old session on its owning loop via Apologies for the noise. |
|
Superseded by #4875, which fixes this at the dispatch layer instead. The consultation that followed the review made clear this was the wrong level entirely. Rebinding the session per loop broke injected test sessions, leaked a session per rebind, and destroyed connection reuse in exactly the alternating-loop case it was written for. It also touched octopus.py and ohme.py, whose callbacks only queue work and were never exposed to this — I changed them because they matched a pattern, not because I had traced their call paths. #4875 leaves session lifetime alone and makes Solis callbacks queue for its own loop, the way Ohme and Octopus already do. |
Problem
An
aiohttp.ClientSessionis bound to the event loop that created it — its connector and transports live on that loop. Reusing one from a different loop raises at request time.The failure mode is quiet rather than loud: where the calling code catches the exception and returns normally, the request silently does nothing. The user is told their change was applied while the device was never contacted.
Which components are affected
solis.py,octopus.pyandohme.pyeach hold aClientSessionacross calls:solis.pyrun()cycle, reused by every requestoctopus.pyasync_create_client_session()caches onself.sessionohme.pyself._session is NoneEach component's poll cycle runs on one loop, but entity callbacks — the
switch/number/selectevents dispatched throughtrigger_callback— can be invoked from a different one. So a held session is not guaranteed to be used on the loop that created it.Most other components build a session per request with
async with aiohttp.ClientSession(...)and are unaffected. This only concerns the three that hold one.Change
Each of the three now goes through an accessor that:
A session belonging to another loop is dropped rather than closed, because closing it would itself touch that loop.
solis.pypreviously created its session eagerly in the first run cycle; it is now created lazily by the same accessor, so it binds to whichever loop actually issues the request. Itsfinal()cleanup is unchanged.Tests
tests/test_client_session_loop_affinity.pytakes a session from two distinct event loops and asserts they differ, plus a companion asserting reuse within a single loop is preserved — the second matters because a naive fix (always create) would pass the first while quietly discarding connection reuse.Verified by mutation: dropping the loop check fails the cross-loop test and leaves the same-loop test passing, so the two are testing distinct properties rather than one implying the other.
The wider
-k "octopus or ohme or solis"selection reports an identical pass/fail/error count with and without this change (checked against a clean tree), so it introduces no regressions there; those pre-existing failures look environmental.Note
Reproducing this needs a caller that invokes an entity callback from a loop other than the component's own, so it will not show up in a single-loop setup. The change is defensive: on a single loop it is a no-op beyond one identity comparison per request.