Repository navigation
Description
- uvloop version: 0.23.0
- Python version: CPython 3.15.2
- Platform: Linux aarch64 (
python:3.15-slimimage); macOS is not affected - Can you reproduce the bug with
PYTHONASYNCIODEBUGin 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_writerfor the same fd raisesRuntimeError.
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:
- libuv's
uv__poll_ioseesPOLLERR, stops the watcher and calls the poll callback withUV_EBADF(src/unix/poll.c). __on_uvpoll_eventpasses this toUVPoll._fatal_error(uvloop/handles/poll.pyx). That method runs the reader and the writer once, sets both toNoneand closes theUVPoll. The error itself never reaches the exception handler.- The closed
UVPollstays inLoop._polls. After that:- The fd is no longer watched, and nothing tells the application.
- A later
add_writer(fd, cb)finds the closedUVPoll.start_writingthen calls_poll_start, and_ensure_aliveraisesRuntimeError: 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_handleis still set at that point, so the new handle replaces it, and_fatal_errorthen sets it toNoneand closes the poll. - Only
remove_writer(fd)followed byadd_writer(fd, cb)gets a newUVPoll, 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_EBADFfrom 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 closedUVPollfromLoop._polls. A lateradd_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_errordoes, 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
Ayush701-code-zm commentedon Nov 10, 2026
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).