Skip to content

Commit dacf472

Browse files
[3.14] gh-152754: Fix crash when an os.scandir iterator is shared between threads (gh-153462) (gh-159381)
(cherry picked from commit 999a046) Co-authored-by: Timofey Ivankov <playboicartina@gmail.com>
1 parent 0d214f8 commit dacf472

4 files changed

Lines changed: 229 additions & 57 deletions

File tree

‎Doc/library/os.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2892,6 +2892,11 @@ features:
28922892

28932893
.. audit-event:: os.scandir path os.scandir
28942894

2895+
Sharing a :func:`scandir` iterator between threads will not corrupt the
2896+
iterator, but it is subject to :term:`race conditions <race condition>`:
2897+
which entries each thread receives is unspecified, and closing the iterator
2898+
while another thread is iterating ends that iteration early.
2899+
28952900
The :func:`scandir` iterator supports the :term:`context manager` protocol
28962901
and has the following method:
28972902

‎Lib/test/test_os.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import sysconfig
2626
import tempfile
2727
import textwrap
28+
import threading
2829
import time
2930
import types
3031
import unittest
@@ -35,6 +36,7 @@
3536
from test.support import os_helper
3637
from test.support import socket_helper
3738
from test.support import infinite_recursion
39+
from test.support import threading_helper
3840
from test.support import warnings_helper
3941
from platform import win32_is_iot
4042

@@ -5613,6 +5615,84 @@ def test_resource_warning(self):
56135615
with self.check_no_resource_warning():
56145616
del iterator
56155617

5618+
def test_no_resource_warning_when_open_fails(self):
5619+
# gh-152754: a scandir() call that never opened a directory owns
5620+
# nothing, and must not report an unclosed iterator.
5621+
self.create_file("file.txt")
5622+
missing = os.path.join(self.path, "missing")
5623+
not_a_dir = os.path.join(self.path, "file.txt")
5624+
for path in (missing, not_a_dir):
5625+
with self.subTest(path=path):
5626+
with self.check_no_resource_warning():
5627+
with self.assertRaises(OSError):
5628+
os.scandir(path)
5629+
5630+
5631+
@threading_helper.requires_working_threading()
5632+
class ScandirThreadingTest(unittest.TestCase):
5633+
# gh-152754: an os.scandir() iterator shared between threads must not crash.
5634+
5635+
if support.check_sanitizer(thread=True):
5636+
SCANDIR_NUMITEMS = 200
5637+
SCANDIR_N_NEXT = 2
5638+
SCANDIR_N_CLOSE = 2
5639+
SCANDIR_REPEAT = 10
5640+
else:
5641+
SCANDIR_NUMITEMS = 1000
5642+
SCANDIR_N_NEXT = 6
5643+
SCANDIR_N_CLOSE = 3
5644+
SCANDIR_REPEAT = 20
5645+
5646+
def setUp(self):
5647+
self.dir = os.path.realpath(os_helper.TESTFN)
5648+
self.addCleanup(os_helper.rmtree, self.dir)
5649+
os.mkdir(self.dir)
5650+
self.names = set()
5651+
for i in range(self.SCANDIR_NUMITEMS):
5652+
name = f"f{i}"
5653+
create_file(os.path.join(self.dir, name))
5654+
self.names.add(name)
5655+
5656+
def test_close_racing_next(self):
5657+
# One thread's next() racing another's close() must not crash.
5658+
def nexter():
5659+
for _ in self.it:
5660+
pass
5661+
5662+
def closer():
5663+
self.it.close()
5664+
5665+
funcs = [nexter] * self.SCANDIR_N_NEXT + [closer] * self.SCANDIR_N_CLOSE
5666+
for _ in range(self.SCANDIR_REPEAT):
5667+
self.it = os.scandir(self.dir)
5668+
try:
5669+
threading_helper.run_concurrently(funcs)
5670+
finally:
5671+
self.it.close()
5672+
5673+
def test_shared_next(self):
5674+
# Threads sharing one iterator must not crash or lose entries: every
5675+
# entry must be handed to exactly one thread.
5676+
expected = sorted(self.names)
5677+
nthreads = self.SCANDIR_N_NEXT + self.SCANDIR_N_CLOSE
5678+
5679+
for _ in range(self.SCANDIR_REPEAT):
5680+
self.it = os.scandir(self.dir)
5681+
results = []
5682+
results_lock = threading.Lock()
5683+
5684+
def worker():
5685+
local = [entry.name for entry in self.it]
5686+
with results_lock:
5687+
results.extend(local)
5688+
5689+
try:
5690+
threading_helper.run_concurrently([worker] * nthreads)
5691+
finally:
5692+
self.it.close()
5693+
5694+
self.assertEqual(sorted(results), expected)
5695+
56165696

56175697
class TestPEP519(unittest.TestCase):
56185698

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix a crash when the same :func:`os.scandir` iterator is used concurrently
2+
from multiple threads. It no longer releases the directory handle while
3+
another thread is reading from it. Sharing an iterator between threads
4+
remains subject to race conditions: which entries each thread receives is
5+
unspecified.

0 commit comments

Comments
 (0)