Skip to content

Commit 50e5921

Browse files
committed
gh-149728: bind a submodule on its parent before it stops initializing
1 parent d125f00 commit 50e5921

3 files changed

Lines changed: 149 additions & 16 deletions

File tree

Lib/importlib/_bootstrap.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -890,8 +890,14 @@ def _exec(spec, module):
890890
sys.modules[spec.name] = module
891891
return module
892892

893-
def _load_unlocked(spec):
893+
def _load_unlocked(spec, finish_load=None):
894894
# A helper for direct use by the import system.
895+
#
896+
# If given, finish_load() is called with the loaded module just before
897+
# spec._initializing is cleared. Other threads may use a module whose
898+
# spec is no longer initializing without taking the import lock, so
899+
# anything that has to be in place by then (such as binding the module
900+
# on its parent package) belongs in finish_load().
895901
module = module_from_spec(spec)
896902

897903
# This must be done before putting the module in sys.modules
@@ -920,6 +926,8 @@ def _load_unlocked(spec):
920926
module = sys.modules.pop(spec.name)
921927
sys.modules[spec.name] = module
922928
_verbose_message('import {!r} # {!r}', spec.name, spec.loader)
929+
if finish_load is not None:
930+
finish_load(module)
923931
finally:
924932
spec._initializing = False
925933

@@ -1295,29 +1303,35 @@ def _find_and_load_unlocked(name, import_, *, lazy_submodule=False):
12951303
return None
12961304
raise ModuleNotFoundError(f'{_ERR_MSG_PREFIX}{name!r}', name=name)
12971305
else:
1306+
def finish_load(module):
1307+
# Called by _load_unlocked() while spec._initializing is still
1308+
# true, so that no thread can see the module as fully imported
1309+
# before it is reachable as an attribute of its parent.
1310+
if parent:
1311+
# Set the module as an attribute on its parent.
1312+
parent_module = sys.modules[parent]
1313+
try:
1314+
setattr(parent_module, child, module)
1315+
except AttributeError:
1316+
msg = (f"Cannot set an attribute on {parent!r} "
1317+
f"for child module {child!r}")
1318+
_warnings.warn(msg, ImportWarning)
1319+
# Set attributes to lazy submodules on the module.
1320+
try:
1321+
_imp._set_lazy_attributes(module, name)
1322+
except Exception as e:
1323+
msg = f"Cannot set lazy attributes on {name!r}: {e!r}"
1324+
_warnings.warn(msg, ImportWarning)
1325+
12981326
if parent_spec:
12991327
# Temporarily add child we are currently importing to parent's
13001328
# _uninitialized_submodules for circular import tracking.
13011329
parent_spec._uninitialized_submodules.append(child)
13021330
try:
1303-
module = _load_unlocked(spec)
1331+
module = _load_unlocked(spec, finish_load)
13041332
finally:
13051333
if parent_spec:
13061334
parent_spec._uninitialized_submodules.pop()
1307-
if parent:
1308-
# Set the module as an attribute on its parent.
1309-
parent_module = sys.modules[parent]
1310-
try:
1311-
setattr(parent_module, child, module)
1312-
except AttributeError:
1313-
msg = f"Cannot set an attribute on {parent!r} for child module {child!r}"
1314-
_warnings.warn(msg, ImportWarning)
1315-
# Set attributes to lazy submodules on the module.
1316-
try:
1317-
_imp._set_lazy_attributes(module, name)
1318-
except Exception as e:
1319-
msg = f"Cannot set lazy attributes on {name!r}: {e!r}"
1320-
_warnings.warn(msg, ImportWarning)
13211335
return module
13221336

13231337

Lib/test/test_importlib/test_threaded_import.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,120 @@ def do_import(name):
461461
errors, [],
462462
f"Import(s) failed on iteration {i}: {errors}")
463463

