Skip to content

Commit ac8b5b6

Browse files
gpsheadclaude
andauthored
gh-143650: Fix importlib race condition on import failure (GH-143651)
Fix a race condition where a thread could receive a partially-initialized module when another thread's import fails. The race occurs when: 1. Thread 1 starts importing, adds module to sys.modules 2. Thread 2 sees the module in sys.modules via the fast path 3. Thread 1's import fails, removes module from sys.modules 4. Thread 2 returns a stale module reference not in sys.modules The fix adds verification after the "skip lock" optimization in both Python and C code paths to check if the module is still in sys.modules. If the module was removed (due to import failure), we retry the import so the caller receives the actual exception from the import failure rather than a stale module reference. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b121dc4 commit ac8b5b6

File tree

4 files changed

+117
-1
lines changed

4 files changed

+117
-1
lines changed

Lib/importlib/_bootstrap.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,14 @@ def _find_and_load(name, import_):
12801280
# NOTE: because of this, initializing must be set *before*
12811281
# putting the new module in sys.modules.
12821282
_lock_unlock_module(name)
1283+
else:
1284+
# Verify the module is still in sys.modules. Another thread may have
1285+
# removed it (due to import failure) between our sys.modules.get()
1286+
# above and the _initializing check. If removed, we retry the import
1287+
# to preserve normal semantics: the caller gets the exception from
1288+
# the actual import failure rather than a synthetic error.
1289+
if sys.modules.get(name) is not module:
1290+
return _find_and_load(name, import_)
12831291

12841292
if module is None:
12851293
message = f'import of {name} halted; None in sys.modules'

Lib/test/test_importlib/test_threaded_import.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,71 @@ def test_multiprocessing_pool_circular_import(self, size):
259259
'partial', 'pool_in_threads.py')
260260
script_helper.assert_python_ok(fn)
261261

262+
def test_import_failure_race_condition(self):
263+
# Regression test for race condition where a thread could receive
264+
# a partially-initialized module when another thread's import fails.
265+
# The race occurs when:
266+
# 1. Thread 1 starts importing, adds module to sys.modules
267+
# 2. Thread 2 sees the module in sys.modules
268+
# 3. Thread 1's import fails, removes module from sys.modules
269+
# 4. Thread 2 should NOT return the stale module reference
270+
os.mkdir(TESTFN)
271+
self.addCleanup(shutil.rmtree, TESTFN)
272+
sys.path.insert(0, TESTFN)
273+
self.addCleanup(sys.path.remove, TESTFN)
274+
275+
# Create a module that partially initializes then fails
276+
modname = 'failing_import_module'
277+
with open(os.path.join(TESTFN, modname + '.py'), 'w') as f:
278+
f.write('''
279+
import time
280+
PARTIAL_ATTR = 'initialized'
281+
time.sleep(0.05) # Widen race window
282+
raise RuntimeError("Intentional import failure")
283+
''')
284+
self.addCleanup(forget, modname)
285+
importlib.invalidate_caches()
286+
287+
errors = []
288+
results = []
289+
290+
def do_import(delay=0):
291+
time.sleep(delay)
292+
try:
293+
mod = __import__(modname)
294+
# If we got a module, verify it's in sys.modules
295+
if modname not in sys.modules:
296+
errors.append(
297+
f"Got module {mod!r} but {modname!r} not in sys.modules"
298+
)
299+
elif sys.modules[modname] is not mod:
300+
errors.append(
301+
f"Got different module than sys.modules[{modname!r}]"
302+
)
303+
else:
304+
results.append(('success', mod))
305+
except RuntimeError:
306+
results.append(('RuntimeError',))
307+
except Exception as e:
308+
errors.append(f"Unexpected exception: {e}")
309+
310+
# Run multiple iterations to increase chance of hitting the race
311+
for _ in range(10):
312+
errors.clear()
313+
results.clear()
314+
if modname in sys.modules:
315+
del sys.modules[modname]
316+
317+
t1 = threading.Thread(target=do_import, args=(0,))
318+
t2 = threading.Thread(target=do_import, args=(0.01,))
319+
t1.start()
320+
t2.start()
321+
t1.join()
322+
t2.join()
323+
324+
# Neither thread should have errors about stale modules
325+
self.assertEqual(errors, [], f"Race condition detected: {errors}")
326+
262327

263328
def setUpModule():
264329
thread_info = threading_helper.threading_setup()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix race condition in :mod:`importlib` where a thread could receive a stale
2+
module reference when another thread's import fails.

Python/import.c

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,12 +297,32 @@ PyImport_GetModule(PyObject *name)
297297
mod = import_get_module(tstate, name);
298298
if (mod != NULL && mod != Py_None) {
299299
if (import_ensure_initialized(tstate->interp, mod, name) < 0) {
300+
goto error;
301+
}
302+
/* Verify the module is still in sys.modules. Another thread may have
303+
removed it (due to import failure) between our import_get_module()
304+
call and the _initializing check in import_ensure_initialized(). */
305+
PyObject *mod_check = import_get_module(tstate, name);
306+
if (mod_check != mod) {
307+
Py_XDECREF(mod_check);
308+
if (_PyErr_Occurred(tstate)) {
309+
goto error;
310+
}
311+
/* The module was removed or replaced. Return NULL to report
312+
"not found" rather than trying to keep up with racing
313+
modifications to sys.modules; returning the new value would
314+
require looping to redo the ensure_initialized check. */
300315
Py_DECREF(mod);
301-
remove_importlib_frames(tstate);
302316
return NULL;
303317
}
318+
Py_DECREF(mod_check);
304319
}
305320
return mod;
321+
322+
error:
323+
Py_DECREF(mod);
324+
remove_importlib_frames(tstate);
325+
return NULL;
306326
}
307327

308328
/* Get the module object corresponding to a module name.
@@ -3897,6 +3917,27 @@ PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
38973917
if (import_ensure_initialized(tstate->interp, mod, abs_name) < 0) {
38983918
goto error;
38993919
}
3920+
/* Verify the module is still in sys.modules. Another thread may have
3921+
removed it (due to import failure) between our import_get_module()
3922+
call and the _initializing check in import_ensure_initialized().
3923+
If removed, we retry the import to preserve normal semantics: the
3924+
caller gets the exception from the actual import failure rather
3925+
than a synthetic error. */
3926+
PyObject *mod_check = import_get_module(tstate, abs_name);
3927+
if (mod_check != mod) {
3928+
Py_XDECREF(mod_check);
3929+
if (_PyErr_Occurred(tstate)) {
3930+
goto error;
3931+
}
3932+
Py_DECREF(mod);
3933+
mod = import_find_and_load(tstate, abs_name);
3934+
if (mod == NULL) {
3935+
goto error;
3936+
}
3937+
}
3938+
else {
3939+
Py_DECREF(mod_check);
3940+
}
39003941
}
39013942
else {
39023943
Py_XDECREF(mod);

0 commit comments

Comments
 (0)