Skip to content

Commit b3fae57

Browse files
miss-islingtonnaschemedeadlovelll
authored
[3.15] gh-152754: Fix crash when an os.scandir iterator is shared between threads (gh-153462) (gh-159365)
(cherry picked from commit 999a046) Co-authored-by: Neil Schemenauer <nas-github@arctrix.com> Co-authored-by: Timofey Ivankov <playboicartina@gmail.com>
1 parent 82bef22 commit b3fae57

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
@@ -2945,6 +2945,11 @@ features:
29452945

29462946
.. audit-event:: os.scandir path os.scandir
29472947

2948+
Sharing a :func:`scandir` iterator between threads will not corrupt the
2949+
iterator, but it is subject to :term:`race conditions <race condition>`:
2950+
which entries each thread receives is unspecified, and closing the iterator
2951+
while another thread is iterating ends that iteration early.
2952+
29482953
The :func:`scandir` iterator supports the :term:`context manager` protocol
29492954
and has the following method:
29502955

‎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
@@ -5292,6 +5294,84 @@ def test_resource_warning(self):
52925294
with self.check_no_resource_warning():
52935295
del iterator
52945296

5297+
def test_no_resource_warning_when_open_fails(self):
5298+
# gh-152754: a scandir() call that never opened a directory owns
5299+
# nothing, and must not report an unclosed iterator.
5300+
self.create_file("file.txt")
5301+
missing = os.path.join(self.path, "missing")
5302+
not_a_dir = os.path.join(self.path, "file.txt")
5303+
for path in (missing, not_a_dir):
5304+
with self.subTest(path=path):
5305+
with self.check_no_resource_warning():
5306+
with self.assertRaises(OSError):
5307+
os.scandir(path)
5308+
5309+
5310+
@threading_helper.requires_working_threading()
5311+
class ScandirThreadingTest(unittest.TestCase):
5312+
# gh-152754: an os.scandir() iterator shared between threads must not crash.
5313+
5314+
if support.check_sanitizer(thread=True):
5315+
SCANDIR_NUMITEMS = 200
5316+
SCANDIR_N_NEXT = 2
5317+
SCANDIR_N_CLOSE = 2
5318+
SCANDIR_REPEAT = 10
5319+
else:
5320+
SCANDIR_NUMITEMS = 1000
5321+
SCANDIR_N_NEXT = 6
5322+
SCANDIR_N_CLOSE = 3
5323+
SCANDIR_REPEAT = 20
5324+
5325+
def setUp(self):
5326+
self.dir = os.path.realpath(os_helper.TESTFN)
5327+
self.addCleanup(os_helper.rmtree, self.dir)
5328+
os.mkdir(self.dir)
5329+
self.names = set()
5330+
for i in range(self.SCANDIR_NUMITEMS):
5331+
name = f"f{i}"
5332+
create_file(os.path.join(self.dir, name))
5333+
self.names.add(name)
5334+
5335+
def test_close_racing_next(self):
5336+
# One thread's next() racing another's close() must not crash.
5337+
def nexter():
5338+
for _ in self.it:
5339+
pass
5340+
5341+
def closer():
5342+
self.it.close()
5343+
5344+
funcs = [nexter] * self.SCANDIR_N_NEXT + [closer] * self.SCANDIR_N_CLOSE
5345+
for _ in range(self.SCANDIR_REPEAT):
5346+
self.it = os.scandir(self.dir)
5347+
try:
5348+
threading_helper.run_concurrently(funcs)
5349+
finally:
5350+
self.it.close()
5351+
5352+
def test_shared_next(self):
5353+
# Threads sharing one iterator must not crash or lose entries: every
5354+
# entry must be handed to exactly one thread.
5355+
expected = sorted(self.names)
5356+
nthreads = self.SCANDIR_N_NEXT + self.SCANDIR_N_CLOSE
5357+
5358+
for _ in range(self.SCANDIR_REPEAT):
5359+
self.it = os.scandir(self.dir)
5360+
results = []
5361+
results_lock = threading.Lock()
5362+
5363+
def worker():
5364+
local = [entry.name for entry in self.it]
5365+
with results_lock:
5366+
results.extend(local)
5367+
5368+
try:
5369+
threading_helper.run_concurrently([worker] * nthreads)
5370+
finally:
5371+
self.it.close()
5372+
5373+
self.assertEqual(sorted(results), expected)
5374+
52955375

52965376
class TestPEP519(unittest.TestCase):
52975377

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)