464+
def test_lazy_submodule_not_visible_before_parent_setattr(self):
465+
# gh-149728: a submodule must not be advertised as fully imported
466+
# before it is bound as an attribute of its parent package. A
467+
# thread seeing that window in a package that resolves its
468+
# submodules from a module-level __getattr__ would re-enter
469+
# __getattr__ for the same submodule over and over, until
470+
# RecursionError.
471+
os.makedirs(os.path.join(TESTFN, "lazypkg"))
472+
self.addCleanup(shutil.rmtree, TESTFN)
473+
with open(os.path.join(TESTFN, "lazypkg", "__init__.py"), "w") as f:
474+
f.write("""if 1:
475+
import sys
476+
import threading
477+
from types import ModuleType
478+
479+
# Set when the import system is about to bind 'sub' on this
480+
# package, i.e. at the start of the window under test.
481+
setattr_reached = threading.Event()
482+
# Set by the probing thread once it is out of that window.
483+
probe_finished = threading.Event()
484+
# __spec__._initializing of 'lazypkg.sub', as seen from
485+
# inside the window.
486+
observed_initializing = []
487+
# Whether the probing thread was still blocked when the
488+
# window closed.
489+
probe_blocked = []
490+
491+
_importing = threading.local()
492+
493+
class _Package(ModuleType):
494+
def __setattr__(self, name, value):
495+
if name == "sub":
496+
observed_initializing.append(
497+
value.__spec__._initializing)
498+
setattr_reached.set()
499+
# Hold the window open long enough to see what
500+
# the other thread does with it. This is a cap,
501+
# not a synchronisation point: a thread wrongly
502+
# handed the module returns at once and sets
503+
# probe_finished, while a thread correctly made
504+
# to block cannot set it at all, since it waits
505+
# on the module lock held right here.
506+
probe_blocked.append(
507+
not probe_finished.wait(0.1))
508+
super().__setattr__(name, value)
509+
510+
def __getattr__(name):
511+
if name != "sub":
512+
raise AttributeError(name)
513+
if getattr(_importing, "sub", False):
514+
raise AssertionError(
515+
"lazypkg.__getattr__ re-entered for 'sub': the "
516+
"submodule was handed out as fully imported "
517+
"before being bound on its parent package")
518+
_importing.sub = True
519+
try:
520+
import lazypkg.sub as sub
521+
finally:
522+
_importing.sub = False
523+
return sub
524+
525+
sys.modules[__name__].__class__ = _Package
526+
""")
527+
with open(os.path.join(TESTFN, "lazypkg", "sub.py"), "w") as f:
528+
f.write("X = 42\n")
529+
530+
sys.path.insert(0, TESTFN)
531+
self.addCleanup(sys.path.remove, TESTFN)
532+
for mod in ("lazypkg", "lazypkg.sub"):
533+
self.addCleanup(forget, mod)
534+
importlib.invalidate_caches()
535+
536+
pkg = importlib.import_module("lazypkg")
537+
results = {}
538+
539+
def importer():
540+
try:
541+
results["importer"] = pkg.sub
542+
except BaseException as exc:
543+
results["importer_error"] = exc
544+
545+
def prober():
546+
pkg.setattr_reached.wait(support.SHORT_TIMEOUT)
547+
try:
548+
results["prober"] = pkg.sub
549+
except BaseException as exc:
550+
results["prober_error"] = exc
551+
finally:
552+
pkg.probe_finished.set()
553+
554+
threads = [threading.Thread(target=prober),
555+
threading.Thread(target=importer)]
556+
for thread in threads:
557+
thread.start()
558+
for thread in threads:
559+
thread.join(timeout=support.SHORT_TIMEOUT)
560+
for thread in threads:
561+
self.assertFalse(thread.is_alive(), "thread deadlocked")
562+
563+
for key in ("importer_error", "prober_error"):
564+
if key in results:
565+
raise results[key]
566+
sub = sys.modules["lazypkg.sub"]
567+
self.assertIs(results["importer"], sub)
568+
self.assertIs(results["prober"], sub)
569+
# The submodule is bound on its parent while its spec still says it
570+
# is initializing, so no thread can take the lock-free fast path
571+
# for it before it is reachable through the parent package.
572+
self.assertEqual(pkg.observed_initializing, [True])
573+
# And the other thread really was held off for as long as that
574+
# window was open, rather than let through to a package that does
575+
# not have 'sub' yet.
576+
self.assertEqual(pkg.probe_blocked, [True])
577+
464578

465579
def setUpModule():
466580
thread_info = threading_helper.threading_setup()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix a race in the import system where a submodule was briefly visible as
2+
fully imported before being set as an attribute of its parent package. A
3+
package importing its submodules from a module-level ``__getattr__`` could
4+
raise :exc:`RecursionError` when several threads first used the same
5+
submodule.

0 commit comments

Comments
 (0)