@@ -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
465579def setUpModule ():
466580 thread_info = threading_helper .threading_setup ()
0 commit comments