From 7ebe773160327026ba5393b27e2e2860db4d08b2 Mon Sep 17 00:00:00 2001 From: Brij Kapadia <97006829+brijkapadia@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:07:17 -0400 Subject: [PATCH 1/7] gh-153881: Atomically load `dk_nentries` in `_PyObject_IsInstanceDictEmpty` (#153882) --- Lib/test/test_free_threading/test_dict.py | 20 +++++++++++++++++++ ...-07-17-22-03-39.gh-issue-153881.oDa06s.rst | 2 ++ Objects/dictobject.c | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-07-17-22-03-39.gh-issue-153881.oDa06s.rst diff --git a/Lib/test/test_free_threading/test_dict.py b/Lib/test/test_free_threading/test_dict.py index ad23290a92ab345..4a812275143bc4d 100644 --- a/Lib/test/test_free_threading/test_dict.py +++ b/Lib/test/test_free_threading/test_dict.py @@ -336,5 +336,25 @@ def reader(): with threading_helper.start_threads([t1, t2]): pass + def test_getstate_race_with_shared_keys(self): + box = [None] + enter = Barrier(2) + leave = Barrier(2) + + def reader(): + for _ in range(1000): + enter.wait() + box[0].__getstate__() + leave.wait() + + def writer(): + for i in range(1000): + box[0] = type(f"C{i}", (), {})() + enter.wait() + setattr(box[0], str(i), 1) + leave.wait() + + threading_helper.run_concurrently([reader, writer]) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-17-22-03-39.gh-issue-153881.oDa06s.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-17-22-03-39.gh-issue-153881.oDa06s.rst new file mode 100644 index 000000000000000..8facf88367fda7f --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-17-22-03-39.gh-issue-153881.oDa06s.rst @@ -0,0 +1,2 @@ +Fix potential data race when calling :meth:`~object.__getstate__` +under the :term:`free-threaded build`. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 246ffe6c18e9b51..c650aa456d2cc9d 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -7722,7 +7722,7 @@ _PyObject_IsInstanceDictEmpty(PyObject *obj) PyDictValues *values = _PyObject_InlineValues(obj); if (FT_ATOMIC_LOAD_UINT8(values->valid)) { PyDictKeysObject *keys = CACHED_KEYS(tp); - for (Py_ssize_t i = 0; i < keys->dk_nentries; i++) { + for (Py_ssize_t i = 0; i < LOAD_KEYS_NENTRIES(keys); i++) { if (FT_ATOMIC_LOAD_PTR_RELAXED(values->values[i]) != NULL) { return 0; } From dc72be1923a37042aee60354059e43d472de7803 Mon Sep 17 00:00:00 2001 From: Solaris-star <67425364+Solaris-star@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:45:42 +0800 Subject: [PATCH 2/7] gh-154320: Fix swapped __defaults__/__kwdefaults__ in annotationlib docs (#154321) The bullet list under "Creating a custom callable annotate function" had the attribute names reversed: positional defaults are __defaults__ (a tuple) and keyword defaults are __kwdefaults__ (a dict). The example code below was already correct. Fixes #154320 --- Doc/library/annotationlib.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst index af28fe0e2fde2f8..a90754620b59fdf 100644 --- a/Doc/library/annotationlib.rst +++ b/Doc/library/annotationlib.rst @@ -535,9 +535,9 @@ following attributes: :attr:`~Format.VALUE_WITH_FAKE_GLOBALS`. * A :ref:`code object ` ``__code__`` containing the compiled code for the annotate function. -* Optional: A tuple of the function's positional defaults ``__kwdefaults__``, if the +* Optional: A tuple of the function's positional defaults ``__defaults__``, if the function represented by ``__code__`` uses any positional defaults. -* Optional: A dict of the function's keyword defaults ``__defaults__``, if the function +* Optional: A dict of the function's keyword defaults ``__kwdefaults__``, if the function represented by ``__code__`` uses any keyword defaults. * Optional: All other :ref:`function attributes `. From f69f7fd526b6601a74ce0852c75466a183a86cea Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 21 Jul 2026 16:12:20 +0300 Subject: [PATCH 3/7] gh-154345: Fix test_posix_pty_functions() killing the worker on Solaris (GH-154346) Pushing the "ptem" STREAMS module makes a session leader without a controlling terminal acquire the slave as one, so closing the file descriptors sent SIGHUP to the session and killed the regrtest worker. Disown it after the pushes, as os.openpty() does. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_os/test_os.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 41928b308c47799..b4310043b60178b 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -4728,12 +4728,27 @@ def test_posix_pty_functions(self): son_fd = os.open(son_path, os.O_RDWR|os.O_NOCTTY) self.addCleanup(os.close, son_fd) if sys.platform.startswith('sunos'): - # The slave is not a terminal until these STREAMS modules - # are pushed onto it. import fcntl I_PUSH = 0x5302 + TIOCNOTTY = 0x7471 + # Pushing "ptem" makes the slave a terminal, which a session + # leader without a controlling terminal then acquires as one + # despite O_NOCTTY. Note whether we already had one. + try: + os.close(os.open('/dev/tty', os.O_RDONLY|os.O_NOCTTY)) + had_ctty = True + except OSError: + had_ctty = False fcntl.ioctl(son_fd, I_PUSH, b'ptem\0') fcntl.ioctl(son_fd, I_PUSH, b'ldterm\0') + if not had_ctty and os.getsid(0) == os.getpid(): + # Disown it, otherwise closing the file descriptors sends + # SIGHUP to the session. TIOCNOTTY sends it too. + old_handler = signal.signal(signal.SIGHUP, signal.SIG_IGN) + try: + fcntl.ioctl(son_fd, TIOCNOTTY) + finally: + signal.signal(signal.SIGHUP, old_handler) self.assertEqual(os.ptsname(mother_fd), os.ttyname(son_fd)) @warnings_helper.ignore_fork_in_thread_deprecation_warnings() From bfd774d61eeb384b74380deb1fc5b726decba75e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 21 Jul 2026 16:22:20 +0300 Subject: [PATCH 4/7] gh-154352: Fix killing a worker process in regrtest on OpenBSD (GH-154353) os.getsid() is only allowed for a process in the same session on OpenBSD. Worker processes are started in their own session, so the failure means that the process group can be killed. Co-authored-by: Claude Opus 4.8 --- Lib/test/libregrtest/run_workers.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Lib/test/libregrtest/run_workers.py b/Lib/test/libregrtest/run_workers.py index befdac7ee77f107..7e6c7fa4cc5507a 100644 --- a/Lib/test/libregrtest/run_workers.py +++ b/Lib/test/libregrtest/run_workers.py @@ -147,9 +147,12 @@ def _kill(self) -> None: use_killpg = USE_PROCESS_GROUP if use_killpg: - parent_sid = os.getsid(0) - sid = os.getsid(popen.pid) - use_killpg = (sid != parent_sid) + try: + use_killpg = (os.getsid(popen.pid) != os.getsid(0)) + except PermissionError: + # On OpenBSD getsid() is only allowed for a process in the + # same session, so the failure means that it is not. + use_killpg = True if use_killpg: what = f"{self} process group" From a6e1b836ec5055cbc8be477ca0c2df0cc12b8cb5 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 21 Jul 2026 18:03:20 +0300 Subject: [PATCH 5/7] gh-154366: Fix test_asyncio.test_sendfile timing out on DragonFly BSD (GH-154367) A 4 KiB receive buffer makes DragonFly defer every window update to the delayed ACK timer, so each buffer worth of data costs 100 ms. Use a larger socket buffer there; it is still small enough to pause the protocol long before all data is sent. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_asyncio/test_sendfile.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_asyncio/test_sendfile.py b/Lib/test/test_asyncio/test_sendfile.py index 69ac9a367f6be02..c8d429c3d1651f0 100644 --- a/Lib/test/test_asyncio/test_sendfile.py +++ b/Lib/test/test_asyncio/test_sendfile.py @@ -98,7 +98,12 @@ class SendfileBase: # 64 KiB page configuration. DATA = b"x" * (1024 * 17 * 64 + 1) # Reduce socket buffer size to test on relative small data sets. - BUF_SIZE = 4 * 1024 # 4 KiB + if sys.platform.startswith('dragonfly'): + # A smaller buffer makes every window update wait 100 ms for the + # delayed ACK timer. + BUF_SIZE = 32 * 1024 # 32 KiB + else: + BUF_SIZE = 4 * 1024 # 4 KiB def create_event_loop(self): raise NotImplementedError From d333e5aa59f6cc20198502f4ff50b14eabde9b8b Mon Sep 17 00:00:00 2001 From: David Date: Tue, 21 Jul 2026 17:08:01 +0200 Subject: [PATCH 6/7] Test `object_hook` and pickleability of some JSON components (GH-154155) --- Lib/test/test_json/test_decode.py | 20 ++++++++++++++++++++ Lib/test/test_json/test_pickleable.py | 14 ++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 Lib/test/test_json/test_pickleable.py diff --git a/Lib/test/test_json/test_decode.py b/Lib/test/test_json/test_decode.py index 33aeefa92f282ae..7bff16ddf2155e9 100644 --- a/Lib/test/test_json/test_decode.py +++ b/Lib/test/test_json/test_decode.py @@ -48,6 +48,26 @@ def test_empty_objects(self): self.assertEqual(self.loads('[]'), []) self.assertEqual(self.loads('""'), "") + def test_object_hook(self): + s = '{"a":{"b":{}}}' + + expected_result = {"a":{"b":{"x":1}, "x":1}, "x":1} + expected_hook_arguments = [ + {}, {"b": {"x":1}}, {"a": {"b": {"x":1}, "x":1}} + ] + + hook_arguments = [] + + def hook(x): + hook_arguments.append(x) + return {**x, "x":1} + + result = self.loads(s, object_hook=hook) + + self.assertEqual(result, expected_result) + self.assertEqual(hook_arguments, expected_hook_arguments) + + def test_object_pairs_hook(self): s = '{"xkd":1, "kcw":2, "art":3, "hxm":4, "qrt":5, "pad":6, "hoy":7}' p = [("xkd", 1), ("kcw", 2), ("art", 3), ("hxm", 4), diff --git a/Lib/test/test_json/test_pickleable.py b/Lib/test/test_json/test_pickleable.py new file mode 100644 index 000000000000000..517cfcdd41a3516 --- /dev/null +++ b/Lib/test/test_json/test_pickleable.py @@ -0,0 +1,14 @@ +import json +import pickle +import unittest + +class TestPickleable(unittest.TestCase): + def test_json_decode_error_is_pickleable(self): + e = json.JSONDecodeError(msg="abc", doc="def", pos=7) + + pickled = pickle.dumps(e) + unpickled = pickle.loads(pickled) + + self.assertEqual(unpickled.msg, e.msg) + self.assertEqual(unpickled.doc, e.doc) + self.assertEqual(unpickled.pos, e.pos) From b6abee522f1dbbba36cc306aa99ac22d1df5c454 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 21 Jul 2026 19:02:51 +0300 Subject: [PATCH 7/7] gh-154379: Fix test_epoll on Solaris (GH-154380) A non-blocking connect() can be completed immediately, and unregistering a closed file descriptor succeeds on Solaris. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_epoll.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_epoll.py b/Lib/test/test_epoll.py index 5e6a4ab0166a86f..51405ca7c9e57c3 100644 --- a/Lib/test/test_epoll.py +++ b/Lib/test/test_epoll.py @@ -25,6 +25,7 @@ import os import select import socket +import sys import time import unittest from test import support @@ -52,12 +53,11 @@ def tearDown(self): def _connected_pair(self): client = socket.socket() client.setblocking(False) + # The connection is either established immediately or in progress. try: client.connect(('127.0.0.1', self.serverSocket.getsockname()[1])) except OSError as e: self.assertEqual(e.args[0], errno.EINPROGRESS) - else: - raise AssertionError("Connect should have raised EINPROGRESS") server, addr = self.serverSocket.accept() self.connections.extend((client, server)) @@ -218,6 +218,9 @@ def test_errors(self): self.assertRaises(ValueError, select.epoll().register, -1, select.EPOLLIN) + @unittest.skipIf(sys.platform.startswith('sunos'), + 'unregistering a closed file descriptor succeeds ' + 'on Solaris') def test_unregister_closed(self): server, client = self._connected_pair() fd = server.fileno()