Skip to content

Commit

Permalink
bpo-38630: Fix subprocess.Popen.send_signal() race condition (pythonG…
Browse files Browse the repository at this point in the history
…H-16984)

On Unix, subprocess.Popen.send_signal() now polls the process status.
Polling reduces the risk of sending a signal to the wrong process if
the process completed, the Popen.returncode attribute is still None,
and the pid has been reassigned (recycled) to a new different
process.
  • Loading branch information
vstinner authored and shihai1991 committed Jan 31, 2020
1 parent 771fce9 commit ce72a6f
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 3 deletions.
2 changes: 2 additions & 0 deletions Doc/library/subprocess.rst
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,8 @@ Instances of the :class:`Popen` class have the following methods:

Sends the signal *signal* to the child.

Do nothing if the process completed.

.. note::

On Windows, SIGTERM is an alias for :meth:`terminate`. CTRL_C_EVENT and
Expand Down
28 changes: 25 additions & 3 deletions Lib/subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -2061,9 +2061,31 @@ def _save_input(self, input):

def send_signal(self, sig):
"""Send a signal to the process."""
# Skip signalling a process that we know has already died.
if self.returncode is None:
os.kill(self.pid, sig)
# bpo-38630: Polling reduces the risk of sending a signal to the
# wrong process if the process completed, the Popen.returncode
# attribute is still None, and the pid has been reassigned
# (recycled) to a new different process. This race condition can
# happens in two cases.
#
# Case 1. Thread A calls Popen.poll(), thread B calls
# Popen.send_signal(). In thread A, waitpid() succeed and returns
# the exit status. Thread B calls kill() because poll() in thread A
# did not set returncode yet. Calling poll() in thread B prevents
# the race condition thanks to Popen._waitpid_lock.
#
# Case 2. waitpid(pid, 0) has been called directly, without
# using Popen methods: returncode is still None is this case.
# Calling Popen.poll() will set returncode to a default value,
# since waitpid() fails with ProcessLookupError.
self.poll()
if self.returncode is not None:
# Skip signalling a process that we know has already died.
return

# The race condition can still happen if the race condition
# described above happens between the returncode test
# and the kill() call.
os.kill(self.pid, sig)

def terminate(self):
"""Terminate the process with SIGTERM
Expand Down
25 changes: 25 additions & 0 deletions Lib/test/test_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -3120,6 +3120,31 @@ def test_stopped(self):

self.assertEqual(returncode, -3)

def test_send_signal_race(self):
# bpo-38630: send_signal() must poll the process exit status to reduce
# the risk of sending the signal to the wrong process.
proc = subprocess.Popen(ZERO_RETURN_CMD)

# wait until the process completes without using the Popen APIs.
pid, status = os.waitpid(proc.pid, 0)
self.assertEqual(pid, proc.pid)
self.assertTrue(os.WIFEXITED(status), status)
self.assertEqual(os.WEXITSTATUS(status), 0)

# returncode is still None but the process completed.
self.assertIsNone(proc.returncode)

with mock.patch("os.kill") as mock_kill:
proc.send_signal(signal.SIGTERM)

# send_signal() didn't call os.kill() since the process already
# completed.
mock_kill.assert_not_called()

# Don't check the returncode value: the test reads the exit status,
# so Popen failed to read it and uses a default returncode instead.
self.assertIsNotNone(proc.returncode)


@unittest.skipUnless(mswindows, "Windows specific tests")
class Win32ProcessTestCase(BaseTestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
On Unix, :meth:`subprocess.Popen.send_signal` now polls the process status.
Polling reduces the risk of sending a signal to the wrong process if the
process completed, the :attr:`subprocess.Popen.returncode` attribute is still
``None``, and the pid has been reassigned (recycled) to a new different
process.

0 comments on commit ce72a6f

Please sign in to comment.