Skip to content
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

Gracefully shutdown child processes #393

Merged
merged 8 commits into from
Mar 4, 2021
25 changes: 21 additions & 4 deletions nox/popen.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,27 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import contextlib
import locale
import subprocess
import sys
from typing import IO, Mapping, Sequence, Tuple, Union
from typing import IO, Mapping, Optional, Sequence, Tuple, Union


def shutdown_process(proc: subprocess.Popen) -> Tuple[Optional[bytes], Optional[bytes]]:
"""Gracefully shutdown a child process."""

with contextlib.suppress(subprocess.TimeoutExpired):
return proc.communicate(timeout=0.3)

proc.terminate()

with contextlib.suppress(subprocess.TimeoutExpired):
return proc.communicate(timeout=0.2)

proc.kill()

return proc.communicate()


def decode_output(output: bytes) -> str:
Expand Down Expand Up @@ -57,9 +74,9 @@ def popen(
sys.stdout.flush()

except KeyboardInterrupt:
proc.terminate()
proc.wait()
raise
out, err = shutdown_process(proc)
if proc.returncode != 0:
raise

return_code = proc.wait()

Expand Down
169 changes: 162 additions & 7 deletions tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import ctypes
import logging
import os
import platform
import signal
import subprocess
import sys
import time
from textwrap import dedent
from unittest import mock

import nox.command
Expand All @@ -23,6 +29,15 @@

PYTHON = sys.executable

skip_on_windows_console = pytest.mark.skipif(
platform.system() == "Windows" and "DETACHED_FROM_CONSOLE" not in os.environ,
reason="Do not run this test attached to a Windows console.",
)
Copy link

Choose a reason for hiding this comment

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

The wording here is possibly misleading. CREATE_NO_WINDOW and CREATE_NEW_CONSOLE run the process attached to a new console session. The creation flag DETACHED_PROCESS, on the other hand, tells the system to spawn a process detached from any console session. A truly detached process is definitely not wanted here since the test needs to generate and receive a console control event.

FYI, in Windows 8.1+, a console session is a set of processes that are connected to an instance of conhost.exe by way of the ConDrv kernel device. The session window is optional and may be excluded because of CREATE_NO_WINDOW or, in Windows 10, because the console host is running in headless conpty mode (pseudoconsole). For example, each tab in Windows Terminal uses a headless conpty session, with an open-source build of "conhost.exe" that's named "openconsole.exe".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for pointing that out. d6b0e59 changes the naming, hopefully for the better 😉


only_on_windows = pytest.mark.skipif(
platform.system() != "Windows", reason="Only run this test on Windows."
)


def test_run_defaults(capsys):
result = nox.command.run([PYTHON, "-c", "print(123)"])
Expand Down Expand Up @@ -98,7 +113,7 @@ def test_run_env_unicode():
result = nox.command.run(
[PYTHON, "-c", 'import os; print(os.environ["SIGIL"])'],
silent=True,
env={u"SIGIL": u"123"},
env={"SIGIL": "123"},
)

assert "123" in result
Expand Down Expand Up @@ -196,13 +211,153 @@ def test_fail_with_silent(capsys):
assert "err" in err


def test_interrupt():
mock_proc = mock.Mock()
mock_proc.communicate.side_effect = KeyboardInterrupt()
@pytest.fixture
def marker(tmp_path):
"""A marker file for process communication."""
return tmp_path / "marker"


def enable_ctrl_c(enabled):
"""Enable keyboard interrupts (CTRL-C) on Windows."""
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)

if not kernel32.SetConsoleCtrlHandler(None, not enabled):
raise ctypes.WinError(ctypes.get_last_error())


def interrupt_process(proc):
"""Send SIGINT or CTRL_C_EVENT to the process."""
if platform.system() == "Windows":
# Disable Ctrl-C so we don't terminate ourselves.
enable_ctrl_c(False)

# Send the keyboard interrupt to all processes attached to the current
# console session.
os.kill(0, signal.CTRL_C_EVENT)
else:
proc.send_signal(signal.SIGINT)


@pytest.fixture
def command_with_keyboard_interrupt(monkeypatch, marker):
"""Monkeypatch Popen.communicate to raise KeyboardInterrupt."""
if platform.system() == "Windows":
# Enable Ctrl-C because the child inherits the setting from us.
enable_ctrl_c(True)

communicate = subprocess.Popen.communicate

def wrapper(proc, *args, **kwargs):
# Raise the interrupt only on the first call, so Nox has a chance to
# shut down the child process subsequently.

if wrapper.firstcall:
wrapper.firstcall = False

# Give the child time to install its signal handlers.
while not marker.exists():
time.sleep(0.05)

# Send a real keyboard interrupt to the child.
interrupt_process(proc)

# Fake a keyboard interrupt in the parent.
raise KeyboardInterrupt

return communicate(proc, *args, **kwargs)

wrapper.firstcall = True

monkeypatch.setattr("subprocess.Popen.communicate", wrapper)


def format_program(program, marker):
"""Preprocess the Python program run by the child process."""
main = f"""
import time
from pathlib import Path

Path({str(marker)!r}).touch()
time.sleep(3)
"""
return dedent(program).format(MAIN=dedent(main))


def run_pytest_without_console_window(test):
"""Run the given test in a subprocess detached from the console window."""
env = dict(os.environ, DETACHED_FROM_CONSOLE="")
creationflags = (
subprocess.CREATE_NO_WINDOW
if sys.version_info[:2] >= (3, 7)
else subprocess.CREATE_NEW_CONSOLE
)

subprocess.run(
["pytest", f"{__file__}::{test}"],
env=env,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=creationflags,
)


@skip_on_windows_console
@pytest.mark.parametrize(
"program",
[
"""
{MAIN}
""",
"""
import signal

signal.signal(signal.SIGINT, signal.SIG_IGN)

{MAIN}
""",
"""
import signal

signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)

{MAIN}
""",
],
)
def test_interrupt_raises(command_with_keyboard_interrupt, program, marker):
"""It kills the process and reraises the keyboard interrupt."""
with pytest.raises(KeyboardInterrupt):
nox.command.run([PYTHON, "-c", format_program(program, marker)])


@only_on_windows
def test_interrupt_raises_on_windows():
"""It kills the process and reraises the keyboard interrupt."""
run_pytest_without_console_window("test_interrupt_raises")


@skip_on_windows_console
def test_interrupt_handled(command_with_keyboard_interrupt, marker):
"""It does not raise if the child handles the keyboard interrupt."""
program = """
import signal

def exithandler(sig, frame):
raise SystemExit()

signal.signal(signal.SIGINT, exithandler)

{MAIN}
"""
nox.command.run([PYTHON, "-c", format_program(program, marker)])


with mock.patch("subprocess.Popen", return_value=mock_proc):
with pytest.raises(KeyboardInterrupt):
nox.command.run([PYTHON, "-c" "123"])
@only_on_windows
def test_interrupt_handled_on_windows():
"""It does not raise if the child handles the keyboard interrupt."""
run_pytest_without_console_window("test_interrupt_handled")


def test_custom_stdout(capsys, tmpdir):
Expand Down