diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index c6f0fb8a50917ac..689a6a6d2e0bd92 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -402,69 +402,85 @@ Example:: task2 = tg.create_task(another_coro(...)) print(f"Both tasks have completed now: {task1.result()}, {task2.result()}") -The ``async with`` statement will wait for all tasks in the group to finish. -While waiting, new tasks may still be added to the group -(for example, by passing ``tg`` into one of the coroutines -and calling ``tg.create_task()`` in that coroutine). There is also opportunity to -request termination of the entire task group with ``tg.cancel()``, based on some condition. -Once the last task has finished and the ``async with`` block is exited, -no new tasks may be added to the group. - -The first time any of the tasks belonging to the group fails -with an exception other than :exc:`asyncio.CancelledError`, -the remaining tasks in the group are cancelled. -No further tasks can then be added to the group. -At this point, if the body of the ``async with`` statement is still active -(i.e., :meth:`~object.__aexit__` hasn't been called yet), -the task directly containing the ``async with`` statement is also cancelled. -The resulting :exc:`asyncio.CancelledError` will interrupt an ``await``, -but it will not bubble out of the containing ``async with`` statement. - -Once all tasks have finished, if any tasks have failed -with an exception other than :exc:`asyncio.CancelledError`, -those exceptions are combined in an -:exc:`ExceptionGroup` or :exc:`BaseExceptionGroup` -(as appropriate; see their documentation) -which is then raised. - -Two base exceptions are treated specially: -If any task fails with :exc:`KeyboardInterrupt` or :exc:`SystemExit`, -the task group still cancels the remaining tasks and waits for them, -but then the initial :exc:`KeyboardInterrupt` or :exc:`SystemExit` -is re-raised instead of :exc:`ExceptionGroup` or :exc:`BaseExceptionGroup`. - -If the body of the ``async with`` statement exits with an exception -(so :meth:`~object.__aexit__` is called with an exception set), -this is treated the same as if one of the tasks failed: -the remaining tasks are cancelled and then waited for, -and non-cancellation exceptions are grouped into an -exception group and raised. -The exception passed into :meth:`~object.__aexit__`, -unless it is :exc:`asyncio.CancelledError`, -is also included in the exception group. -The same special case is made for -:exc:`KeyboardInterrupt` and :exc:`SystemExit` as in the previous paragraph. -There is an additional special case made only for the body of the -``async with``: if it raises :exc:`GeneratorExit` and none of the -other tasks raise exceptions that would be reported, then the -:exc:`GeneratorExit` is reraised. - -Task groups are careful not to mix up the internal cancellation used to -"wake up" their :meth:`~object.__aexit__` with cancellation requests -for the task in which they are running made by other parties. +A few points to keep in mind when using task groups: + +* The ``async with`` statement will wait for all tasks in the group + to finish. While waiting, new tasks may still be added to the group + (for example, by passing ``tg`` into one of the coroutines and + calling ``tg.create_task()`` in that coroutine); once the last task + has finished and the ``async with`` block is exited, no new tasks + may be added. + +* Termination of the entire task group may be requested with + ``tg.cancel()``, based on some condition. + +* If the group is shut down (e.g. because another task failed) before + a newly created task has started running, the task is cancelled + without its coroutine executing at all, not even to its first + ``await``. To guarantee that the coroutine starts, create the task + eagerly with ``eager_start=True`` or use + :func:`asyncio.eager_task_factory`. For example:: + + async def job(): + print("job started") # never printed + try: + await asyncio.sleep(1) + finally: + print("job cleaned up") # never printed + + async def main(): + async with asyncio.TaskGroup() as tg: + tg.create_task(job()) + raise RuntimeError # shuts down the group before job() runs + + With ``tg.create_task(job(), eager_start=True)``, ``job()`` runs up + to the ``await``, is cancelled there, and both messages are printed. + +When any of the tasks belonging to the group fails with an exception +other than :exc:`asyncio.CancelledError` (or the body of the +``async with`` statement exits with an exception, which is treated +the same way): + +* The first time this happens, the remaining tasks in the group are + cancelled and then waited for, and no further tasks can be added to + the group. If the body of the ``async with`` statement is still + active (i.e., :meth:`~object.__aexit__` hasn't been called yet), + the task directly containing the ``async with`` statement is also + cancelled. The resulting :exc:`asyncio.CancelledError` will + interrupt an ``await``, but it will not bubble out of the containing + ``async with`` statement. + +* Once all tasks have finished, the non-cancellation exceptions -- + including the exception the body exited with, unless it is + :exc:`asyncio.CancelledError` -- are combined in an + :exc:`ExceptionGroup` or :exc:`BaseExceptionGroup` + (as appropriate; see their documentation), which is then raised. + +* Some exceptions are treated specially: if any task fails with + :exc:`KeyboardInterrupt` or :exc:`SystemExit`, the task group still + cancels the remaining tasks and waits for them, but then the initial + :exc:`KeyboardInterrupt` or :exc:`SystemExit` is re-raised instead + of :exc:`ExceptionGroup` or :exc:`BaseExceptionGroup`. + Additionally, if the body of the ``async with`` statement raises + :exc:`GeneratorExit` and none of the other tasks raise exceptions + that would be reported, the :exc:`GeneratorExit` is re-raised. + +Task groups are careful not to mix up the internal cancellation used +to "wake up" their :meth:`~object.__aexit__` with cancellation +requests for the task in which they are running made by other parties. In particular, when one task group is syntactically nested in another, -and both experience an exception in one of their child tasks simultaneously, -the inner task group will process its exceptions, and then the outer task group -will receive another cancellation and process its own exceptions. +and both experience an exception in one of their child tasks +simultaneously, the inner task group will process its exceptions, and +then the outer task group will receive another cancellation and +process its own exceptions. In the case where a task group is cancelled externally and also must raise an :exc:`ExceptionGroup`, it will call the parent task's -:meth:`~asyncio.Task.cancel` method. This ensures that a +:meth:`~asyncio.Task.cancel` method. This ensures that a :exc:`asyncio.CancelledError` will be raised at the next -:keyword:`await`, so the cancellation is not lost. - -Task groups preserve the cancellation count -reported by :meth:`asyncio.Task.cancelling`. +:keyword:`await`, so the cancellation is not lost. Task groups also +preserve the cancellation count reported by +:meth:`asyncio.Task.cancelling`. .. versionchanged:: 3.13 diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 525ec3a0c858ff1..0f033efefd209cd 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -2960,6 +2960,11 @@ features: .. audit-event:: os.scandir path os.scandir + Sharing a :func:`scandir` iterator between threads will not corrupt the + iterator, but it is subject to :term:`race conditions `: + which entries each thread receives is unspecified, and closing the iterator + while another thread is iterating ends that iteration early. + The :func:`scandir` iterator supports the :term:`context manager` protocol and has the following method: diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index b9781de77860bb2..bcc8f855c06f71d 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -233,15 +233,15 @@ always available. Unless explicitly noted otherwise, all variables are read-only .. function:: _clear_type_cache() - Clear the internal type cache. The type cache is used to speed up attribute - and method lookups. Use the function *only* to drop unnecessary references - during reference leak debugging. - - This function should be used for internal and specialized purposes only. + This function is a no-op. It used to clear the internal type cache, which + is now implemented per-type. .. deprecated:: 3.13 Use the more general :func:`_clear_internal_caches` function instead. + .. versionchanged:: 3.16 + This function is now a no-op. + .. function:: _clear_internal_caches() @@ -250,6 +250,9 @@ always available. Unless explicitly noted otherwise, all variables are read-only .. versionadded:: 3.13 + .. versionchanged:: 3.16 + The type cache is no longer cleared, as it is now implemented per-type. + .. function:: _current_frames() diff --git a/Doc/library/sys_path_init.rst b/Doc/library/sys_path_init.rst index e6c2cddbe842487..329e49253e91aa4 100644 --- a/Doc/library/sys_path_init.rst +++ b/Doc/library/sys_path_init.rst @@ -105,6 +105,8 @@ Please refer to :mod:`site`'s mechanism, such as :mod:`venv`, that many virtual environment implementations follow. +.. _sys-path-init-_pth-files: + _pth files ---------- diff --git a/Lib/test/test_asyncio/test_futures.py b/Lib/test/test_asyncio/test_futures.py index 320de2180e7db7c..a217177e1deb06d 100644 --- a/Lib/test/test_asyncio/test_futures.py +++ b/Lib/test/test_asyncio/test_futures.py @@ -255,6 +255,14 @@ def test_future_cancel_message_setter(self): f.cancel('my message') f._cancel_message = 'my new message' self.assertEqual(f._cancel_message, 'my new message') + f._cancel_message = None + self.assertIsNone(f._cancel_message) + f._cancel_message = 'my new message' + if not isinstance(f, futures._PyFuture): + # The C implementation does not support deletion. + with self.assertRaises(AttributeError): + del f._cancel_message + self.assertEqual(f._cancel_message, 'my new message') # Also check that the value is used for cancel(). with self.assertRaises(asyncio.CancelledError): diff --git a/Lib/test/test_ctypes/test_delattr.py b/Lib/test/test_ctypes/test_delattr.py index eb99c0dafc86563..689bf327176f099 100644 --- a/Lib/test/test_ctypes/test_delattr.py +++ b/Lib/test/test_ctypes/test_delattr.py @@ -1,5 +1,6 @@ import unittest -from ctypes import POINTER, Structure, c_char, c_int +from ctypes import CDLL, POINTER, Structure, c_char, c_int +from test.support import import_helper class X(Structure): @@ -26,6 +27,25 @@ def test_struct(self): with self.assertRaises(TypeError): del struct.foo + def test_raw(self): + chararray = (c_char * 5)() + with self.assertRaises(AttributeError): + del chararray.raw + + def test_func_pointer(self): + # Deleting these attributes restores the default. + dll = CDLL(import_helper.import_module('_ctypes_test').__file__) + func = dll._testfunc_i_bhilfd + func.argtypes = [c_int] + func.restype = c_int + func.errcheck = lambda *args: None + del func.argtypes + self.assertIsNone(func.argtypes) + del func.errcheck + self.assertIsNone(func.errcheck) + del func.restype + self.assertIs(func.restype, c_int) + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index ac0110c671455c3..ea2dcd76b585a99 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -1085,6 +1085,37 @@ def test_output_string_pair_restored(self): curses.A_BOLD) self.assertEqual(win.attr_get()[1], pair) + def test_cell_embedded_null_chars(self): + # A NUL cannot share a cell with another character: setcchar() takes a + # NUL-terminated string, so the rest of the cell would be dropped. + for text in ['a\0', 'a\0\u0301', 'a\0b', '\0a']: + with self.subTest(text=text): + self.assertRaises(ValueError, curses.complexchar, text) + if WIDE_BUILD: + self.assertRaises(ValueError, self.stdscr.addch, text) + + def test_cell_null_char(self): + # A lone NUL is a character like any other, as addch(0) always was. + stdscr = self.stdscr + cell = curses.complexchar('\0') + self.assertEqual(str(cell), '\0') + self.assertEqual(eval(repr(cell), {'curses': curses}), cell) + stdscr.erase() + stdscr.addch(0, 0, 0) + expected = stdscr.instr(0, 0, 4) + for ch in ['\0', cell]: + with self.subTest(ch=ch): + stdscr.erase() + stdscr.addch(0, 0, ch) + self.assertEqual(stdscr.instr(0, 0, 4), expected) + # A cell holding a NUL reads back as the cell that writes it. + win = curses.newwin(3, 8, 0, 0) + win.insch(0, 0, '\0') + self.assertEqual(win.in_wch(0, 0), cell) + # A string of cells cannot hold a NUL: it would end a batch write. + self.assertRaises(ValueError, curses.complexstr, 'a\0b') + self.assertRaises(ValueError, curses.complexstr, '\0') + def test_add_string_behavior(self): # addstr() advances the cursor past the written text; addnstr() # writes at most n characters. diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index a0ba5a8351aebdf..7524822632bae7a 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -4331,7 +4331,7 @@ def test_invalid_context(self): # Attributes cannot be deleted for attr in ['prec', 'Emax', 'Emin', 'rounding', 'capitals', 'clamp', - 'flags', 'traps']: + 'flags', 'traps', '_allcr', '_flags', '_traps']: self.assertRaises(AttributeError, c.__delattr__, attr) # Invalid attributes diff --git a/Lib/test/test_defaultdict.py b/Lib/test/test_defaultdict.py index cc78f01e3e2ebdc..84a17eca0aa530d 100644 --- a/Lib/test/test_defaultdict.py +++ b/Lib/test/test_defaultdict.py @@ -37,6 +37,9 @@ def test_basic(self): self.assertIn(42, d2.keys()) self.assertNotIn(12, d2) self.assertNotIn(12, d2.keys()) + d2.default_factory = list + del d2.default_factory + self.assertEqual(d2.default_factory, None) d2.default_factory = None self.assertEqual(d2.default_factory, None) try: diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 0cee756958f3ad1..c34cf44d722456c 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -682,6 +682,44 @@ def test_invalid_setattr(self): msg = "exception context must be None or derive from BaseException" self.assertRaisesRegex(TE, msg, setattr, exc, '__context__', 1) + def test_object_attributes(self): + # These attributes are implemented as plain object members: + # they accept any object and are reset to None when deleted. + cases = [ + (SyntaxError('msgStr'), 'msg'), + (SyntaxError('msgStr'), 'filename'), + (SyntaxError('msgStr'), 'lineno'), + (SyntaxError('msgStr'), 'offset'), + (SyntaxError('msgStr'), 'end_lineno'), + (SyntaxError('msgStr'), 'end_offset'), + (SyntaxError('msgStr'), 'text'), + (SyntaxError('msgStr'), 'print_file_and_line'), + (SyntaxError('msgStr'), '_metadata'), + (ImportError('msgStr'), 'msg'), + (ImportError('msgStr'), 'name'), + (ImportError('msgStr'), 'path'), + (ImportError('msgStr'), 'name_from'), + (SystemExit(1), 'code'), + (StopIteration(), 'value'), + (NameError('msgStr'), 'name'), + (AttributeError('msgStr'), 'name'), + (AttributeError('msgStr'), 'obj'), + (OSError(2, 'msgStr'), 'errno'), + (OSError(2, 'msgStr'), 'strerror'), + (OSError(2, 'msgStr'), 'filename'), + (OSError(2, 'msgStr'), 'filename2'), + (UnicodeDecodeError('utf-8', b'\xff', 0, 1, 'reasonStr'), 'reason'), + ] + if sys.platform == 'win32': + cases.append((OSError(2, 'msgStr'), 'winerror')) + for exc, name in cases: + with self.subTest(exc=type(exc).__name__, name=name): + for value in 'strValue', 42, [1, 2], None: + setattr(exc, name, value) + self.assertEqual(getattr(exc, name), value) + delattr(exc, name) + self.assertIsNone(getattr(exc, name)) + def test_invalid_delattr(self): TE = TypeError try: @@ -739,6 +777,13 @@ def testChainingDescriptors(self): self.assertTrue(e.__suppress_context__) e.__suppress_context__ = False self.assertFalse(e.__suppress_context__) + with self.assertRaisesRegex(TypeError, + 'attribute value type must be bool'): + e.__suppress_context__ = 1 + with self.assertRaisesRegex(TypeError, + "can't delete numeric/char attribute"): + del e.__suppress_context__ + self.assertFalse(e.__suppress_context__) def testKeywordArgs(self): # test that builtin exception don't take keyword args, diff --git a/Lib/test/test_frame.py b/Lib/test/test_frame.py index a3a329cb578a8dc..a0a11966ebf3c17 100644 --- a/Lib/test/test_frame.py +++ b/Lib/test/test_frame.py @@ -222,6 +222,33 @@ def test_f_lineno_del_segfault(self): with self.assertRaises(AttributeError): del f.f_lineno + def test_f_trace(self): + f, _, _ = self.make_frames() + def tracer(*args): + pass + for value in tracer, 42, None: + f.f_trace = value + self.assertEqual(f.f_trace, value) + f.f_trace = tracer + del f.f_trace + self.assertIsNone(f.f_trace) + + def test_f_trace_lines_and_opcodes(self): + f, _, _ = self.make_frames() + for name in 'f_trace_lines', 'f_trace_opcodes': + with self.subTest(name=name): + for value in False, True: + setattr(f, name, value) + self.assertEqual(getattr(f, name), value) + with self.assertRaisesRegex(TypeError, + 'attribute value type must be bool'): + setattr(f, name, 1) + with self.assertRaisesRegex(TypeError, + "can't delete numeric/char attribute"): + del f.f_trace_lines + with self.assertRaisesRegex(AttributeError, 'cannot be deleted'): + del f.f_trace_opcodes + def test_f_generator(self): # Test f_generator in different contexts. diff --git a/Lib/test/test_free_threading/test_sqlite3.py b/Lib/test/test_free_threading/test_sqlite3.py new file mode 100644 index 000000000000000..456771726f7a8f4 --- /dev/null +++ b/Lib/test/test_free_threading/test_sqlite3.py @@ -0,0 +1,54 @@ +import unittest + +from test import support +from test.support import import_helper, threading_helper +from test.support.threading_helper import run_concurrently + + +sqlite3 = import_helper.import_module("sqlite3") + + +NAME = "FREE_THREADING_RACE" +NCOLUMNS = 64 +NITER = 3000 +QUERY = "select " + ", ".join( + f"'value' as 'c{i} [{NAME}]'" for i in range(NCOLUMNS) +) + + +@threading_helper.requires_working_threading() +class TestSQLite3(unittest.TestCase): + def test_concurrent_converter_replacement(self): + # gh-155781: Converter lookups must retain a strong reference while + # another thread updates the public converter registry. + class Converter: + __slots__ = ("value",) + + def __init__(self, value): + self.value = value + + def __call__(self, value): + return self.value + + def reader(): + con = sqlite3.connect(":memory:", + detect_types=sqlite3.PARSE_COLNAMES) + try: + for _ in range(NITER): + row = con.execute(QUERY).fetchone() + self.assertEqual(len(row), NCOLUMNS) + finally: + con.close() + + def mutator(): + for i in range(NITER * NCOLUMNS): + sqlite3.register_converter(NAME, Converter(i)) + if i % 3 == 0: + sqlite3.converters.pop(NAME, None) + + with support.swap_item(sqlite3.converters, NAME, Converter(0)): + run_concurrently([reader] * 8 + [mutator] * 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_funcattrs.py b/Lib/test/test_funcattrs.py index fe14e7cb342c9e5..b2fa10d74580125 100644 --- a/Lib/test/test_funcattrs.py +++ b/Lib/test/test_funcattrs.py @@ -266,6 +266,41 @@ def e(): return num_one, num_two self.fail("__code__ with different numbers of free vars should " "not be possible") + def test___kwdefaults__(self): + def func(a=1, *, b=2, c=3): + return a, b, c + self.assertEqual(func.__kwdefaults__, {'b': 2, 'c': 3}) + func.__kwdefaults__ = {'b': 4} + self.assertEqual(func.__kwdefaults__, {'b': 4}) + self.assertEqual(func(c=5), (1, 4, 5)) + func.__kwdefaults__ = None + self.assertIsNone(func.__kwdefaults__) + self.assertRaises(TypeError, func) + with self.assertRaisesRegex(TypeError, + '__kwdefaults__ must be set to a dict object'): + func.__kwdefaults__ = [('b', 4)] + del func.__kwdefaults__ + self.assertIsNone(func.__kwdefaults__) + + def test_invalid___code___deletion(self): + def func(): pass + with self.assertRaisesRegex(TypeError, + '__code__ must be set to a code object'): + func.__code__ = None + with self.assertRaisesRegex(TypeError, + '__code__ must be set to a code object'): + del func.__code__ + + def test___doc__(self): + def func(): + "docstring" + self.assertEqual(func.__doc__, 'docstring') + for value in 'other', 42, None: + func.__doc__ = value + self.assertEqual(func.__doc__, value) + del func.__doc__ + self.assertIsNone(func.__doc__) + def test_blank_func_defaults(self): self.assertEqual(self.b.__defaults__, None) del self.b.__defaults__ diff --git a/Lib/test/test_io/test_fileio.py b/Lib/test/test_io/test_fileio.py index e53c4749f58cf22..21a160904a7f1e0 100644 --- a/Lib/test/test_io/test_fileio.py +++ b/Lib/test/test_io/test_fileio.py @@ -83,6 +83,12 @@ def testBlksize(self): fst = os.fstat(self.f.fileno()) blksize = getattr(fst, 'st_blksize', blksize) self.assertEqual(self.f._blksize, blksize) + # it is read-only + with self.assertRaises(AttributeError): + self.f._blksize = blksize + with self.assertRaises(AttributeError): + del self.f._blksize + # verify readinto def testReadintoByteArray(self): @@ -503,6 +509,21 @@ class CAutoFileTests(AutoFileTests, unittest.TestCase): FileIO = _io.FileIO modulename = '_io' + def testFinalizing(self): + # test the private _finalizing attribute + self.assertIs(self.f._finalizing, False) + self.f._finalizing = True + self.assertIs(self.f._finalizing, True) + with self.assertRaisesRegex(TypeError, + 'attribute value type must be bool'): + self.f._finalizing = 1 + with self.assertRaisesRegex(TypeError, + "can't delete numeric/char attribute"): + del self.f._finalizing + # closing a file which is being finalized emits a ResourceWarning + self.f._finalizing = False + + class PyAutoFileTests(AutoFileTests, unittest.TestCase): FileIO = _pyio.FileIO modulename = '_pyio' diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 07f6b1415d0dbda..a210b81d877dc1e 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1448,6 +1448,29 @@ def _to_memoryview(buf): class CTextIOWrapperTest(TextIOWrapperTest, CTestCase): shutdown_error = "LookupError: unknown encoding: ascii" + def test_chunk_size(self): + t = self.TextIOWrapper(self.BytesIO(), encoding="utf-8") + self.assertGreater(t._CHUNK_SIZE, 0) + t._CHUNK_SIZE = 1024 + self.assertEqual(t._CHUNK_SIZE, 1024) + with self.assertRaisesRegex(ValueError, + 'a strictly positive integer is required'): + t._CHUNK_SIZE = 0 + with self.assertRaises(TypeError): + t._CHUNK_SIZE = 'x' + with self.assertRaises(ValueError): + t._CHUNK_SIZE = sys.maxsize + 1 + with self.assertRaises(ValueError): + t._CHUNK_SIZE = -sys.maxsize - 2 + with self.assertRaises(ValueError): + t._CHUNK_SIZE = 2**1000 + with self.assertRaises(ValueError): + t._CHUNK_SIZE = -2**1000 + with self.assertRaisesRegex(AttributeError, 'cannot be deleted'): + del t._CHUNK_SIZE + # a failed assignment does not change the value + self.assertEqual(t._CHUNK_SIZE, 1024) + def test_initialization(self): r = self.BytesIO(b"\xc3\xa9\n\n") b = self.BufferedReader(r, 1000) diff --git a/Lib/test/test_kqueue.py b/Lib/test/test_kqueue.py index 2649f3a7aee9b96..e4223262e099339 100644 --- a/Lib/test/test_kqueue.py +++ b/Lib/test/test_kqueue.py @@ -126,6 +126,31 @@ def test_create_event(self): self.assertNotEqual(ev, other) + def test_event_attributes(self): + fd = os.open(os.devnull, os.O_WRONLY) + self.addCleanup(os.close, fd) + + ev = select.kevent(fd) + # All attributes are numeric members: they can be set and cannot be + # deleted. + for name, value in (('ident', 1), ('filter', select.KQ_FILTER_WRITE), + ('flags', select.KQ_EV_DELETE), ('fflags', 2), + ('data', 3), ('udata', 4)): + with self.subTest(name=name): + setattr(ev, name, value) + self.assertEqual(getattr(ev, name), value) + with self.assertRaises(TypeError): + setattr(ev, name, 'not a number') + with self.assertRaises(OverflowError): + setattr(ev, name, 2**1000) + with self.assertRaises(OverflowError): + setattr(ev, name, -2**1000) + with self.assertRaisesRegex( + TypeError, "can't delete numeric/char attribute"): + delattr(ev, name) + # a failed assignment does not change the value + self.assertEqual(getattr(ev, name), value) + def test_queue_event(self): serverSocket = socket.create_server(('127.0.0.1', 0)) client = socket.socket() diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py index 7a49cfa0c29ec5b..81b3043eb7e75bc 100644 --- a/Lib/test/test_os/test_os.py +++ b/Lib/test/test_os/test_os.py @@ -24,6 +24,7 @@ import sysconfig import tempfile import textwrap +import threading import time import types import unittest @@ -35,6 +36,7 @@ from test.support import infinite_recursion from test.support import requires_root_user from test.support import requires_non_root_user +from test.support import threading_helper from test.support import warnings_helper from platform import win32_is_iot from .utils import create_file @@ -5350,6 +5352,84 @@ def test_resource_warning(self): with self.check_no_resource_warning(): del iterator + def test_no_resource_warning_when_open_fails(self): + # gh-152754: a scandir() call that never opened a directory owns + # nothing, and must not report an unclosed iterator. + self.create_file("file.txt") + missing = os.path.join(self.path, "missing") + not_a_dir = os.path.join(self.path, "file.txt") + for path in (missing, not_a_dir): + with self.subTest(path=path): + with self.check_no_resource_warning(): + with self.assertRaises(OSError): + os.scandir(path) + + +@threading_helper.requires_working_threading() +class ScandirThreadingTest(unittest.TestCase): + # gh-152754: an os.scandir() iterator shared between threads must not crash. + + if support.check_sanitizer(thread=True): + SCANDIR_NUMITEMS = 200 + SCANDIR_N_NEXT = 2 + SCANDIR_N_CLOSE = 2 + SCANDIR_REPEAT = 10 + else: + SCANDIR_NUMITEMS = 1000 + SCANDIR_N_NEXT = 6 + SCANDIR_N_CLOSE = 3 + SCANDIR_REPEAT = 20 + + def setUp(self): + self.dir = os.path.realpath(os_helper.TESTFN) + self.addCleanup(os_helper.rmtree, self.dir) + os.mkdir(self.dir) + self.names = set() + for i in range(self.SCANDIR_NUMITEMS): + name = f"f{i}" + create_file(os.path.join(self.dir, name)) + self.names.add(name) + + def test_close_racing_next(self): + # One thread's next() racing another's close() must not crash. + def nexter(): + for _ in self.it: + pass + + def closer(): + self.it.close() + + funcs = [nexter] * self.SCANDIR_N_NEXT + [closer] * self.SCANDIR_N_CLOSE + for _ in range(self.SCANDIR_REPEAT): + self.it = os.scandir(self.dir) + try: + threading_helper.run_concurrently(funcs) + finally: + self.it.close() + + def test_shared_next(self): + # Threads sharing one iterator must not crash or lose entries: every + # entry must be handed to exactly one thread. + expected = sorted(self.names) + nthreads = self.SCANDIR_N_NEXT + self.SCANDIR_N_CLOSE + + for _ in range(self.SCANDIR_REPEAT): + self.it = os.scandir(self.dir) + results = [] + results_lock = threading.Lock() + + def worker(): + local = [entry.name for entry in self.it] + with results_lock: + results.extend(local) + + try: + threading_helper.run_concurrently([worker] * nthreads) + finally: + self.it.close() + + self.assertEqual(sorted(results), expected) + class TestPEP519(unittest.TestCase): diff --git a/Lib/test/test_pickle.py b/Lib/test/test_pickle.py index 21f45339e8a3442..5b0fd70105447f9 100644 --- a/Lib/test/test_pickle.py +++ b/Lib/test/test_pickle.py @@ -389,6 +389,57 @@ class CPicklerTests(PyPicklerTests): pickler = _pickle.Pickler unpickler = _pickle.Unpickler + def test_c_pickler_attributes(self): + pickler = _pickle.Pickler(io.BytesIO()) + for name in 'bin', 'fast': + with self.subTest(name=name): + for value in 0, 1, True: + setattr(pickler, name, value) + self.assertEqual(getattr(pickler, name), int(value)) + with self.assertRaises(TypeError): + setattr(pickler, name, 'x') + with self.assertRaises(OverflowError): + setattr(pickler, name, sys.maxsize + 1) + with self.assertRaises(OverflowError): + setattr(pickler, name, -sys.maxsize - 2) + with self.assertRaises(OverflowError): + setattr(pickler, name, 2**1000) + with self.assertRaises(OverflowError): + setattr(pickler, name, -2**1000) + with self.assertRaisesRegex( + TypeError, "can't delete numeric/char attribute"): + delattr(pickler, name) + # a failed assignment does not change the value + self.assertEqual(getattr(pickler, name), 1) + + self.assertRaises(AttributeError, getattr, pickler, + 'dispatch_table') + table = {} + pickler.dispatch_table = table + self.assertIs(pickler.dispatch_table, table) + del pickler.dispatch_table + self.assertRaises(AttributeError, getattr, pickler, + 'dispatch_table') + + pickler.memo = {} + self.assertEqual(pickler.memo.copy(), {}) + with self.assertRaisesRegex(TypeError, 'must be a PicklerMemoProxy'): + pickler.memo = None + with self.assertRaisesRegex(TypeError, + 'attribute deletion is not supported'): + del pickler.memo + + def test_c_unpickler_attributes(self): + unpickler = _pickle.Unpickler(io.BytesIO(b'.')) + unpickler.memo = {} + self.assertEqual(unpickler.memo.copy(), {}) + with self.assertRaisesRegex(TypeError, + 'must be an UnpicklerMemoProxy'): + unpickler.memo = None + with self.assertRaisesRegex(TypeError, + 'attribute deletion is not supported'): + del unpickler.memo + class CPersPicklerTests(PyPersPicklerTests): pickler = _pickle.Pickler unpickler = _pickle.Unpickler diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 375f12e8d4791d7..978227a8651e148 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -487,6 +487,13 @@ def test_connection_init_good_isolation_levels(self): cx.isolation_level = level self.assertEqual(cx.isolation_level, level) + def test_connection_delete_isolation_level(self): + with memory_database() as cx: + with self.assertRaisesRegex(AttributeError, + "cannot delete attribute"): + del cx.isolation_level + self.assertEqual(cx.isolation_level, "") + def test_connection_reinit(self): with memory_database() as cx: cx.text_factory = bytes @@ -1081,6 +1088,8 @@ def test_invalid_array_size(self): self.assertRaises(OverflowError, setter, UINT32_MAX + 1) self.assertRaises(OverflowError, setter, 2**1000) self.assertRaises(ValueError, setter, -2**1000) + self.assertRaisesRegex(AttributeError, 'cannot be deleted', + delattr, self.cu, 'arraysize') # a failed assignment does not change the value self.assertEqual(self.cu.arraysize, 2) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 285bcac65ab57b3..37323b7ebc6b1ae 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1846,6 +1846,32 @@ def test__create_stdlib_context_check_hostname(self): self.assertEqual(ctx.verify_mode, ssl.CERT_OPTIONAL) self.assertTrue(ctx.check_hostname) + def test_delete_sslobject_attributes(self): + # None of the attributes of _ssl._SSLSocket can be deleted. + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslobj = ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO())._sslobj + for name in 'context', 'owner', 'session', 'session_reused': + with self.subTest(name=name): + value = getattr(sslobj, name) + with self.assertRaises(AttributeError): + delattr(sslobj, name) + self.assertEqual(getattr(sslobj, name), value) + + def test_delete_attributes(self): + # None of the attributes implemented in C can be deleted. + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + names = ['check_hostname', 'verify_mode', 'verify_flags', 'options', + 'minimum_version', 'maximum_version', 'sni_callback', + '_host_flags', 'security_level', 'post_handshake_auth'] + if hasattr(ctx, 'num_tickets'): + names.append('num_tickets') + for name in names: + with self.subTest(name=name): + value = getattr(ctx, name) + with self.assertRaises(AttributeError): + delattr(ctx, name) + self.assertEqual(getattr(ctx, name), value) + def test_check_hostname(self): with warnings_helper.check_warnings(): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-18-14-19-51.gh-issue-155254.75TMlb.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-18-14-19-51.gh-issue-155254.75TMlb.rst new file mode 100644 index 000000000000000..4ebe0a417c3f3b8 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-18-14-19-51.gh-issue-155254.75TMlb.rst @@ -0,0 +1 @@ +Avoid warning about missing standard library when using :ref:`sys-path-init-_pth-files`. diff --git a/Misc/NEWS.d/next/Library/2026-07-09-14-56-24.gh-issue-152754.CyyC5j.rst b/Misc/NEWS.d/next/Library/2026-07-09-14-56-24.gh-issue-152754.CyyC5j.rst new file mode 100644 index 000000000000000..b0378d9afe103e7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-09-14-56-24.gh-issue-152754.CyyC5j.rst @@ -0,0 +1,5 @@ +Fix a crash when the same :func:`os.scandir` iterator is used concurrently +from multiple threads. It no longer releases the directory handle while +another thread is reading from it. Sharing an iterator between threads +remains subject to race conditions: which entries each thread receives is +unspecified. diff --git a/Misc/NEWS.d/next/Library/2026-08-14-11-12-18.gh-issue-155781.sqlite-converter.rst b/Misc/NEWS.d/next/Library/2026-08-14-11-12-18.gh-issue-155781.sqlite-converter.rst new file mode 100644 index 000000000000000..cd6655899d59551 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-14-11-12-18.gh-issue-155781.sqlite-converter.rst @@ -0,0 +1,3 @@ +Fix a possible crash in free-threaded builds when the :mod:`sqlite3` +converter registry is modified concurrently while a cursor builds its row +cast map. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index e208b2d3d52acce..7cc72b96d0a46de 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -561,6 +561,10 @@ PyCurses_ConvertToWideCell(PyObject *obj, wchar_t *wch) setcchar() would silently drop a trailing spacing character, or fail with a generic error for a control-character base. */ if (nch > 1) { + if (wmemchr(wch, L'\0', nch) != NULL) { + PyErr_SetString(PyExc_ValueError, "embedded null character"); + return -1; + } int bad = wcwidth(wch[0]) < 0; for (Py_ssize_t i = 1; !bad && i < nch; i++) { bad = wcwidth(wch[i]) != 0; @@ -908,7 +912,9 @@ curses_cell_text(cursesmodule_state *state, const curses_cell_t *cell) PyErr_SetString(state->error, "getcchar() returned ERR"); return NULL; } - return PyUnicode_FromWideChar(wstr, -1); + /* setcchar() stores no text for a NUL (it takes a NUL-terminated string), + so an empty cell holds a NUL character, as on a narrow build. */ + return PyUnicode_FromWideChar(wstr, wstr[0] == L'\0' ? 1 : -1); #else char ch = (char)(*cell & A_CHARTEXT); return PyUnicode_Decode(&ch, 1, curses_screen_encoding, NULL); @@ -1318,6 +1324,16 @@ static PyObject * complexstr_from_string(cursesmodule_state *state, PyObject *str, attr_t attr, int pair) { + /* A NUL cell ends a batch write and a cell array read (add_wchnstr(3X)), + so a string of cells cannot hold one, as addstr() cannot either. */ + Py_ssize_t nul = PyUnicode_FindChar(str, 0, 0, PyUnicode_GET_LENGTH(str), 1); + if (nul < -1) { + return NULL; + } + if (nul >= 0) { + PyErr_SetString(PyExc_ValueError, "embedded null character"); + return NULL; + } #ifdef HAVE_NCURSESW Py_ssize_t n; wchar_t *wbuf = PyUnicode_AsWideCharString(str, &n); diff --git a/Modules/_sqlite/cursor.c b/Modules/_sqlite/cursor.c index 96fb3dc9e42d3ac..dd4204ff267d0a2 100644 --- a/Modules/_sqlite/cursor.c +++ b/Modules/_sqlite/cursor.c @@ -223,8 +223,11 @@ _pysqlite_get_converter(pysqlite_state *state, const char *keystr, return NULL; } - retval = PyDict_GetItemWithError(state->converters, upcase_key); + int rc = PyDict_GetItemRef(state->converters, upcase_key, &retval); Py_DECREF(upcase_key); + if (rc < 0) { + return NULL; + } return retval; } @@ -296,11 +299,10 @@ pysqlite_build_row_cast_map(pysqlite_Cursor* self) } } - if (!converter) { - converter = Py_None; - } - - if (PyList_Append(self->row_cast_map, converter) != 0) { + int rc = PyList_Append(self->row_cast_map, + converter ? converter : Py_None); + Py_XDECREF(converter); + if (rc != 0) { Py_CLEAR(self->row_cast_map); return -1; } diff --git a/Modules/getpath.py b/Modules/getpath.py index 6199567bd777aa0..c322d5c4b4c251d 100644 --- a/Modules/getpath.py +++ b/Modules/getpath.py @@ -766,10 +766,10 @@ def search_up(prefix, *landmarks, test=isfile): # SANITY CHECKS # ****************************************************************************** -# Warn if the standard library is missing, unless pythonpath_was_set was set, as -# that skips parts of the stdlib directories calculation — assume the provided -# pythonpath is correct. This is how subinterpreters initialize the path for eg. -if not py_setpath and not pythonpath_was_set: +# Warn if we did a search for the standard library and couldn't find it. Never +# show a warning if paths were provided explicitly, since we trust the caller +# knows what they're doing even if the layout doesn't look "normal". +if not (py_setpath or pythonpath_was_set or pth): home_hint = f"The Python 'home' directory was set to {home!r}, is this correct?" if (not stdlib_zip or not isfile(stdlib_zip)) and (not stdlib_dir or not isdir(stdlib_dir)): hint = home_hint if home else f'sys.prefix is set to {prefix}, is this correct?' diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index d94291e2ae524bc..bec305a4042c49d 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -21,6 +21,7 @@ #include "pycore_import.h" // _PyImport_AcquireLock() #include "pycore_initconfig.h" // _PyStatus_EXCEPTION() #include "pycore_jit_unwind.h" // _Py_jit_debug_mutex +#include "pycore_lock.h" // PyMutex_LockFast() #include "pycore_long.h" // _PyLong_IsNegative() #include "pycore_moduleobject.h" // _PyModule_GetState() #include "pycore_object.h" // _PyObject_LookupSpecial() @@ -16985,6 +16986,13 @@ typedef struct { #ifdef HAVE_FDOPENDIR int fd; #endif + // Sharing the iterator between threads is subject to race conditions: + // which entries each thread receives is unspecified. It must not + // corrupt the iterator or crash. Since we don't want close() to be + // held up by a blocking directory read, we set the 'closed' flag if + // there are reads in progress. + PyMutex read_mutex; + uint8_t closed; } ScandirIterator; #define ScandirIterator_CAST(op) ((ScandirIterator *)(op)) @@ -16994,62 +17002,84 @@ typedef struct { static int ScandirIterator_is_closed(ScandirIterator *iterator) { - return iterator->handle == INVALID_HANDLE_VALUE; + return _Py_atomic_load_uint8(&iterator->closed); } static void ScandirIterator_closedir(ScandirIterator *iterator) { - HANDLE handle = iterator->handle; + HANDLE handle = INVALID_HANDLE_VALUE; - if (handle == INVALID_HANDLE_VALUE) - return; + _Py_atomic_store_uint8(&iterator->closed, 1); + if (PyMutex_LockFast(&iterator->read_mutex)) { + // no reads in progress, we can close the handle + handle = iterator->handle; + iterator->handle = INVALID_HANDLE_VALUE; + PyMutex_Unlock(&iterator->read_mutex); + } - iterator->handle = INVALID_HANDLE_VALUE; - Py_BEGIN_ALLOW_THREADS - FindClose(handle); - Py_END_ALLOW_THREADS + if (handle != INVALID_HANDLE_VALUE) { + Py_BEGIN_ALLOW_THREADS + FindClose(handle); + Py_END_ALLOW_THREADS + } } static PyObject * ScandirIterator_iternext(PyObject *op) { ScandirIterator *iterator = ScandirIterator_CAST(op); - WIN32_FIND_DATAW *file_data = &iterator->file_data; + WIN32_FIND_DATAW file_data; BOOL success; - PyObject *entry; + DWORD error = ERROR_SUCCESS; + int found = 0; + PyMutex_Lock(&iterator->read_mutex); /* Happens if the iterator is iterated twice, or closed explicitly */ - if (iterator->handle == INVALID_HANDLE_VALUE) - return NULL; - - while (1) { + while (iterator->handle != INVALID_HANDLE_VALUE && + !_Py_atomic_load_uint8_relaxed(&iterator->closed)) + { if (!iterator->first_time) { Py_BEGIN_ALLOW_THREADS - success = FindNextFileW(iterator->handle, file_data); + success = FindNextFileW(iterator->handle, &iterator->file_data); + if (!success) { + error = GetLastError(); + } Py_END_ALLOW_THREADS if (!success) { - /* Error or no more files */ - if (GetLastError() != ERROR_NO_MORE_FILES) - path_error(&iterator->path); break; } } iterator->first_time = 0; /* Skip over . and .. */ - if (wcscmp(file_data->cFileName, L".") != 0 && - wcscmp(file_data->cFileName, L"..") != 0) + if (wcscmp(iterator->file_data.cFileName, L".") != 0 && + wcscmp(iterator->file_data.cFileName, L"..") != 0) { - PyObject *module = PyType_GetModule(Py_TYPE(iterator)); - entry = DirEntry_from_find_data(module, &iterator->path, file_data); - if (!entry) - break; - return entry; + file_data = iterator->file_data; + found = 1; + break; } /* Loop till we get a non-dot directory or finish iterating */ } + PyMutex_Unlock(&iterator->read_mutex); + + if (found && ScandirIterator_is_closed(iterator)) { + ScandirIterator_closedir(iterator); // deferred close + } + + if (found) { + PyObject *module = PyType_GetModule(Py_TYPE(iterator)); + PyObject *entry = DirEntry_from_find_data(module, &iterator->path, &file_data); + if (entry != NULL) { + return entry; + } + } + else if (error != ERROR_SUCCESS && error != ERROR_NO_MORE_FILES) { + SetLastError(error); + path_error(&iterator->path); + } /* Error or no more files */ ScandirIterator_closedir(iterator); @@ -17061,27 +17091,32 @@ ScandirIterator_iternext(PyObject *op) static int ScandirIterator_is_closed(ScandirIterator *iterator) { - return !iterator->dirp; + return _Py_atomic_load_uint8(&iterator->closed); } static void ScandirIterator_closedir(ScandirIterator *iterator) { - DIR *dirp = iterator->dirp; + DIR *dirp = NULL; - if (!dirp) - return; + _Py_atomic_store_uint8(&iterator->closed, 1); + if (PyMutex_LockFast(&iterator->read_mutex)) { + // no reads in progress, we can close dirp + dirp = iterator->dirp; + iterator->dirp = NULL; + PyMutex_Unlock(&iterator->read_mutex); + } - iterator->dirp = NULL; - Py_BEGIN_ALLOW_THREADS + if (dirp != NULL) { + Py_BEGIN_ALLOW_THREADS #ifdef HAVE_FDOPENDIR - if (iterator->path.is_fd) { - rewinddir(dirp); - } + if (iterator->path.is_fd) { + rewinddir(dirp); + } #endif - closedir(dirp); - Py_END_ALLOW_THREADS - return; + closedir(dirp); + Py_END_ALLOW_THREADS + } } static PyObject * @@ -17089,24 +17124,32 @@ ScandirIterator_iternext(PyObject *op) { ScandirIterator *iterator = ScandirIterator_CAST(op); struct dirent *direntp; - Py_ssize_t name_len; + Py_ssize_t name_len = 0; int is_dot; - PyObject *entry; + int found = 0; + int error = 0; + int no_memory = 0; + char namebuf[256]; + char *name = namebuf; + ino_t d_ino = 0; +#ifdef HAVE_DIRENT_D_TYPE + unsigned char d_type = 0; +#endif + PyMutex_Lock(&iterator->read_mutex); /* Happens if the iterator is iterated twice, or closed explicitly */ - if (!iterator->dirp) - return NULL; - - while (1) { - errno = 0; + while (iterator->dirp != NULL && + !_Py_atomic_load_uint8_relaxed(&iterator->closed)) + { Py_BEGIN_ALLOW_THREADS + errno = 0; direntp = readdir(iterator->dirp); + if (direntp == NULL) { + error = errno; + } Py_END_ALLOW_THREADS if (!direntp) { - /* Error or no more files */ - if (errno != 0) - path_error(&iterator->path); break; } @@ -17115,21 +17158,54 @@ ScandirIterator_iternext(PyObject *op) is_dot = direntp->d_name[0] == '.' && (name_len == 1 || (direntp->d_name[1] == '.' && name_len == 2)); if (!is_dot) { - PyObject *module = PyType_GetModule(Py_TYPE(iterator)); - entry = DirEntry_from_posix_info(module, - &iterator->path, direntp->d_name, - name_len, direntp->d_ino + if ((size_t)name_len >= sizeof(namebuf)) { + name = PyMem_RawMalloc(name_len + 1); + if (name == NULL) { + no_memory = 1; + break; + } + } + memcpy(name, direntp->d_name, name_len); + name[name_len] = '\0'; + d_ino = direntp->d_ino; #ifdef HAVE_DIRENT_D_TYPE - , direntp->d_type + d_type = direntp->d_type; #endif - ); - if (!entry) - break; - return entry; + found = 1; + break; } /* Loop till we get a non-dot directory or finish iterating */ } + PyMutex_Unlock(&iterator->read_mutex); + + if (found && ScandirIterator_is_closed(iterator)) { + ScandirIterator_closedir(iterator); // deferred close + } + + if (found) { + PyObject *module = PyType_GetModule(Py_TYPE(iterator)); + PyObject *entry = DirEntry_from_posix_info(module, + &iterator->path, name, + name_len, d_ino +#ifdef HAVE_DIRENT_D_TYPE + , d_type +#endif + ); + if (name != namebuf) { + PyMem_RawFree(name); + } + if (entry != NULL) { + return entry; + } + } + else if (no_memory) { + PyErr_NoMemory(); + } + else if (error != 0) { + errno = error; + path_error(&iterator->path); + } /* Error or no more files */ ScandirIterator_closedir(iterator); @@ -17167,9 +17243,11 @@ ScandirIterator_finalize(PyObject *op) /* Save the current exception, if any. */ PyObject *exc = PyErr_GetRaisedException(); - if (!ScandirIterator_is_closed(iterator)) { - ScandirIterator_closedir(iterator); + int was_closed = ScandirIterator_is_closed(iterator); + + ScandirIterator_closedir(iterator); + if (!was_closed) { if (PyErr_ResourceWarning(op, 1, "unclosed scandir iterator %R", iterator)) { @@ -17267,6 +17345,8 @@ os_scandir_impl(PyObject *module, path_t *path) if (!iterator) return NULL; + iterator->read_mutex = (PyMutex){0}; + iterator->closed = 1; #ifdef MS_WINDOWS iterator->handle = INVALID_HANDLE_VALUE; #else @@ -17339,6 +17419,7 @@ os_scandir_impl(PyObject *module, path_t *path) } #endif + iterator->closed = 0; return (PyObject *)iterator; error: