Skip to content

[Bug]: "Bad file descriptor" raised repeatedly when plt.pause() interrupted in IPython #29688

Closed

Description

@user202729

Bug summary

As in the title.

Code for reproduction

See command-line shell. The cells `In [1]:` and `In [2]:` are copy-pasted in the shell.

Python 3.11.10 | packaged by conda-forge | (main, Oct 16 2024, 01:27:36) [GCC 13.3.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.31.0.dev -- An enhanced Interactive Python. Type '?' for help.

In [1]: %matplotlib
   ...: import matplotlib
   ...: import matplotlib.pyplot as plt
   ...: 
   ...: plt.rcParams["figure.figsize"]=(10, 7)
   ...: plt.rcParams["figure.dpi"]=144
   ...: plt.rcParams["figure.facecolor"]="white"
   ...: 
   ...: plt.ion()
Using matplotlib backend: qtagg
Out[1]: <contextlib.ExitStack at 0x7eb903e4f0d0>

In [2]: plt.plot([1, 2, 3],[5, 4, 6])
   ...: plt.pause(10)
^C---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[2], line 2
      1 plt.plot([1, 2, 3],[5, 4, 6])
----> 2 plt.pause(10)

File ~/.../lib/python3.11/site-packages/matplotlib/pyplot.py:756, in pause(interval)
    754         canvas.draw_idle()
    755     show(block=False)
--> 756     canvas.start_event_loop(interval)
    757 else:
    758     time.sleep(interval)

File ~/.../lib/python3.11/site-packages/matplotlib/backends/backend_qt.py:451, in FigureCanvasQT.start_event_loop(self, timeout)
    448 if timeout > 0:
    449     _ = QtCore.QTimer.singleShot(int(timeout * 1000), event_loop.quit)
--> 451 with _allow_interrupt_qt(event_loop):
    452     qt_compat._exec(event_loop)

File ~/.../lib/python3.11/contextlib.py:144, in _GeneratorContextManager.__exit__(self, typ, value, traceback)
    142 if typ is None:
    143     try:
--> 144         next(self.gen)
    145     except StopIteration:
    146         return False

File ~/.../lib/python3.11/site-packages/matplotlib/backend_bases.py:1672, in _allow_interrupt(prepare_notifier, handle_sigint)
   1670 signal.signal(signal.SIGINT, old_sigint_handler)
   1671 if handler_args is not None:
-> 1672     old_sigint_handler(*handler_args)

KeyboardInterrupt: 

---------------------------------------------------------------------------
OSError                                   Traceback (most recent call last)
File ~/.../lib/python3.11/site-packages/matplotlib/backends/backend_qt.py:166, in _allow_interrupt_qt.<locals>.prepare_notifier.<locals>._may_clear_sock()
    158 @sn.activated.connect
    159 def _may_clear_sock():
    160     # Running a Python function on socket activation gives the interpreter a
   (...)
    163     # the wakeup.  (We need this in case set_wakeup_fd catches a signal other
    164     # than SIGINT and we shall continue waiting.)
    165     try:
--> 166         rsock.recv(1)
    167     except BlockingIOError:
    168         # This may occasionally fire too soon or more than once on Windows, so
    169         # be forgiving about reading an empty socket.
    170         pass

OSError: [Errno 9] Bad file descriptor

Actual outcome

The error is raised.

Expected outcome

No repeated OSError.

Additional information

No response

Operating system

No response

Matplotlib Version

3.9.2

Matplotlib Backend

No response

Python version

No response

Jupyter version

No response

Installation

None

Activity

  1. added this to the v3.10.2 milestone on Feb 27, 2025
  2. mehakkhosa commented on Mar 5, 2025

    @mehakkhosa

    Hi, I’d be happy to take it on!

  3. tacaswell commented on Mar 5, 2025

    @tacaswell
    Member

    I had started to look at this but did not get very far yet, here are some notes to get you started @mehakkhosa :

    • where the Qt setup gets done:

    def _allow_interrupt_qt(qapp_or_eventloop):
    """A context manager that allows terminating a plot by sending a SIGINT."""
    # Use QSocketNotifier to read the socketpair while the Qt event loop runs.
    def prepare_notifier(rsock):
    sn = QtCore.QSocketNotifier(rsock.fileno(), QtCore.QSocketNotifier.Type.Read)
    @sn.activated.connect
    def _may_clear_sock():
    # Running a Python function on socket activation gives the interpreter a
    # chance to handle the signal in Python land. We also need to drain the
    # socket with recv() to re-arm it, because it will be written to as part of
    # the wakeup. (We need this in case set_wakeup_fd catches a signal other
    # than SIGINT and we shall continue waiting.)
    try:
    rsock.recv(1)
    except BlockingIOError:
    # This may occasionally fire too soon or more than once on Windows, so
    # be forgiving about reading an empty socket.
    pass
    return sn # Actually keep the notifier alive.
    def handle_sigint():
    if hasattr(qapp_or_eventloop, 'closeAllWindows'):
    qapp_or_eventloop.closeAllWindows()
    qapp_or_eventloop.quit()
    return _allow_interrupt(prepare_notifier, handle_sigint)

    • where we use that function:

    @contextmanager
    def _allow_interrupt(prepare_notifier, handle_sigint):
    """
    A context manager that allows terminating a plot by sending a SIGINT. It
    is necessary because the running backend prevents the Python interpreter
    from running and processing signals (i.e., to raise a KeyboardInterrupt).
    To solve this, one needs to somehow wake up the interpreter and make it
    close the plot window. We do this by using the signal.set_wakeup_fd()
    function which organizes a write of the signal number into a socketpair.
    A backend-specific function, *prepare_notifier*, arranges to listen to
    the pair's read socket while the event loop is running. (If it returns a
    notifier object, that object is kept alive while the context manager runs.)
    If SIGINT was indeed caught, after exiting the on_signal() function the
    interpreter reacts to the signal according to the handler function which
    had been set up by a signal.signal() call; here, we arrange to call the
    backend-specific *handle_sigint* function. Finally, we call the old SIGINT
    handler with the same arguments that were given to our custom handler.
    We do this only if the old handler for SIGINT was not None, which means
    that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
    which means we should ignore the interrupts.
    Parameters
    ----------
    prepare_notifier : Callable[[socket.socket], object]
    handle_sigint : Callable[[], object]
    """
    old_sigint_handler = signal.getsignal(signal.SIGINT)
    if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
    yield
    return
    handler_args = None
    wsock, rsock = socket.socketpair()
    wsock.setblocking(False)
    rsock.setblocking(False)
    old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
    notifier = prepare_notifier(rsock)
    def save_args_and_handle_sigint(*args):
    nonlocal handler_args
    handler_args = args
    handle_sigint()
    signal.signal(signal.SIGINT, save_args_and_handle_sigint)
    try:
    yield
    finally:
    wsock.close()
    rsock.close()
    signal.set_wakeup_fd(old_wakeup_fd)
    signal.signal(signal.SIGINT, old_sigint_handler)
    if handler_args is not None:
    old_sigint_handler(*handler_args)

    Something is clearly going wrong in the cleanup where when we get SIGINT we are not fully cleaning up after ourselves. A quick fix would be to just exit early in the callback, but I think the correct fix is to sort out how to cleanly disconnect the Qt Signal.

  4. modified the milestones: v3.10.2, v3.10.3 on May 2, 2025
  5. chrisjbillington commented on Jun 23, 2025

    @chrisjbillington
    Contributor

    I'm seeing this without IPython, when exiting the python REPL after pressing ctrl-C whilst a plot was visible. This only occurs when running a script with python -i, not when executing the matplotlib lines from the REPL itself.

    Here's what triggers it for me on Arch Linux, Python 3.13.3, matplotlib 3.10.3:

    Test script (for me called 2060.py):

    import matplotlib.pyplot as plt
    plt.plot([1, 2, 3], [4, 5, 6])
    plt.show()

    Run the script with the -i flag:

    python -i 2060.py

    When the plot appears, go back to the terminal and hit ctrl-C, that yields this traceback, no problem so far:

    ^CTraceback (most recent call last):
      File "/home/bilbo/Documents/Scratch/2060.py", line 3, in <module>
        plt.show()
        ~~~~~~~~^^
      File "/usr/lib/python3.13/site-packages/matplotlib/pyplot.py", line 614, in show
        return _get_backend_mod().show(*args, **kwargs)
               ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
      File "/usr/lib/python3.13/site-packages/matplotlib/backend_bases.py", line 3548, in show
        cls.mainloop()
        ~~~~~~~~~~~~^^
      File "/usr/lib/python3.13/site-packages/matplotlib/backends/backend_qt.py", line 642, in start_main_loop
        with _allow_interrupt_qt(qapp):
             ~~~~~~~~~~~~~~~~~~~^^^^^^
      File "/usr/lib/python3.13/contextlib.py", line 148, in __exit__
        next(self.gen)
        ~~~~^^^^^^^^^^
      File "/usr/lib/python3.13/site-packages/matplotlib/backend_bases.py", line 1658, in _allow_interrupt
        old_sigint_handler(*handler_args)
        ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
    KeyboardInterrupt
    

    But then, press ctrl-D to exit the Python interactive interpreter. At that point I get this (sorry, screengrab from a poor-quality screen recording because the window vanishes soon after)

    Image

    and then weirdly the terminal emulator exits, even though it should return to bash. I get this no matter which terminal emulator I use (tried gnome-terminal, gnome console, alacritty). In gnome-console the main window is still visible but there's no prompt or anything, it's non-functional.

    Notably it doesn't happen if you execute the same lines from within a REPL - only when running Python on a script with -i.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions