Skip to content

Segfault in GC triggered by asyncio.wait_for busy-loop creating unbounded TimerHandle objects #145340

@serracloud

Description

@serracloud

Bug description

A tight loop in asyncio.wait_for() causes a segmentation fault when an asyncio.Event is already set but an outer condition prevents the caller from returning. The crash occurs inside the garbage collector during _Py_HandlePending, triggered by the rapid allocation of TimerHandle objects.

The bug is nondeterministic and requires pytest-asyncio's per-test asyncio.Runner lifecycle to reproduce. It crashes approximately 1 in 7 runs.

Mechanism

The following wait_for pattern creates a busy-loop that floods the GC:

async def wait(self):
    while True:
        if event.is_set() and vpn_event.is_set():
            return
        try:
            # BUG: event IS set, so event.wait() returns instantly.
            # But vpn_event is NOT set, so the while-loop continues.
            # Each iteration allocates a new TimerHandle via wait_for().
            await asyncio.wait_for(event.wait(), timeout=1.0)
        except asyncio.TimeoutError:
            pass

With 3 concurrent tasks running this loop:

  1. Thousands of TimerHandle objects are allocated per second
  2. Gen0 GC threshold (2000) is hit every ~600 iterations
  3. After enough gen0 collections, a cascading gen0 -> gen1 -> gen2 collection triggers
  4. The GC segfaults during traversal/finalization of the unreachable set

Faulthandler output

Fatal Python error: Segmentation fault

Current thread 0x00007fab8ee3f100 (most recent call first):
  Garbage-collecting
  File "/usr/lib/python3.13/asyncio/events.py", line 113 in __init__
  File "/usr/lib/python3.13/asyncio/base_events.py", line 816 in call_at
  File "/usr/lib/python3.13/asyncio/timeouts.py", line 71 in reschedule
  File "/usr/lib/python3.13/asyncio/timeouts.py", line 94 in __aenter__
  File "/usr/lib/python3.13/asyncio/tasks.py", line 506 in wait_for

SIGSEGV handler details (custom handler installed via conftest.py)

Frame: /usr/lib/python3.13/asyncio/events.py:36 in __init__
GC counts: (15, 0, 0)

GC counts (15, 0, 0) confirms gen1 and gen2 were both just collected (counts reset to 0). The crash happens during or immediately after a full gen2 collection.

C-level backtrace (from GDB post-mortem on core dump)

#0  kill ()                              <- SIGSEGV handler re-raise
#1  os_kill.lto_priv.0
#2  cfunction_vectorcall_FASTCALL
#3  PyObject_Vectorcall
#4  _PyEval_EvalFrameDefault
#7  _Py_HandlePending                    <- GC triggered here
#8  _PyEval_EvalFrameDefault             <- interrupted by GC
#9  slot_tp_init.lto_priv.0              <- Handle.__init__ tp_init slot
#10 _PyObject_MakeTpCall
#12 gen_send_ex2.lto_priv.0              <- coroutine.send()
#13-14 _asyncio.cpython-313 .so          <- task_step_impl (C accelerator)

Reproducer

Requires: pytest, pytest-asyncio>=1.3.0

"""Reproducer: save as test_gh_XXXXX.py and run with pytest."""
import asyncio
import pytest


class Gate:
    def __init__(self):
        self._target_events = {}
        self._vpn_event = asyncio.Event()
        self._vpn_event.set()

    def register(self, ip):
        if ip not in self._target_events:
            self._target_events[ip] = asyncio.Event()
            self._target_events[ip].set()

    def mark_vpn_down(self):
        self._vpn_event.clear()

    def mark_vpn_up(self):
        self._vpn_event.set()

    async def wait_buggy(self, ip):
        while True:
            ev = self._target_events[ip]
            if ev.is_set() and self._vpn_event.is_set():
                return
            try:
                await asyncio.wait_for(ev.wait(), timeout=1.0)
            except asyncio.TimeoutError:
                pass


# 14 padding tests to accumulate GC gen1/gen2 pressure
class TestPadding:
    @pytest.mark.asyncio
    async def test_01(self):
        g = Gate(); g.register("10.0.0.1")
        await asyncio.wait_for(g.wait_buggy("10.0.0.1"), timeout=1.0)

    @pytest.mark.asyncio
    async def test_02(self):
        g = Gate(); g.register("10.0.0.2")
        await asyncio.wait_for(g.wait_buggy("10.0.0.2"), timeout=1.0)

    @pytest.mark.asyncio
    async def test_03(self):
        g = Gate(); g.register("10.0.0.3")
        g.mark_vpn_down(); g.mark_vpn_up()
        await asyncio.wait_for(g.wait_buggy("10.0.0.3"), timeout=1.0)

    @pytest.mark.asyncio
    async def test_04(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_05(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_06(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_07(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_08(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_09(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_10(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_11(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_12(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_13(self): await asyncio.sleep(0.01)
    @pytest.mark.asyncio
    async def test_14(self): await asyncio.sleep(0.01)


class TestCrash:
    @pytest.mark.asyncio
    async def test_busyloop_gc_segfault(self):
        """Crashes ~1 in 7 runs."""
        gate = Gate()
        gate.register("10.8.0.3")
        gate.mark_vpn_down()

        unblocked = []
        async def _waiter(n):
            await gate.wait_buggy("10.8.0.3")
            unblocked.append(n)

        tasks = [asyncio.create_task(_waiter(i)) for i in range(3)]
        await asyncio.sleep(0.05)
        assert len(unblocked) == 0

        gate.mark_vpn_up()
        await asyncio.wait_for(asyncio.gather(*tasks), timeout=3.0)
        assert len(unblocked) == 3

Run with:

# Crashes nondeterministically. Run in a loop:
for i in $(seq 1 10); do
    timeout 60 python3 -m pytest test_gh_XXXXX.py -x -q 2>&1 | tail -3
    [ $? -eq 139 ] && echo "SEGFAULT on run $i" && break
done

Key observations

  • Does not crash outside pytest. The asyncio.Runner per-test lifecycle (new loop per test, GC between tests) is required.
  • Does not crash with asyncio.run() alone, even with aggressive gc.set_threshold(10, 2, 2).
  • The padding tests are required -- removing them eliminates the crash (insufficient gen2 pressure).
  • The crash site (Handle.__init__ line 36) is the function def line itself, meaning the SIGSEGV happens during Python frame entry when _Py_HandlePending triggers GC.

Possibly related issues

Your environment

  • CPython version: 3.13.5 (main, Jun 25 2025, 18:55:22) [GCC 14.2.0]
  • Operating system and architecture: Linux 6.17.13+2-amd64, x86_64
  • pytest 8.3.5, pytest-asyncio 1.3.0
  • GIL: enabled (standard build, not free-threaded)

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)topic-asynciotype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions