Skip to content

Commit c117ef1

Browse files
authored
[3.13] gh-143650: Fix importlib race condition on import failure (GH-143651) (#144697)
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> cherry picked from ac8b5b6
1 parent 36aa081 commit c117ef1

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
@@ -1364,6 +1364,14 @@ def _find_and_load(name, import_):
13641364
# NOTE: because of this, initializing must be set *before*
13651365
# putting the new module in sys.modules.
13661366
_lock_unlock_module(name)
1367+
else:
1368+
# Verify the module is still in sys.modules. Another thread may have
1369+
# removed it (due to import failure) between our sys.modules.get()
1370+
# above and the _initializing check. If removed, we retry the import
1371+
# to preserve normal semantics: the caller gets the exception from
1372+
# the actual import failure rather than a synthetic error.
1373+
if sys.modules.get(name) is not module:
1374+
return _find_and_load(name, import_)
13671375

13681376
if module is None:
13691377
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
@@ -252,12 +252,32 @@ PyImport_GetModule(PyObject *name)
252252
mod = import_get_module(tstate, name);
253253
if (mod != NULL && mod != Py_None) {
254254
if (import_ensure_initialized(tstate->interp, mod, name) < 0) {
255+
goto error;
256+
}
257+
/* Verify the module is still in sys.modules. Another thread may have
258+
removed it (due to import failure) between our import_get_module()
259+
call and the _initializing check in import_ensure_initialized(). */
260+
PyObject *mod_check = import_get_module(tstate, name);
261+
if (mod_check != mod) {
262+
Py_XDECREF(mod_check);
263+
if (_PyErr_Occurred(tstate)) {
264+
goto error;
265+
}
266+
/* The module was removed or replaced. Return NULL to report
267+
"not found" rather than trying to keep up with racing
268+
modifications to sys.modules; returning the new value would
269+
require looping to redo the ensure_initialized check. */
255270
Py_DECREF(mod);
256-
remove_importlib_frames(tstate);
257271
return NULL;
258272
}
273+
Py_DECREF(mod_check);
259274
}
260275
return mod;
276+
277+
error:
278+
Py_DECREF(mod);
279+
remove_importlib_frames(tstate);
280+
return NULL;
261281
}
262282

263283
/* Get the module object corresponding to a module name.
@@ -3780,6 +3800,27 @@ PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
37803800
if (import_ensure_initialized(tstate->interp, mod, abs_name) < 0) {
37813801
goto error;
37823802
}
3803+
/* Verify the module is still in sys.modules. Another thread may have
3804+
removed it (due to import failure) between our import_get_module()
3805+
call and the _initializing check in import_ensure_initialized().
3806+
If removed, we retry the import to preserve normal semantics: the
3807+
caller gets the exception from the actual import failure rather
3808+
than a synthetic error. */
3809+
PyObject *mod_check = import_get_module(tstate, abs_name);
3810+
if (mod_check != mod) {
3811+
Py_XDECREF(mod_check);
3812+
if (_PyErr_Occurred(tstate)) {
3813+
goto error;
3814+
}
3815+
Py_DECREF(mod);
3816+
mod = import_find_and_load(tstate, abs_name);
3817+
if (mod == NULL) {
3818+
goto error;
3819+
}
3820+
}
3821+
else {
3822+
Py_DECREF(mod_check);
3823+
}
37833824
}
37843825
else {
37853826
Py_XDECREF(mod);

0 commit comments

Comments
 (0)