Skip to content

bpo-43406: Fix possible race condition where PyErr_CheckSignals tries to execute a non-Python signal handler #24756

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Lib/test/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import statistics
import subprocess
import sys
import threading
import time
import unittest
from test import support
Expand Down Expand Up @@ -1251,6 +1252,55 @@ def handler(signum, frame):
# Python handler
self.assertEqual(len(sigs), N, "Some signals were lost")

@unittest.skipUnless(hasattr(signal, "SIGUSR1"),
"test needs SIGUSR1")
def test_stress_modifying_handlers(self):
# bpo-43406: race condition between trip_signal() and signal.signal
signum = signal.SIGUSR1
num_sent_signals = 0
num_received_signals = 0
do_stop = False

def custom_handler(signum, frame):
nonlocal num_received_signals
num_received_signals += 1

def set_interrupts():
nonlocal num_sent_signals
while not do_stop:
signal.raise_signal(signum)
num_sent_signals += 1

def cycle_handlers():
while num_sent_signals < 100:
for i in range(20000):
# Cycle between a Python-defined and a non-Python handler
for handler in [custom_handler, signal.SIG_IGN]:
signal.signal(signum, handler)

old_handler = signal.signal(signum, custom_handler)
self.addCleanup(signal.signal, signum, old_handler)
t = threading.Thread(target=set_interrupts)
t.start()
try:
with support.catch_unraisable_exception() as cm:
cycle_handlers()
if cm.unraisable is not None:
# An unraisable exception may be printed out when
# a signal is ignored due to the aforementioned
# race condition, check it.
self.assertIsInstance(cm.unraisable.exc_value, OSError)
self.assertIn(
f"Signal {signum} ignored due to race condition",
str(cm.unraisable.exc_value))
# Sanity check that some signals were received, but not all
self.assertGreater(num_received_signals, 0)
self.assertLess(num_received_signals, num_sent_signals)
finally:
do_stop = True
t.join()


class RaiseSignalTest(unittest.TestCase):

def test_sigint(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix a possible race condition where ``PyErr_CheckSignals`` tries to execute a
non-Python signal handler.
26 changes: 25 additions & 1 deletion Modules/signalmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1706,10 +1706,34 @@ _PyErr_CheckSignalsTstate(PyThreadState *tstate)
}
_Py_atomic_store_relaxed(&Handlers[i].tripped, 0);

/* Signal handlers can be modified while a signal is received,
* and therefore the fact that trip_signal() or PyErr_SetInterrupt()
* was called doesn't guarantee that there is still a Python
* signal handler for it by the time PyErr_CheckSignals() is called
* (see bpo-43406).
*/
PyObject *func = Handlers[i].func;
if (func == NULL || func == Py_None || func == IgnoreHandler ||
func == DefaultHandler) {
/* No Python signal handler due to aforementioned race condition.
* We can't call raise() as it would break the assumption
* that PyErr_SetInterrupt() only *simulates* an incoming
* signal (i.e. it will never kill the process).
* We also don't want to interrupt user code with a cryptic
* asynchronous exception, so instead just write out an
* unraisable error.
*/
PyErr_Format(PyExc_OSError,
"Signal %i ignored due to race condition",
i);
PyErr_WriteUnraisable(Py_None);
continue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably require some consensus, but I would personally raise, as the situation is tricky enough that I think it should not pass silently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vstinner What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One problem is that re-raising will break the assumption that _thread.interrupt_main only simulates SIGINT.

Copy link
Member

@pablogsal pablogsal Mar 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One problem is that re-raising will break the assumption that _thread.interrupt_main only simulates SIGINT.

That's an excellent point actually. Maybe we should set an unraisable exception (PyErr_WriteUnraisable)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would not be much better than the asynchronous TypeError, would it?

Copy link
Member

@pablogsal pablogsal Mar 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not set the error indicator IIRC and can be handled separately if needed by a hook. By default is like printing to stderr. The advantage would be that keeps the assumption that _thread.interrupt_main only simulates SIGINT but doesn't pass silently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sounds good then!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to take a look at the updated patch @pablogsal .

}

PyObject *arglist = Py_BuildValue("(iO)", i, frame);
PyObject *result;
if (arglist) {
result = _PyObject_Call(tstate, Handlers[i].func, arglist, NULL);
result = _PyObject_Call(tstate, func, arglist, NULL);
Py_DECREF(arglist);
}
else {
Expand Down