From b87e630318e330af4e2079e69750e33b30fc2abb Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 26 Jul 2026 23:23:42 +0300 Subject: [PATCH 1/2] gh-154749: Reject a terminal-less screen in curses.set_term() curses.set_term() accepted a screen returned by new_prescr(), which has no terminal attached. Making such a screen current left curses without a terminal, and the next window refresh dereferenced NULL and crashed the interpreter. set_term() now raises curses.error for a screen with no standard window, which is exactly a new_prescr() screen. The documentation already said the argument comes from newterm(). --- Doc/library/curses.rst | 2 ++ Lib/test/test_curses.py | 12 ++++++++++++ .../2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst | 3 +++ Modules/_cursesmodule.c | 8 +++++++- 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 9d0bb239af06df..a833914a5d56d4 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -129,6 +129,8 @@ Initialization and termination and return the previously current screen. Returns ``None`` if the previous screen was the one created by :func:`initscr`. + Raises :exc:`error` if *screen* has no terminal, + as is the case for a screen returned by :func:`new_prescr`. .. versionadded:: next diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index ad5893e6754f68..396c0e83328f3d 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -3072,6 +3072,18 @@ def test_new_prescr(self): del screen gc_collect() + @unittest.skipUnless(hasattr(curses, 'new_prescr'), + 'requires curses.new_prescr()') + def test_set_term_prescr_screen(self): + # A new_prescr() screen has no terminal, so it cannot become the + # current one. It used to be accepted, and the next refresh then + # crashed inside curses. + s = self.make_pty() + screen = curses.newterm('xterm', s, s) + self.assertRaises(curses.error, curses.set_term, curses.new_prescr()) + # The current screen is unchanged, so refreshing it still works. + screen.stdscr.refresh() + @cpython_only def test_disallow_instantiation(self): # The screen type cannot be instantiated directly (bpo-43916). diff --git a/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst b/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst new file mode 100644 index 00000000000000..e75e72187157db --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst @@ -0,0 +1,3 @@ +:func:`curses.set_term` now raises :exc:`curses.error` when given a screen with +no terminal, such as one returned by :func:`curses.new_prescr`. Such a screen +was accepted before, and the next window refresh crashed the interpreter. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index b2d745332317a3..c19ea338c862aa 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -6834,11 +6834,17 @@ _curses_set_term(PyObject *module, PyObject *screen) if (so == NULL) { return NULL; } + cursesmodule_state *state = get_cursesmodule_state(module); + if (so->stdscr_win == NULL) { + /* A screen from new_prescr() has no terminal, so it cannot become the + current one: a later refresh would dereference NULL in curses. */ + PyErr_SetString(state->error, "the screen has no terminal"); + return NULL; + } set_term(so->screen); if (!update_lines_cols(module)) { return NULL; } - cursesmodule_state *state = get_cursesmodule_state(module); PyObject *prev = state->topscreen; /* steal the owned reference */ state->topscreen = Py_NewRef(screen); return prev != NULL ? prev : Py_NewRef(Py_None); From 9a90d60ef3003aa21e162be9b89af63ade2fc55b Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Mon, 27 Jul 2026 20:01:58 +0300 Subject: [PATCH 2/2] Use requires_curses_func in the test, drop the NEWS entry Both from review: the test uses the existing requires_curses_func helper, and the NEWS entry goes away because set_term() and new_prescr() are both new in 3.16 and unreleased, so the crash was never reachable by a user. --- Lib/test/test_curses.py | 3 +-- .../Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 396c0e83328f3d..96112720570669 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -3072,8 +3072,7 @@ def test_new_prescr(self): del screen gc_collect() - @unittest.skipUnless(hasattr(curses, 'new_prescr'), - 'requires curses.new_prescr()') + @requires_curses_func('new_prescr') def test_set_term_prescr_screen(self): # A new_prescr() screen has no terminal, so it cannot become the # current one. It used to be accepted, and the next refresh then diff --git a/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst b/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst deleted file mode 100644 index e75e72187157db..00000000000000 --- a/Misc/NEWS.d/next/Library/2026-07-26-20-22-45.gh-issue-154749.6ekT3T.rst +++ /dev/null @@ -1,3 +0,0 @@ -:func:`curses.set_term` now raises :exc:`curses.error` when given a screen with -no terminal, such as one returned by :func:`curses.new_prescr`. Such a screen -was accepted before, and the next window refresh crashed the interpreter.