Describe the bug
Since the 'Optimization for Python 3.12' in ClientRequest.send() (client_reqrep.py), the request-body writer is started as an eager task bound to the session's loop:
task = asyncio.Task(coro, loop=self.loop, eager_start=True)
CPython's eager start gates only on loop.is_running() — true from any thread — and then swaps self.loop's current-task slot and steps write_bytes synchronously in the calling thread. So when a request is issued on a session from a thread that is not running the session's loop (a misuse, but a common accidental one — e.g. a shared client driven from a private asyncio.run() loop inside a thread-pool worker), the corruption lands on the session's loop:
write_bytes' first sock.send() releases the GIL mid-swap, the session loop's thread observes the foreign task in its current-task slot, and any task_wakeup in the window dies with RuntimeError: Cannot enter into task <unrelated task on the session's loop> while another task <Task coro=<ClientRequest.write_bytes() ...>> is being executed.
- A wakeup callback that raises is never retried, so the victim task on the session's loop hangs permanently — no exception ever reaches its awaiter. We hit this in production: unrelated
asyncio.gather fan-outs on the main loop wedged mid-run with nothing to alarm on, with write_bytes named as the 'other task' every time.
The subtlety worth aiohttp's attention: before this optimization, the same misuse failed loudly and locally (cross-loop future errors in the connector, or Timeout context manager should be used inside a task — though note that pre-check itself races: asyncio.current_task(loop=self._loop) returns a task whenever the session loop's thread happens to be mid-step, so under load it frequently passes). With the eager writer, the misuse instead corrupts the other loop silently.
CPython 3.14 already guards this structurally (per-thread current-task tracking; the foreign-thread eager start now raises RuntimeError: loop <...> is not the running loop in the calling thread and the session's loop is unharmed). Filed upstream with a stdlib-only reproducer and the full mechanism: python/cpython#155338. Python 3.12/3.13 remain exposed.
To Reproduce
Deterministic stdlib-only repro of the underlying mechanism (aiohttp-free) is in python/cpython#155338. With aiohttp itself: create a ClientSession on a busy main loop, then POST a >64KB body on it from inside asyncio.run(...) in a worker thread while the main loop runs CPU-bound task steps; the pre-checks race through intermittently and the main loop logs Cannot enter into task ... while another task <ClientRequest.write_bytes ...> is being executed.
Expected behavior
Cross-loop misuse should fail loudly in the caller (as pre-3.10 behavior effectively did), not corrupt the session's loop. Suggested minimal hardening — take the eager path only when the caller is actually on the session's loop:
if sys.version_info >= (3, 12) and self.loop is asyncio.get_running_loop():
task = asyncio.Task(coro, loop=self.loop, eager_start=True)
else:
task = self.loop.create_task(coro)
(get_running_loop() can't raise here since send() is async machinery, and same-loop callers keep the optimization.)
Logs/tracebacks
Exception in callback Task.task_wakeup(<_GatheringFu...result=[None]>)
Traceback (most recent call last):
File "/usr/local/lib/python3.12/asyncio/events.py", line 88, in _run
self._context.run(self._callback, *self._args)
RuntimeError: Cannot enter into task <Task pending name='Task-6262' coro=<_pump() ...> wait_for=<_GatheringFuture finished result=[None]>> while another task <Task pending name='Task-6293' coro=<ClientRequest.write_bytes() running at .../aiohttp/client_reqrep.py:1397>> is being executed.
Python Version
3.12.13 and 3.13.14 affected; 3.14.7 guarded by CPython itself
aiohttp Version
3.14.3
Multidict/propcache/yarl Version
as pinned by aiohttp 3.14.3
OS
Linux (Docker python:3.12-slim, aarch64; observed on ECS Fargate)
Related component
Client
Additional context
Happy to provide the full production traces or adapt the CPython repro to an aiohttp-native one if useful.
Code of Conduct
Describe the bug
Since the 'Optimization for Python 3.12' in
ClientRequest.send()(client_reqrep.py), the request-body writer is started as an eager task bound to the session's loop:CPython's eager start gates only on
loop.is_running()— true from any thread — and then swapsself.loop's current-task slot and stepswrite_bytessynchronously in the calling thread. So when a request is issued on a session from a thread that is not running the session's loop (a misuse, but a common accidental one — e.g. a shared client driven from a privateasyncio.run()loop inside a thread-pool worker), the corruption lands on the session's loop:write_bytes' firstsock.send()releases the GIL mid-swap, the session loop's thread observes the foreign task in its current-task slot, and anytask_wakeupin the window dies withRuntimeError: Cannot enter into task <unrelated task on the session's loop> while another task <Task coro=<ClientRequest.write_bytes() ...>> is being executed.asyncio.gatherfan-outs on the main loop wedged mid-run with nothing to alarm on, withwrite_bytesnamed as the 'other task' every time.The subtlety worth aiohttp's attention: before this optimization, the same misuse failed loudly and locally (cross-loop future errors in the connector, or
Timeout context manager should be used inside a task— though note that pre-check itself races:asyncio.current_task(loop=self._loop)returns a task whenever the session loop's thread happens to be mid-step, so under load it frequently passes). With the eager writer, the misuse instead corrupts the other loop silently.CPython 3.14 already guards this structurally (per-thread current-task tracking; the foreign-thread eager start now raises
RuntimeError: loop <...> is not the running loopin the calling thread and the session's loop is unharmed). Filed upstream with a stdlib-only reproducer and the full mechanism: python/cpython#155338. Python 3.12/3.13 remain exposed.To Reproduce
Deterministic stdlib-only repro of the underlying mechanism (aiohttp-free) is in python/cpython#155338. With aiohttp itself: create a
ClientSessionon a busy main loop, then POST a >64KB body on it from insideasyncio.run(...)in a worker thread while the main loop runs CPU-bound task steps; the pre-checks race through intermittently and the main loop logsCannot enter into task ... while another task <ClientRequest.write_bytes ...> is being executed.Expected behavior
Cross-loop misuse should fail loudly in the caller (as pre-3.10 behavior effectively did), not corrupt the session's loop. Suggested minimal hardening — take the eager path only when the caller is actually on the session's loop:
(
get_running_loop()can't raise here sincesend()is async machinery, and same-loop callers keep the optimization.)Logs/tracebacks
Python Version
3.12.13 and 3.13.14 affected; 3.14.7 guarded by CPython itself
aiohttp Version
3.14.3
Multidict/propcache/yarl Version
as pinned by aiohttp 3.14.3
OS
Linux (Docker python:3.12-slim, aarch64; observed on ECS Fargate)
Related component
Client
Additional context
Happy to provide the full production traces or adapt the CPython repro to an aiohttp-native one if useful.
Code of Conduct