diff --git a/Lib/importlib/_bootstrap_external.py b/Lib/importlib/_bootstrap_external.py index a1cb729efb7fef2..fbb8d3b6e87c195 100644 --- a/Lib/importlib/_bootstrap_external.py +++ b/Lib/importlib/_bootstrap_external.py @@ -1454,6 +1454,13 @@ def _find_children(self): while True: try: entry = next(scan_iterator) + except StopIteration: + break + except OSError: + # A failing scan cannot make progress; end the listing + # like _fill_cache() treats an unreadable directory. + break + try: if entry.name == _PYCACHE: continue # packages @@ -1467,9 +1474,7 @@ def _find_children(self): if entry.name.endswith(suffix) } except OSError: - pass # ignore exceptions from next(scan_iterator) and os.DirEntry - except StopIteration: - break + pass # skip entries whose os.DirEntry methods fail def discover(self, parent=None): if parent and parent.submodule_search_locations is None: diff --git a/Lib/test/test_importlib/test_discover.py b/Lib/test/test_importlib/test_discover.py index c4ab7b6982e3ba9..490b54569e09c42 100644 --- a/Lib/test/test_importlib/test_discover.py +++ b/Lib/test/test_importlib/test_discover.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch from test.test_importlib import util @@ -114,6 +114,68 @@ def test_invalid_parent(self): with self.assertRaises(ValueError): list(finder.discover(example)) + def _patch_scandir(self, scandir): + module_os = self.machinery.FileFinder._fill_cache.__globals__['_os'] + return patch.object(module_os, 'scandir', scandir) + + def test_discover_persistently_failing_scan(self): + # gh-155935: an iterator that raises OSError on every next() call + # must end the listing instead of looping forever. + class FailingScandirIterator: + calls = 0 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def __next__(self): + self.calls += 1 + if self.calls > 100: + # Safety net so regressed code fails fast on the call + # count below instead of hanging the test forever. + raise StopIteration + raise OSError('persistently failing directory scan') + + scan_iterator = FailingScandirIterator() + with self._patch_scandir(lambda path: scan_iterator): + finder = self.get_finder('dummy') + discovered = list(finder.discover()) + self.assertEqual(discovered, []) + # A failed scan must not be retried. + self.assertEqual(scan_iterator.calls, 1) + + def test_find_children_failing_direntry(self): + # An entry whose DirEntry methods raise OSError is skipped; the + # remaining entries are still listed. + failing = Mock() + failing.name = 'failing' + failing.is_dir.side_effect = OSError('stat failed') + good = Mock() + good.name = 'example.py' + good.is_dir.return_value = False + good.is_file.return_value = True + + class FakeScandirIterator: + def __init__(self, entries): + self._iterator = iter(entries) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def __next__(self): + return next(self._iterator) + + with self._patch_scandir( + lambda path: FakeScandirIterator([failing, good])): + finder = self.get_finder('dummy') + children = list(finder._find_children()) + self.assertEqual(children, ['example']) + ( Frozen_TestFileFinder, diff --git a/Misc/NEWS.d/next/Library/2026-08-17-06-07-49.gh-issue-155935.lbMPKW.rst b/Misc/NEWS.d/next/Library/2026-08-17-06-07-49.gh-issue-155935.lbMPKW.rst new file mode 100644 index 000000000000000..21d7ba6a4f758c4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-17-06-07-49.gh-issue-155935.lbMPKW.rst @@ -0,0 +1 @@ +Fix :meth:`!importlib.machinery.FileFinder.discover` looping forever when the underlying directory scan keeps failing with :exc:`OSError`. A failing scan now ends the listing instead of being retried.