Kill the process group when conversion terminates

Instead of killing just the invoked Podman/Docker/qrexec process, kill
the whole process group, to make sure that other components that have
been spawned die as well. In the case of Podman, conmon is one of the
processes that lingers, so that's one way to kill it.
This commit is contained in:
Alex Pyrgiotis 2024-09-26 16:50:17 +03:00
parent b9a3dd63ad
commit d6410652cb
No known key found for this signature in database
GPG key ID: B6C15EBA0357C9AA
4 changed files with 50 additions and 10 deletions

View file

@ -1,5 +1,8 @@
import contextlib import contextlib
import logging import logging
import os
import platform
import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@ -27,6 +30,38 @@ TIMEOUT_GRACE = 15
TIMEOUT_FORCE = 5 TIMEOUT_FORCE = 5
def _signal_process_group(p: subprocess.Popen, signo: int) -> None:
"""Send a signal to a process group."""
try:
os.killpg(os.getpgid(p.pid), signo)
except (ProcessLookupError, PermissionError):
# If the process no longer exists, we may encounter the above errors, either
# when looking for the process group (ProcessLookupError), or when trying to
# kill a process group that no longer exists (PermissionError)
return
except Exception:
log.exception(
f"Unexpected error while sending signal {signo} to the"
f"document-to-pixels process group (PID: {p.pid})"
)
def terminate_process_group(p: subprocess.Popen) -> None:
"""Terminate a process group."""
if platform.system() == "Windows":
p.terminate()
else:
_signal_process_group(p, signal.SIGTERM)
def kill_process_group(p: subprocess.Popen) -> None:
"""Forcefully kill a process group."""
if platform.system() == "Windows":
p.kill()
else:
_signal_process_group(p, signal.SIGKILL)
def read_bytes(f: IO[bytes], size: int, exact: bool = True) -> bytes: def read_bytes(f: IO[bytes], size: int, exact: bool = True) -> bytes:
"""Read bytes from a file-like object.""" """Read bytes from a file-like object."""
buf = f.read(size) buf = f.read(size)
@ -240,7 +275,7 @@ class IsolationProvider(ABC):
) )
# Forcefully kill the running process. # Forcefully kill the running process.
p.kill() kill_process_group(p)
try: try:
p.wait(timeout_force) p.wait(timeout_force)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:

View file

@ -13,7 +13,12 @@ from ..conversion import errors
from ..document import Document from ..document import Document
from ..util import get_tmp_dir # NOQA : required for mocking in our tests. from ..util import get_tmp_dir # NOQA : required for mocking in our tests.
from ..util import get_resource_path, get_subprocess_startupinfo from ..util import get_resource_path, get_subprocess_startupinfo
from .base import PIXELS_TO_PDF_LOG_END, PIXELS_TO_PDF_LOG_START, IsolationProvider from .base import (
PIXELS_TO_PDF_LOG_END,
PIXELS_TO_PDF_LOG_START,
IsolationProvider,
terminate_process_group,
)
TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns. TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns.
@ -436,7 +441,7 @@ class Container(IsolationProvider):
# #
# See also https://github.com/freedomofpress/dangerzone/issues/791 # See also https://github.com/freedomofpress/dangerzone/issues/791
self.kill_container(self.doc_to_pixels_container_name(document)) self.kill_container(self.doc_to_pixels_container_name(document))
p.terminate() terminate_process_group(p)
def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def] def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def]
self, document: Document, *args, **kwargs self, document: Document, *args, **kwargs

View file

@ -140,14 +140,14 @@ class IsolationProviderTermination:
terminate_proc_mock = mocker.patch.object( terminate_proc_mock = mocker.patch.object(
provider_wait, "terminate_doc_to_pixels_proc", return_value=None provider_wait, "terminate_doc_to_pixels_proc", return_value=None
) )
popen_kill_spy = mocker.spy(subprocess.Popen, "kill") kill_pg_spy = mocker.spy(base, "kill_process_group")
with provider_wait.doc_to_pixels_proc(doc, timeout_grace=0) as proc: with provider_wait.doc_to_pixels_proc(doc, timeout_grace=0) as proc:
pass pass
get_proc_exception_spy.assert_not_called() get_proc_exception_spy.assert_not_called()
terminate_proc_mock.assert_called() terminate_proc_mock.assert_called()
popen_kill_spy.assert_called() kill_pg_spy.assert_called()
assert proc.poll() is not None assert proc.poll() is not None
def test_linger_unkillable( def test_linger_unkillable(
@ -165,8 +165,8 @@ class IsolationProviderTermination:
terminate_proc_mock = mocker.patch.object( terminate_proc_mock = mocker.patch.object(
provider_wait, "terminate_doc_to_pixels_proc", return_value=None provider_wait, "terminate_doc_to_pixels_proc", return_value=None
) )
popen_kill_mock = mocker.patch.object( kill_pg_mock = mocker.patch(
subprocess.Popen, "kill", return_value=None "dangerzone.isolation_provider.base.kill_process_group", return_value=None
) )
with provider_wait.doc_to_pixels_proc( with provider_wait.doc_to_pixels_proc(
@ -176,7 +176,7 @@ class IsolationProviderTermination:
get_proc_exception_spy.assert_not_called() get_proc_exception_spy.assert_not_called()
terminate_proc_mock.assert_called() terminate_proc_mock.assert_called()
popen_kill_mock.assert_called() kill_pg_mock.assert_called()
assert proc.poll() is None assert proc.poll() is None
# Reset the function to the original state. # Reset the function to the original state.

View file

@ -65,7 +65,7 @@ class TestContainerTermination(IsolationProviderTermination):
provider_wait.progress_callback = mocker.MagicMock() provider_wait.progress_callback = mocker.MagicMock()
get_proc_exception_spy = mocker.spy(provider_wait, "get_proc_exception") get_proc_exception_spy = mocker.spy(provider_wait, "get_proc_exception")
terminate_proc_spy = mocker.spy(provider_wait, "terminate_doc_to_pixels_proc") terminate_proc_spy = mocker.spy(provider_wait, "terminate_doc_to_pixels_proc")
popen_kill_spy = mocker.spy(subprocess.Popen, "kill") kill_proc_spy = mocker.spy(base, "kill_process_group")
# Switch the subprocess.run() function with a patched function that # Switch the subprocess.run() function with a patched function that
# intercepts the `kill` command and switches it with `wait` instead. This way, # intercepts the `kill` command and switches it with `wait` instead. This way,
@ -98,5 +98,5 @@ class TestContainerTermination(IsolationProviderTermination):
get_proc_exception_spy.assert_not_called() get_proc_exception_spy.assert_not_called()
terminate_proc_spy.assert_called() terminate_proc_spy.assert_called()
popen_kill_spy.assert_called() kill_proc_spy.assert_called()
assert proc.poll() is not None assert proc.poll() is not None