Skip to content

Commit 999a046

Browse files
gh-152754: Fix crash when an os.scandir iterator is shared between threads (gh-153462)
Co-authored-by: Timofey Ivankov <playboicartina@gmail.com>
1 parent 862befd commit 999a046

4 files changed

Lines changed: 229 additions & 58 deletions

File tree

Doc/library/os.rst

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

29612961
.. audit-event:: os.scandir path os.scandir
29622962

2963+
Sharing a :func:`scandir` iterator between threads will not corrupt the
2964+
iterator, but it is subject to :term:`race conditions <race condition>`:
2965+
which entries each thread receives is unspecified, and closing the iterator
2966+
while another thread is iterating ends that iteration early.
2967+
29632968
The :func:`scandir` iterator supports the :term:`context manager` protocol
29642969
and has the following method:
29652970

Lib/test/test_os/test_os.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import sysconfig
2525
import tempfile
2626
import textwrap
27+
import threading
2728
import time
2829
import types
2930
import unittest
@@ -35,6 +36,7 @@
3536
from test.support import infinite_recursion
3637
from test.support import requires_root_user
3738
from test.support import requires_non_root_user
39+
from test.support import threading_helper
3840
from test.support import warnings_helper
3941
from platform import win32_is_iot
4042
from .utils import create_file
@@ -5350,6 +5352,84 @@ def test_resource_warning(self):
53505352
with self.check_no_resource_warning():
53515353
del iterator
53525354

5355+
def test_no_resource_warning_when_open_fails(self):
5356+
# gh-152754: a scandir() call that never opened a directory owns
5357+
# nothing, and must not report an unclosed iterator.
5358+
self.create_file("file.txt")
5359+
missing = os.path.join(self.path, "missing")
5360+
not_a_dir = os.path.join(self.path, "file.txt")
5361+
for path in (missing, not_a_dir):
5362+
with self.subTest(path=path):
5363+
with self.check_no_resource_warning():
5364+
with self.assertRaises(OSError):
5365+
os.scandir(path)
5366+
5367+
5368+
@threading_helper.requires_working_threading()
5369+
class ScandirThreadingTest(unittest.TestCase):
5370+
# gh-152754: an os.scandir() iterator shared between threads must not crash.
5371+
5372+
if support.check_sanitizer(thread=True):
5373+
SCANDIR_NUMITEMS = 200
5374+
SCANDIR_N_NEXT = 2
5375+
SCANDIR_N_CLOSE = 2
5376+
SCANDIR_REPEAT = 10
5377+
else:
5378+
SCANDIR_NUMITEMS = 1000
5379+
SCANDIR_N_NEXT = 6
5380+
SCANDIR_N_CLOSE = 3
5381+
SCANDIR_REPEAT = 20
5382+
5383+
def setUp(self):
5384+
self.dir = os.path.realpath(os_helper.TESTFN)
5385+
self.addCleanup(os_helper.rmtree, self.dir)
5386+
os.mkdir(self.dir)
5387+
self.names = set()
5388+
for i in range(self.SCANDIR_NUMITEMS):
5389+
name = f"f{i}"
5390+
create_file(os.path.join(self.dir, name))
5391+
self.names.add(name)
5392+
5393+
def test_close_racing_next(self):
5394+
# One thread's next() racing another's close() must not crash.
5395+
def nexter():
5396+
for _ in self.it:
5397+
pass
5398+
5399+
def closer():
5400+
self.it.close()
5401+
5402+
funcs = [nexter] * self.SCANDIR_N_NEXT + [closer] * self.SCANDIR_N_CLOSE
5403+
for _ in range(self.SCANDIR_REPEAT):
5404+
self.it = os.scandir(self.dir)
5405+
try:
5406+
threading_helper.run_concurrently(funcs)
5407+
finally:
5408+
self.it.close()
5409+
5410+
def test_shared_next(self):
5411+
# Threads sharing one iterator must not crash or lose entries: every
5412+
# entry must be handed to exactly one thread.
5413+
expected = sorted(self.names)
5414+
nthreads = self.SCANDIR_N_NEXT + self.SCANDIR_N_CLOSE
5415+
5416+
for _ in range(self.SCANDIR_REPEAT):
5417+
self.it = os.scandir(self.dir)
5418+
results = []
5419+
results_lock = threading.Lock()
5420+
5421+
def worker():
5422+
local = [entry.name for entry in self.it]
5423+
with results_lock:
5424+
results.extend(local)
5425+
5426+
try:
5427+
threading_helper.run_concurrently([worker] * nthreads)
5428+
finally:
5429+
self.it.close()
5430+
5431+
self.assertEqual(sorted(results), expected)
5432+
53535433

53545434
class TestPEP519(unittest.TestCase):
53555435

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)