Skip to content

Ctrl+C can exit with a traceback and leave the terminal unrestored: endwin() runs in the SIGINT handler #489

Description

@jbellars

Up front, so it can be weighted properly: I hit this on a locally-built binary using Python 3.14, and I could not reproduce it on the official 4.0.0-beta.14 release binary (Python 3.12) in 5 attempts. CONTRIBUTING.md is explicit that the app is built with 3.12 and may misbehave on other versions, so released builds may well never see it. I'm filing it anyway because the underlying defect is in the source and is timing-dependent rather than version-specific — the 3.12 build wins the race, it doesn't avoid it — and because it may explain part of #247.

What happens

Ctrl+C out of the TUI exits non-zero with a traceback instead of exiting cleanly, and leaves the terminal unrestored (needing a reset):

Traceback (most recent call last):
  File "curses/__init__.py", line 94, in wrapper
  File "ou_dedetai/tui_app.py", line 1373, in control_panel_app
  File "ou_dedetai/tui_app.py", line 559, in run
  File "ou_dedetai/tui_app.py", line 506, in display
    self.header.display()
  File "ou_dedetai/tui_screen.py", line 218, in display
    ).run()
  File "ou_dedetai/tui_curses.py", line 364, in run
    self.input()
  File "ou_dedetai/tui_curses.py", line 333, in input
    key = self.stdscr.getch()
  File "ou_dedetai/tui_app.py", line 410, in end
_curses.error: endwin() returned ERR

During handling of the above exception, another exception occurred:
  ...
  File "curses/__init__.py", line 101, in wrapper
_curses.error: endwin() returned ERR

(Line numbers are offset by a local patch; 410 in end is curses.endwin() in TUI.end.)

Root cause

TUI.end is installed as the SIGINT handler (tui_app.py, in display()) and tears curses down inline:

def end(self, signal, frame):
    logging.debug("Exiting…")
    self.is_running = False
    curses.endwin()

SIGINT can land anywhere — in the traceback above, inside stdscr.getch(). endwin() runs, but the display loop is still mid-iteration and calls refresh() before it next checks is_running, which puts the screen back in running state. curses.wrapper()'s teardown endwin() then returns ERR, and because that fires from the finally, it replaces the clean exit with an unhandled exception and the terminal is never restored.

It's a race, so it doesn't reproduce every time. Sending Ctrl+C through a pty after the control panel settles, 5 runs per build:

build dirty
official 4.0.0-beta.14 release binary (py3.12) 0/5
locally built, same source, py3.14 4/5

One of my 5 came out clean, which is what makes me read this as a race that different build timings win or lose rather than a 3.14-specific fault.

Suggested fix

The endwin() call is also redundant. App.exit() ends in sys.exit(), which propagates through display() and run()'s finally into curses.wrapper(), and the wrapper tears curses down on the way out. There is no os._exit() anywhere in the package, so that path is never bypassed. So the handler only needs to stop the loop:

     def end(self, signal, frame):
+        # Only ask the loop to stop. This runs as the SIGINT handler, so tearing
+        # curses down here races the loop's next refresh(), which puts the screen
+        # back in running state; the teardown endwin() then fails with
+        # "endwin() returned ERR" and the terminal is left unrestored. run()'s
+        # finally and curses.wrapper() already do the teardown on the way out.
         logging.debug("Exiting…")
         self.is_running = False
-        curses.endwin()

With that applied and rebuilt: 0/8 dirty on the build that was previously 4/5, exit status 0, no traceback, terminal restored. Resize behaviour is unchanged (verified separately at 24x80, 50x160 and 33x100).

Relation to other issues

Same shape as #488 — a signal handler doing real curses work that races the main loop — though a different handler and a different failure. Both are instances of "SIGINT/SIGWINCH handlers should set a flag, not drive curses".

#247 reports the TUI being left needing a reset after exiting. A failed endwin() is one way to end up exactly there, so this may be a contributing cause, at least for users on non-3.12 builds.

Environment

  • Arch Linux, kernel 7.1.9-arch1-2, foot terminal, Hyprland 0.56.2
  • OuDedetai 4.0.0-beta.14 (source at main @ 43fa3e5)
  • ncurses: libncursesw.so.6 in both builds compared
  • Reproduced on a PyInstaller build with Python 3.14; not reproduced on the released 3.12 build

Happy to open a PR with the one-line fix and a unit test.

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