Skip to content

Eager write_bytes task makes cross-loop ClientSession use silently corrupt the session's event loop on Python 3.12/3.13 #13346

Description

@smitrob

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

  • I agree to follow the aio-libs Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions