From 89e3aae7ad19efcc9cc35c5d9a411d2d2a245024 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 16 Aug 2026 08:29:48 +0300 Subject: [PATCH 1/7] gh-155860: Reject a detached window in panel.replace() (GH-155861) curses.screen.close() detaches the screen's standard window: the wrapper object stays alive but the curses window behind it is gone. panel.replace() did not check for that, so it stored the detached window in the panel and curses dereferenced it on the panel's next use, killing the interpreter with SIGSEGV. Raise curses.panel.error instead, the way new_panel() already does on the same window. --- Doc/library/curses.panel.rst | 2 ++ Lib/test/test_curses.py | 16 ++++++++++++++++ Modules/_curses_panel.c | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/Doc/library/curses.panel.rst b/Doc/library/curses.panel.rst index dd345bff428ad68..50e16847993e148 100644 --- a/Doc/library/curses.panel.rst +++ b/Doc/library/curses.panel.rst @@ -116,6 +116,8 @@ Panel objects .. method:: panel.replace(win) Change the window associated with the panel to the window *win*. + Raise :exc:`curses.panel.error` if *win* has been detached from its + screen by :meth:`screen.close() `. .. method:: panel.set_userptr(obj) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index d87374a298fc337..08afa587f041954 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -3079,6 +3079,22 @@ def test_close(self): # close() is idempotent. screen.close() + @requires_curses_func('panel') + def test_close_then_panel_replace(self): + # A detached window has no underlying curses window, so replace() + # must reject it. It used to be accepted, and the panel then + # crashed inside curses on its next use. + s = self.make_pty() + screen = curses.newterm('xterm', s, s) + win = screen.stdscr + panel = curses.panel.new_panel(curses.newwin(3, 6, 0, 0)) + # Drop the panel from the global stack before later tests inspect it. + self.addCleanup(gc_collect) + screen.close() + self.assertRaises(curses.panel.error, panel.replace, win) + # The panel kept its own window, so it still works. + panel.move(1, 1) + @unittest.skipUnless(hasattr(curses, 'new_prescr'), 'requires curses.new_prescr()') def test_new_prescr(self): diff --git a/Modules/_curses_panel.c b/Modules/_curses_panel.c index 742a3310bc3528a..78d7bf7c2636465 100644 --- a/Modules/_curses_panel.c +++ b/Modules/_curses_panel.c @@ -594,6 +594,12 @@ _curses_panel_panel_replace_impl(PyCursesPanelObject *self, return NULL; } + if (win->win == NULL) { + _curses_panel_state *state = get_curses_panel_state_by_panel(self); + PyErr_SetString(state->error, "the window has been detached"); + return NULL; + } + int rtn = replace_panel(self->pan, win->win); if (rtn == ERR) { curses_panel_panel_set_error(self, "replace_panel", "replace"); From 76ac2dec7991fdd6b9365689a36922d1c1ae0a38 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 16 Aug 2026 08:34:21 +0300 Subject: [PATCH 2/7] gh-154002: Do not wrap a constructor TypeError in pickle._Unpickler (GH-154003) It passed the traceback as the second argument to a new TypeError, so that the traceback object ended up in args and the original error was not chained. The original error now propagates, as in the C implementation. --- Lib/pickle.py | 6 +----- Lib/test/picklecommon.py | 7 +++++++ Lib/test/pickletester.py | 14 ++++++++++++++ .../2026-07-18-18-12-44.gh-issue-154002.Qw9zTn.rst | 4 ++++ 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-18-18-12-44.gh-issue-154002.Qw9zTn.rst diff --git a/Lib/pickle.py b/Lib/pickle.py index f92b1fde768fc7d..b475d96dd6576d9 100644 --- a/Lib/pickle.py +++ b/Lib/pickle.py @@ -1623,11 +1623,7 @@ def load_dict(self): def _instantiate(self, klass, args): if (args or not isinstance(klass, type) or hasattr(klass, "__getinitargs__")): - try: - value = klass(*args) - except TypeError as err: - raise TypeError("in constructor for %s: %s" % - (klass.__name__, str(err)), err.__traceback__) + value = klass(*args) else: value = klass.__new__(klass) self.append(value) diff --git a/Lib/test/picklecommon.py b/Lib/test/picklecommon.py index bb8e41b01492ead..5dd56c4fbf9ec81 100644 --- a/Lib/test/picklecommon.py +++ b/Lib/test/picklecommon.py @@ -17,6 +17,11 @@ class E(C): def __getinitargs__(self): return () +# For test_load_bad_constructor +class BadConstructor: + def __init__(self, *args): + raise TypeError("bad constructor") + import __main__ __main__.C = C C.__module__ = "__main__" @@ -24,6 +29,8 @@ def __getinitargs__(self): D.__module__ = "__main__" __main__.E = E E.__module__ = "__main__" +__main__.BadConstructor = BadConstructor +BadConstructor.__module__ = "__main__" # Simple mutable object. class Object(object): diff --git a/Lib/test/pickletester.py b/Lib/test/pickletester.py index 83a71b7efedd379..c53262e358b48ea 100644 --- a/Lib/test/pickletester.py +++ b/Lib/test/pickletester.py @@ -846,6 +846,20 @@ def test_load_classic_instance(self): b'q\x00oq\x01}q\x02b.').replace(b'X', xname) self.assert_is_copy(X(*args), self.loads(pickle2)) + def test_load_bad_constructor(self): + # gh-154002: a TypeError raised by an old-style instance constructor + # during INST/OBJ unpickling propagates unchanged. The pure-Python + # unpickler used to replace it with one that carried the traceback + # object in its args. + # 0: ( MARK + # 1: I INT 1 + # 4: i INST '__main__ BadConstructor' (MARK at 0) + # 28: . STOP + data = b'(I1\ni__main__\nBadConstructor\n.' + with self.assertRaises(TypeError) as cm: + self.loads(data) + self.assertEqual(cm.exception.args, ("bad constructor",)) + def test_maxint64(self): maxint64 = (1 << 63) - 1 data = b'I' + str(maxint64).encode("ascii") + b'\n.' diff --git a/Misc/NEWS.d/next/Library/2026-07-18-18-12-44.gh-issue-154002.Qw9zTn.rst b/Misc/NEWS.d/next/Library/2026-07-18-18-12-44.gh-issue-154002.Qw9zTn.rst new file mode 100644 index 000000000000000..0475ce7330b6fa8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-18-18-12-44.gh-issue-154002.Qw9zTn.rst @@ -0,0 +1,4 @@ +The pure-Python :mod:`pickle` unpickler no longer replaces a :exc:`TypeError` +raised by an old-style instance constructor with a new one carrying the +traceback object in its ``args``. The original error now propagates, as it +already did in the C implementation. From f10166035d602da5052e8a48f9d5c216c57b401d Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 16 Aug 2026 10:45:05 +0300 Subject: [PATCH 3/7] gh-155864: Keep the module's current screen in step with use_screen() (GH-155865) screen.use() makes its screen current for the callback, but the module kept recording the previously current screen, so newwin(), newpad() and getwin() tagged the new window with the wrong owner. The window then failed to keep its own screen alive: the screen could be freed while the window was still in use, and the next call on it read freed memory. initscr() inside use() returned the other screen's standard window for the same reason. --- Lib/test/test_curses.py | 27 +++++++++++++++++++++++++++ Modules/_cursesmodule.c | 5 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 08afa587f041954..f582336fae17344 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -3056,6 +3056,33 @@ def test_window_keeps_screen_alive(self): win.addstr(0, 0, 'still alive') win.refresh() + @unittest.skipUnless(hasattr(curses.screen, 'use'), + 'requires curses.screen.use()') + def test_window_made_in_use_keeps_its_screen_alive(self): + # use() makes its screen current for the callback, so a window created + # there belongs to that screen and must keep it alive, not the screen + # that was current before. + s = self.make_pty() + s2 = self.make_pty() + a = curses.newterm('xterm', s, s) + b = curses.newterm('xterm', s2, s2) # current screen is b + win = a.use(lambda scr: curses.newwin(3, 3)) + del a + gc_collect() + win.addstr(0, 0, 'x') + b.stdscr.refresh() + + @unittest.skipUnless(hasattr(curses.screen, 'use'), + 'requires curses.screen.use()') + def test_initscr_in_use_returns_its_screen(self): + # initscr() returns the standard window of the current screen, and + # inside use() that is the used screen. + s = self.make_pty() + s2 = self.make_pty() + a = curses.newterm('xterm', s, s) + b = curses.newterm('xterm', s2, s2) # current screen is b + self.assertIs(a.use(lambda scr: curses.initscr()), a.stdscr) + def test_screen_freed(self): # Dropping all references to a (non-current) screen and its windows # frees it without error. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 383de378670ea97..006e27d55d8925d 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -5303,8 +5303,8 @@ static PyObject * PyCursesScreen_use(PyObject *self, PyObject *args, PyObject *kwargs) { PyCursesScreenObject *so = _PyCursesScreenObject_CAST(self); + cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(self)); if (so->screen == NULL) { - cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(self)); PyErr_SetString(state->error, "the screen has been deleted"); return NULL; } @@ -5313,7 +5313,10 @@ PyCursesScreen_use(PyObject *self, PyObject *args, PyObject *kwargs) return NULL; } curses_use_data data = {self, func, extra, kwargs, NULL}; + PyObject *prev = state->topscreen; + state->topscreen = Py_NewRef(self); use_screen(so->screen, curses_use_screen_cb, &data); + Py_SETREF(state->topscreen, prev); Py_DECREF(extra); return data.result; } From 13aa41f4e253f23c8a2adb5565996cc9227d596e Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Sun, 16 Aug 2026 10:51:56 +0300 Subject: [PATCH 4/7] gh-155389: Return bytes from _pyio.BytesIO.peek() (GH-155390) peek() returned a slice of the internal bytearray, where read() converts with take_bytes(). It also did not coerce its size through __index__ and did not hold the lock while slicing, both of which read() and the C implementation do. --- Lib/_pyio.py | 14 ++++++++++++-- Lib/test/test_io/test_memoryio.py | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Lib/_pyio.py b/Lib/_pyio.py index ac301180d284fa9..cf4ef04f37d26cc 100644 --- a/Lib/_pyio.py +++ b/Lib/_pyio.py @@ -1003,9 +1003,19 @@ def tell(self): def peek(self, size=0): if self.closed: raise ValueError("peek on closed file") + try: + size_index = size.__index__ + except AttributeError: + raise TypeError(f"{size!r} is not an integer") + else: + size = size_index() + if size < 1: - return self._buffer[self._pos:self._pos + io.DEFAULT_BUFFER_SIZE] - return self._buffer[self._pos:self._pos + size] + size = io.DEFAULT_BUFFER_SIZE + + with self._lock: + b = self._buffer[self._pos:self._pos + size] + return b.take_bytes() def truncate(self, pos=None): if self.closed: diff --git a/Lib/test/test_io/test_memoryio.py b/Lib/test/test_io/test_memoryio.py index 0037fdc2fd67c1a..e934e3fb2bdf124 100644 --- a/Lib/test/test_io/test_memoryio.py +++ b/Lib/test/test_io/test_memoryio.py @@ -596,6 +596,11 @@ def test_peek(self): buf = self.buftype("1234567890") with self.ioclass(buf) as memio: self.assertEqual(memio.tell(), 0) + # bytearray(b'1') == b'1', so the type has to be asserted separately. + self.assertIsInstance(memio.peek(), bytes) + self.assertIsInstance(memio.peek(1), bytes) + self.assertEqual(memio.peek(IntLike(3)), buf[:3]) + self.assertRaises(TypeError, memio.peek, 1.5) self.assertEqual(memio.peek(1), buf[:1]) self.assertEqual(memio.peek(1), buf[:1]) self.assertEqual(memio.peek(), buf) From c1447994a42004a81a8caca026218daf69d451c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:23:02 +0200 Subject: [PATCH 5/7] gh-155717: use `spawn` as the default start method for read-only filesystems (#155827) The "forkserver" start method (the default start method on non-Windows systems) requires the ability to write temporary files, which is not possible if TMPDIR is read-only (e.g., k8s containers mounted with `readOnlyRootFilesystem=True`). On such filesystems, the default start method changes from "forkserver" to "spawn". --- Lib/multiprocessing/context.py | 8 ++++- Lib/multiprocessing/util.py | 31 ++++++++++++++-- Lib/test/_test_multiprocessing.py | 35 +++++++++++++++++++ ...-08-15-09-47-23.gh-issue-155717.jWFLR2.rst | 3 ++ 4 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst diff --git a/Lib/multiprocessing/context.py b/Lib/multiprocessing/context.py index 45c393798deaca2..e94e6c8690bf506 100644 --- a/Lib/multiprocessing/context.py +++ b/Lib/multiprocessing/context.py @@ -4,6 +4,7 @@ from . import process from . import reduction +from . import util __all__ = () @@ -333,7 +334,12 @@ def _check_available(self): # bpo-33725: running arbitrary code after fork() is no longer reliable # on macOS since macOS 10.14 (Mojave). Use spawn by default instead. # gh-84559: We changed everyones default to a thread safeish one in 3.14. - if reduction.HAVE_SEND_HANDLE and sys.platform != 'darwin': + if ( + reduction.HAVE_SEND_HANDLE + and sys.platform != 'darwin' + # gh-155717: forkserver requires to write temporary files + and util._has_writeable_tempdir() + ): _default_context = DefaultContext(_concrete_contexts['forkserver']) else: _default_context = DefaultContext(_concrete_contexts['spawn']) diff --git a/Lib/multiprocessing/util.py b/Lib/multiprocessing/util.py index 549fb07c27549e0..cf7e0b2990598b7 100644 --- a/Lib/multiprocessing/util.py +++ b/Lib/multiprocessing/util.py @@ -10,6 +10,7 @@ import os import itertools import sys +import tempfile import weakref import atexit import threading # we want threading to install it's @@ -143,6 +144,7 @@ def is_abstract_socket_namespace(address): # On Windows platforms, we do not create AF_UNIX sockets. _SUN_PATH_MAX = None if os.name == 'nt' else 92 + def _remove_temp_dir(rmtree, tempdir): rmtree(tempdir) @@ -152,7 +154,8 @@ def _remove_temp_dir(rmtree, tempdir): if current_process is not None: current_process._config['tempdir'] = None -def _get_base_temp_dir(tempfile): + +def _get_base_temp_dir(): """Get a temporary directory where socket files will be created. To prevent additional imports, pass a pre-imported 'tempfile' module. @@ -208,12 +211,13 @@ def _get_base_temp_dir(tempfile): assert len(base_system_tempdir) + 14 + 14 < _SUN_PATH_MAX return base_system_tempdir + def get_temp_dir(): # get name of a temp directory which will be automatically cleaned up tempdir = process.current_process()._config.get('tempdir') if tempdir is None: - import shutil, tempfile - base_tempdir = _get_base_temp_dir(tempfile) + import shutil + base_tempdir = _get_base_temp_dir() tempdir = tempfile.mkdtemp(prefix='pymp-', dir=base_tempdir) info('created temp directory %s', tempdir) # keep a strong reference to shutil.rmtree(), since the finalizer @@ -223,6 +227,27 @@ def get_temp_dir(): process.current_process()._config['tempdir'] = tempdir return tempdir + +def _has_writeable_tempdir(): + # 'forkserver' requires writeable temporary files. This function is + # called to determine the default context's start method. + # + # See: https://github.com/python/cpython/issues/155717. + + path = _get_base_temp_dir() + if path is None: + return False + + # os.access() is advisory and racy. It can lie on read-only filesystems, + # NFS/network mounts, containers, and immutable-flag files, so we simply + # try to create a file to check if this works and delete it otherwise. + try: + with tempfile.NamedTemporaryFile(dir=path): + return True + except OSError: + return False + + # # Support for reinitialization of objects when bootstrapping a child process # diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index ba1c0de5d283323..4aaaa22f4274f03 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -26,6 +26,7 @@ import struct import tempfile import operator +import pathlib import pickle import weakref import warnings @@ -6355,6 +6356,40 @@ def test_nested_startmethod(self): # there is no synchronization in the test. self.assertSetEqual(set(results), set([2, 1])) + @unittest.skipIf(os.name == "nt", "requires POSIX") + @support.subTests("mode", [ + os.R_OK, # read-only directory + os.R_OK | os.X_OK, # read-only directory + os.W_OK # write-only directory _without_ permissions for creating files + ]) + def test_forkserver_requires_writeable_tempdir(self, mode): + # Regression test to ensure that the defualt start method is + # not 'forkserver' when the temporary directory is not writeable. + # + # See https://github.com/python/cpython/issues/155717. + + cmd = '''if 1: + import os, tempfile + # We fake the read-onlyiness of /tmp (which is a fallback when + # the user-defined TMPDIR is not acceptable) by hardcoding the + # temporary directory for this specific test. + tempfile.tempdir = os.environ["TMPDIR"] + + # Imported after patching 'tempfile' so that the default start + # method is deduced according to the permissions of TMPDIR. + import multiprocessing + if __name__ == "__main__": + print(multiprocessing.get_start_method()) + ''' + + with support.os_helper.temp_dir() as root: + TMPDIR = pathlib.Path(root, "TMPDIR") + TMPDIR.mkdir(mode=mode) + file = pathlib.Path(TMPDIR, "file") + self.assertRaises(OSError, file.touch) + _, out, err = script_helper.assert_python_ok('-c', cmd, TMPDIR=TMPDIR) + self.assertEqual(out.decode().strip(), "spawn") + @unittest.skipIf(sys.platform == "win32", "test semantics don't make sense on Windows") diff --git a/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst b/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst new file mode 100644 index 000000000000000..0994dc8d03051f4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-09-47-23.gh-issue-155717.jWFLR2.rst @@ -0,0 +1,3 @@ +:mod:`multiprocessing`'s default start method on systems with non-writeable +tempfile filesystem is now :ref:`"spawn" ` +instead of ``"forkserver"``. Patch by Bénédikt Tran. From b2c299373d86b670f50d930564da22550af55308 Mon Sep 17 00:00:00 2001 From: Timofei Ivankov <128279579+deadlovelll@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:42:55 +0300 Subject: [PATCH 6/7] gh-155888: Fix asyncio writelines() hanging on an empty chunk (#155889) --- Lib/asyncio/selector_events.py | 5 +++++ Lib/test/test_asyncio/test_events.py | 13 +++++++++++++ .../2026-08-16-11-38-17.gh-issue-155888.pO-nAp.rst | 2 ++ 3 files changed, 20 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-08-16-11-38-17.gh-issue-155888.pO-nAp.rst diff --git a/Lib/asyncio/selector_events.py b/Lib/asyncio/selector_events.py index 19ffd8cc98b0e9d..83916160b9fbde9 100644 --- a/Lib/asyncio/selector_events.py +++ b/Lib/asyncio/selector_events.py @@ -1198,8 +1198,13 @@ def writelines(self, list_of_data): return for data in list_of_data: + # gh-155888: an empty chunk can never be drained, so never buffer it + if not data: + continue self._buffer.append(memoryview(data)) self._buffer_size += len(data) + if not self._buffer: + return self._write_ready() # If the entire buffer couldn't be written, register a write handler if self._buffer: diff --git a/Lib/test/test_asyncio/test_events.py b/Lib/test/test_asyncio/test_events.py index f7cd59a54199710..db316fae090280a 100644 --- a/Lib/test/test_asyncio/test_events.py +++ b/Lib/test/test_asyncio/test_events.py @@ -560,6 +560,19 @@ def writer(data): r.close() self.assertEqual(read, data) + def test_writelines_empty_chunk(self): + # gh-155888: an empty chunk can never be drained, so never buffer it + rsock, wsock = socket.socketpair() + self.addCleanup(rsock.close) + + async def main(): + reader, writer = await asyncio.open_connection(sock=wsock) + writer.writelines([b'data', b'']) + writer.close() + await asyncio.wait_for(writer.wait_closed(), support.SHORT_TIMEOUT) + + self.loop.run_until_complete(main()) + @unittest.skipUnless(hasattr(signal, 'SIGKILL'), 'No SIGKILL') def test_add_signal_handler(self): caught = 0 diff --git a/Misc/NEWS.d/next/Library/2026-08-16-11-38-17.gh-issue-155888.pO-nAp.rst b/Misc/NEWS.d/next/Library/2026-08-16-11-38-17.gh-issue-155888.pO-nAp.rst new file mode 100644 index 000000000000000..df3db71b912e1c2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-16-11-38-17.gh-issue-155888.pO-nAp.rst @@ -0,0 +1,2 @@ +Fix :meth:`asyncio.WriteTransport.writelines` hanging the transport when the +last data chunk is empty. From 4dd8f0bbf4ca7dc4e29290b3326ce50f2199e6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:48:31 +0200 Subject: [PATCH 7/7] gh-155717: skip `TMPDIR` writeability test if process user is root (#155891) --- Lib/test/_test_multiprocessing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index 4aaaa22f4274f03..338a31fd7f869ea 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -6357,6 +6357,7 @@ def test_nested_startmethod(self): self.assertSetEqual(set(results), set([2, 1])) @unittest.skipIf(os.name == "nt", "requires POSIX") + @support.requires_non_root_user @support.subTests("mode", [ os.R_OK, # read-only directory os.R_OK | os.X_OK, # read-only directory