Skip to content

Commit 4715be8

Browse files
authored
[3.8] bpo-43406: Fix possible race condition where PyErr_CheckSignals tries to execute a non-Python signal handler (GH-24756) (GH-24762)
We can receive signals (at the C level, in `trip_signal()` in signalmodule.c) while `signal.signal` is being called to modify the corresponding handler. Later when `PyErr_CheckSignals()` is called to handle the given signal, the handler may be a non-callable object and would raise a cryptic asynchronous exception.. (cherry picked from commit 68245b7) Co-authored-by: Antoine Pitrou <[email protected]>
1 parent e12a9e2 commit 4715be8

File tree

3 files changed

+91
-15
lines changed

3 files changed

+91
-15
lines changed

Lib/test/test_signal.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import statistics
77
import subprocess
88
import sys
9+
import threading
910
import time
1011
import unittest
1112
from test import support
@@ -1243,6 +1244,55 @@ def handler(signum, frame):
12431244
# Python handler
12441245
self.assertEqual(len(sigs), N, "Some signals were lost")
12451246

1247+
@unittest.skipUnless(hasattr(signal, "SIGUSR1"),
1248+
"test needs SIGUSR1")
1249+
def test_stress_modifying_handlers(self):
1250+
# bpo-43406: race condition between trip_signal() and signal.signal
1251+
signum = signal.SIGUSR1
1252+
num_sent_signals = 0
1253+
num_received_signals = 0
1254+
do_stop = False
1255+
1256+
def custom_handler(signum, frame):
1257+
nonlocal num_received_signals
1258+
num_received_signals += 1
1259+
1260+
def set_interrupts():
1261+
nonlocal num_sent_signals
1262+
while not do_stop:
1263+
signal.raise_signal(signum)
1264+
num_sent_signals += 1
1265+
1266+
def cycle_handlers():
1267+
while num_sent_signals < 100:
1268+
for i in range(20000):
1269+
# Cycle between a Python-defined and a non-Python handler
1270+
for handler in [custom_handler, signal.SIG_IGN]:
1271+
signal.signal(signum, handler)
1272+
1273+
old_handler = signal.signal(signum, custom_handler)
1274+
self.addCleanup(signal.signal, signum, old_handler)
1275+
t = threading.Thread(target=set_interrupts)
1276+
t.start()
1277+
try:
1278+
with support.catch_unraisable_exception() as cm:
1279+
cycle_handlers()
1280+
if cm.unraisable is not None:
1281+
# An unraisable exception may be printed out when
1282+
# a signal is ignored due to the aforementioned
1283+
# race condition, check it.
1284+
self.assertIsInstance(cm.unraisable.exc_value, OSError)
1285+
self.assertIn(
1286+
f"Signal {signum} ignored due to race condition",
1287+
str(cm.unraisable.exc_value))
1288+
# Sanity check that some signals were received, but not all
1289+
self.assertGreater(num_received_signals, 0)
1290+
self.assertLess(num_received_signals, num_sent_signals)
1291+
finally:
1292+
do_stop = True
1293+
t.join()
1294+
1295+
12461296
class RaiseSignalTest(unittest.TestCase):
12471297

12481298
def test_sigint(self):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a possible race condition where ``PyErr_CheckSignals`` tries to execute a
2+
non-Python signal handler.

Modules/signalmodule.c

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,23 +1675,47 @@ _PyErr_CheckSignals(void)
16751675
f = Py_None;
16761676

16771677
for (i = 1; i < NSIG; i++) {
1678-
if (_Py_atomic_load_relaxed(&Handlers[i].tripped)) {
1679-
PyObject *result = NULL;
1680-
PyObject *arglist = Py_BuildValue("(iO)", i, f);
1681-
_Py_atomic_store_relaxed(&Handlers[i].tripped, 0);
1682-
1683-
if (arglist) {
1684-
result = PyEval_CallObject(Handlers[i].func,
1685-
arglist);
1686-
Py_DECREF(arglist);
1687-
}
1688-
if (!result) {
1689-
_Py_atomic_store(&is_tripped, 1);
1690-
return -1;
1691-
}
1678+
if (!_Py_atomic_load_relaxed(&Handlers[i].tripped)) {
1679+
continue;
1680+
}
1681+
_Py_atomic_store_relaxed(&Handlers[i].tripped, 0);
16921682

1693-
Py_DECREF(result);
1683+
/* Signal handlers can be modified while a signal is received,
1684+
* and therefore the fact that trip_signal() or PyErr_SetInterrupt()
1685+
* was called doesn't guarantee that there is still a Python
1686+
* signal handler for it by the time PyErr_CheckSignals() is called
1687+
* (see bpo-43406).
1688+
*/
1689+
PyObject *func = Handlers[i].func;
1690+
if (func == NULL || func == Py_None || func == IgnoreHandler ||
1691+
func == DefaultHandler) {
1692+
/* No Python signal handler due to aforementioned race condition.
1693+
* We can't call raise() as it would break the assumption
1694+
* that PyErr_SetInterrupt() only *simulates* an incoming
1695+
* signal (i.e. it will never kill the process).
1696+
* We also don't want to interrupt user code with a cryptic
1697+
* asynchronous exception, so instead just write out an
1698+
* unraisable error.
1699+
*/
1700+
PyErr_Format(PyExc_OSError,
1701+
"Signal %i ignored due to race condition",
1702+
i);
1703+
PyErr_WriteUnraisable(Py_None);
1704+
continue;
1705+
}
1706+
1707+
PyObject *result = NULL;
1708+
PyObject *arglist = Py_BuildValue("(iO)", i, f);
1709+
if (arglist) {
1710+
result = PyEval_CallObject(func, arglist);
1711+
Py_DECREF(arglist);
16941712
}
1713+
if (!result) {
1714+
/* On error, re-schedule a call to PyErr_CheckSignals() */
1715+
_Py_atomic_store(&is_tripped, 1);
1716+
return -1;
1717+
}
1718+
Py_DECREF(result);
16951719
}
16961720

16971721
return 0;

0 commit comments

Comments
 (0)