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.
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):(Line numbers are offset by a local patch;
410 in endiscurses.endwin()inTUI.end.)Root cause
TUI.endis installed as the SIGINT handler (tui_app.py, indisplay()) and tears curses down inline:SIGINT can land anywhere — in the traceback above, inside
stdscr.getch().endwin()runs, but the display loop is still mid-iteration and callsrefresh()before it next checksis_running, which puts the screen back in running state.curses.wrapper()'s teardownendwin()then returns ERR, and because that fires from thefinally, 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:
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 insys.exit(), which propagates throughdisplay()andrun()'sfinallyintocurses.wrapper(), and the wrapper tears curses down on the way out. There is noos._exit()anywhere in the package, so that path is never bypassed. So the handler only needs to stop the loop: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
resetafter exiting. A failedendwin()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
main@ 43fa3e5)libncursesw.so.6in both builds comparedHappy to open a PR with the one-line fix and a unit test.