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/_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/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/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/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/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index ba1c0de5d283323..338a31fd7f869ea 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,41 @@ 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.requires_non_root_user + @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/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/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/Lib/test/test_curses.py b/Lib/test/test_curses.py index d87374a298fc337..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. @@ -3079,6 +3106,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/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) 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. 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. 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. 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"); 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; }