Skip to content

uvloop stops watching a handle after an error of it on Linux #787

Open

Description

@dex4er
  • uvloop version: 0.23.0
  • Python version: CPython 3.15.2
  • Platform: Linux aarch64 (python:3.15-slim image); macOS is not affected
  • Can you reproduce the bug with PYTHONASYNCIODEBUG in env?: yes
  • Does uvloop behave differently from vanilla asyncio? How?: yes. asyncio keeps calling the callback for as long as the error lasts. uvloop calls it once and then stops watching the fd, and a later add_writer for the same fd raises RuntimeError.

Description

Take an fd that add_writer (or add_reader) watches and get it into an error state, for example a pipe whose read end has been closed. On Linux:

  1. libuv's uv__poll_io sees POLLERR, stops the watcher and calls the poll callback with UV_EBADF (src/unix/poll.c).
  2. __on_uvpoll_event passes this to UVPoll._fatal_error (uvloop/handles/poll.pyx). That method runs the reader and the writer once, sets both to None and closes the UVPoll. The error itself never reaches the exception handler.
  3. The closed UVPoll stays in Loop._polls. After that:
    • The fd is no longer watched, and nothing tells the application.
    • A later add_writer(fd, cb) finds the closed UVPoll. start_writing then calls _poll_start, and _ensure_alive raises RuntimeError: unable to perform operation on <UVPoll closed=True ...>; the handler is closed.
    • When the callback itself calls add_writer(fd, cb) during that single call, the call silently does nothing: writing_handle is still set at that point, so the new handle replaces it, and _fatal_error then sets it to None and closes the poll.
    • Only remove_writer(fd) followed by add_writer(fd, cb) gets a new UVPoll, and that one again reports the error just once.

asyncio uses level-triggered epoll, so EPOLLERR keeps the fd ready and the writer is called on every iteration until it is removed. A writer that has nothing to write when the error happens (and so does not see EPIPE then) still sees it later. With uvloop such a writer never sees the error, because the fd is never reported again.

macOS (kqueue) does not report POLLERR here, and there uvloop behaves like asyncio.

Reproducer

import asyncio
import os
import sys

import uvloop

kind, mode = sys.argv[1], sys.argv[2]  # asyncio|uvloop, remove|add
loop = uvloop.new_event_loop() if kind == 'uvloop' else asyncio.new_event_loop()
errors = []
loop.set_exception_handler(lambda loop, context: errors.append(context))
r, w = os.pipe()
os.set_blocking(w, False)
calls = []


def writer():
    calls.append(loop.time())
    if len(calls) == 1:
        os.close(r)  # the pipe gets an error: its reader is gone


def rearm():
    calls.append('rearm')
    try:
        if mode == 'remove':
            loop.remove_writer(w)
        loop.add_writer(w, writer)
    except Exception as ex:
        calls.append(repr(ex))


loop.add_writer(w, writer)
loop.call_later(0.2, rearm)
loop.call_later(0.4, loop.stop)
loop.run_forever()
i = calls.index('rearm')
extra = [c for c in calls if isinstance(c, str) and c != 'rearm']
n = len([c for c in calls[i + 1:] if not isinstance(c, str)])
print(kind, mode, 'calls before re-arm:', i, 'after:', n, 'exceptions:', extra, 'errors:', errors)

On Linux:

asyncio remove calls before re-arm: 221396 after: 222559 exceptions: [] errors: []
asyncio add calls before re-arm: 220291 after: 223040 exceptions: [] errors: []
uvloop remove calls before re-arm: 2 after: 1 exceptions: [] errors: []
uvloop add calls before re-arm: 2 after: 0 exceptions: ["RuntimeError('unable to perform operation on <UVPoll closed=True 0xffff81cbd850>; the handler is closed')"] errors: []

The second uvloop call is the one from _fatal_error. On macOS, uvloop gives hundreds of thousands of calls before and after, the same as asyncio.

The case where the callback re-arms itself:

import asyncio
import os
import sys

import uvloop

loop = uvloop.new_event_loop() if sys.argv[1] == 'uvloop' else asyncio.new_event_loop()
r, w = os.pipe()
os.set_blocking(w, False)
calls = []


def writer():
    calls.append(loop.time())
    if len(calls) == 1:
        os.close(r)
    if len(calls) == 2:
        loop.add_writer(w, writer)  # called with the error, watches the fd again


loop.add_writer(w, writer)
loop.call_later(0.2, loop.stop)
loop.run_forever()
print(sys.argv[1], 'calls:', len(calls))
asyncio calls: 217765
uvloop calls: 2

Possible fixes

Any of these would help, from the closest match to asyncio to the smallest change:

  • On UV_EBADF from a poll that is still wanted, run the callbacks and start the poll again instead of closing it. That matches asyncio's level-triggered behaviour: the callback keeps running until the application removes it.
  • Or, in _fatal_error, remove the closed UVPoll from Loop._polls. A later add_reader/add_writer, including one made from the callback, would then create a new poll instead of raising or doing nothing.
  • In either case, report the error to the exception handler, as UVHandle._fatal_error does, instead of dropping it.

I work around it in a reactor of Pyjoyment. After each callback it checks the fd for POLLERR with select.poll, then calls remove_* and add_* again. The workaround in my package is pretty long: https://github.com/pyjoyment/Pyjo-Reactor-Uvloop/blob/2511abaa0c30917911a601abfdc704e8b2e0a298/Pyjo/Reactor/Uvloop.py#L173-L245

Activity

  1. Ayush701-code-zm commented on Nov 10, 2026

    @Ayush701-code-zm

    Thanks for the detailed report and repros. I'd like to take this if nobody else is on it. My plan is the asyncio-like option: on UV_EBADF for a poll that still has a reader/writer, run the callbacks and keep watching instead of closing, so the callback keeps getting called until it's removed, plus regression tests based on your scripts (Linux).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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