Skip to content

Commit 60ecb14

Browse files
committed
Implement helpful features from #3237
- `shell=True` implies `process_group=0` - make default `deliver_cancel` aware of process groups - backport `process_group` to Pythons prior to 3.11
1 parent 3029119 commit 60ecb14

File tree

3 files changed

+152
-19
lines changed

3 files changed

+152
-19
lines changed

newsfragments/3237.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve defaults if ``shell=True`` for `trio.run_process`, as well as backport the ``process_group`` argument.

src/trio/_subprocess.py

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import contextlib
44
import os
5+
import signal
56
import subprocess
67
import sys
78
import warnings
9+
from collections.abc import Awaitable
810
from contextlib import ExitStack
911
from functools import partial
1012
from typing import (
@@ -30,7 +32,6 @@
3032
from ._util import NoPublicConstructor, final
3133

3234
if TYPE_CHECKING:
33-
import signal
3435
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
3536
from io import TextIOWrapper
3637

@@ -441,24 +442,45 @@ async def _windows_deliver_cancel(p: Process) -> None: # noqa: RUF029
441442
)
442443

443444

444-
async def _posix_deliver_cancel(p: Process) -> None:
445-
try:
446-
p.terminate()
447-
await trio.sleep(5)
448-
warnings.warn(
449-
RuntimeWarning(
450-
f"process {p!r} ignored SIGTERM for 5 seconds. "
451-
"(Maybe you should pass a custom deliver_cancel?) "
452-
"Trying SIGKILL.",
453-
),
454-
stacklevel=1,
455-
)
456-
p.kill()
457-
except OSError as exc:
458-
warnings.warn(
459-
RuntimeWarning(f"tried to kill process {p!r}, but failed with: {exc!r}"),
460-
stacklevel=1,
445+
def _get_posix_deliver_cancel(
446+
process_group: int | None,
447+
) -> Callable[[Process], Awaitable[None]]:
448+
async def _posix_deliver_cancel(p: Process) -> None:
449+
should_deliver_to_pg = (
450+
sys.platform != "win32"
451+
and process_group is not None
452+
and os.getpgrp() != os.getpgid(p.pid)
461453
)
454+
try:
455+
# TODO: should Process#terminate do this special logic
456+
if sys.platform != "win32" and should_deliver_to_pg:
457+
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
458+
else:
459+
p.terminate()
460+
461+
await trio.sleep(5)
462+
warnings.warn(
463+
RuntimeWarning(
464+
f"process {p!r} ignored SIGTERM for 5 seconds. "
465+
"(Maybe you should pass a custom deliver_cancel?) "
466+
"Trying SIGKILL.",
467+
),
468+
stacklevel=1,
469+
)
470+
471+
if sys.platform != "win32" and should_deliver_to_pg:
472+
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
473+
else:
474+
p.kill()
475+
except OSError as exc:
476+
warnings.warn(
477+
RuntimeWarning(
478+
f"tried to kill process {p!r}, but failed with: {exc!r}"
479+
),
480+
stacklevel=1,
481+
)
482+
483+
return _posix_deliver_cancel
462484

463485

464486
# Use a private name, so we can declare platform-specific stubs below.
@@ -472,6 +494,7 @@ async def _run_process(
472494
check: bool = True,
473495
deliver_cancel: Callable[[Process], Awaitable[object]] | None = None,
474496
task_status: TaskStatus[Process] = trio.TASK_STATUS_IGNORED,
497+
shell: bool = False,
475498
**options: object,
476499
) -> subprocess.CompletedProcess[bytes]:
477500
"""Run ``command`` in a subprocess and wait for it to complete.
@@ -689,6 +712,9 @@ async def my_deliver_cancel(process):
689712
"stderr=subprocess.PIPE is only valid with nursery.start, "
690713
"since that's the only way to access the pipe",
691714
)
715+
716+
options["shell"] = shell
717+
692718
if isinstance(stdin, (bytes, bytearray, memoryview)):
693719
input_ = stdin
694720
options["stdin"] = subprocess.PIPE
@@ -708,12 +734,36 @@ async def my_deliver_cancel(process):
708734
raise ValueError("can't specify both stderr and capture_stderr")
709735
options["stderr"] = subprocess.PIPE
710736

737+
# ensure things can be killed including a shell's child processes
738+
if shell and sys.platform != "win32" and "process_group" not in options:
739+
options["process_group"] = 0
740+
711741
if deliver_cancel is None:
712742
if os.name == "nt":
713743
deliver_cancel = _windows_deliver_cancel
714744
else:
715745
assert os.name == "posix"
716-
deliver_cancel = _posix_deliver_cancel
746+
deliver_cancel = _get_posix_deliver_cancel(options.get("process_group")) # type: ignore[arg-type]
747+
748+
if (
749+
sys.platform != "win32"
750+
and options.get("process_group") is not None
751+
and sys.version_info < (3, 11)
752+
):
753+
# backport the argument to Python versions prior to 3.11
754+
preexec_fn = options.get("preexec_fn")
755+
process_group = options.pop("process_group")
756+
757+
def new_preexecfn() -> object: # pragma: no cover
758+
assert sys.platform != "win32"
759+
os.setpgid(0, process_group) # type: ignore[arg-type]
760+
761+
if callable(preexec_fn):
762+
return preexec_fn()
763+
else:
764+
return None
765+
766+
options["preexec_fn"] = new_preexecfn
717767

718768
stdout_chunks: list[bytes | bytearray] = []
719769
stderr_chunks: list[bytes | bytearray] = []

src/trio/_tests/test_subprocess.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,3 +765,85 @@ async def wait_and_tell(proc: Process) -> None:
765765
# for everything to notice
766766
await noticed_exit.wait()
767767
assert noticed_exit.is_set(), "child task wasn't woken after poll, DEADLOCK"
768+
769+
770+
@pytest.mark.skipif(not posix, reason="posix only")
771+
@slow
772+
async def test_shells_killed_by_default() -> None:
773+
assert sys.platform != "win32"
774+
775+
async with _core.open_nursery() as nursery:
776+
proc = await nursery.start(
777+
partial(
778+
trio.run_process,
779+
'python -u -c "import os, time, signal; '
780+
f"os.kill({os.getpid()}, signal.SIGHUP); "
781+
'print(os.getpid()); time.sleep(5)" | cat',
782+
shell=True,
783+
stdout=subprocess.PIPE,
784+
)
785+
)
786+
787+
with trio.fail_after(1):
788+
with trio.open_signal_receiver(signal.SIGHUP) as signal_aiter: # type: ignore[attr-defined,unused-ignore]
789+
async for signum in signal_aiter: # pragma: no branch
790+
assert signum == signal.SIGHUP # type: ignore[attr-defined,unused-ignore]
791+
break
792+
793+
nursery.cancel_scope.cancel()
794+
795+
chunks = []
796+
with trio.move_on_after(0.1):
797+
async with proc.stdout:
798+
async for c in proc.stdout:
799+
chunks.append(c) # noqa: PERF401
800+
801+
# give the child time to wind down execution (this is unfortunate)
802+
await trio.sleep(1)
803+
804+
child_pid = int(b"".join(chunks))
805+
if sys.platform == "linux":
806+
# docker containers can have an evil init which
807+
# won't reap zombie children, so we have to work around that
808+
with pytest.raises(FileNotFoundError):
809+
with open(f"/proc/{child_pid}/status") as f: # noqa: ASYNC230
810+
for line in f: # pragma: no branch
811+
if line.startswith("State:"):
812+
assert "Z" in line
813+
raise FileNotFoundError
814+
else:
815+
with pytest.raises(OSError, match="No such process"):
816+
os.kill(child_pid, 0)
817+
818+
819+
@pytest.mark.skipif(not posix, reason="posix only")
820+
@slow
821+
async def test_process_group_SIGKILL_escalation(mock_clock: MockClock) -> None:
822+
assert sys.platform != "win32"
823+
mock_clock.autojump_threshold = 1
824+
825+
with pytest.warns(RuntimeWarning, match=".*ignored SIGTERM.*"): # noqa: PT031
826+
async with _core.open_nursery() as nursery:
827+
nursery.start_soon(
828+
# this ignore is because process_group=0 on Windows
829+
partial( # type: ignore[call-arg,unused-ignore]
830+
trio.run_process,
831+
[
832+
"python",
833+
"-c",
834+
"import os, time, signal;"
835+
f"os.kill({os.getpid()}, signal.SIGHUP);"
836+
"signal.signal(signal.SIGTERM, signal.SIG_IGN);"
837+
"time.sleep(10)",
838+
],
839+
process_group=0,
840+
)
841+
)
842+
843+
with trio.fail_after(1):
844+
with trio.open_signal_receiver(signal.SIGHUP) as signal_aiter: # type: ignore[attr-defined,unused-ignore]
845+
async for signum in signal_aiter: # pragma: no branch
846+
assert signum == signal.SIGHUP # type: ignore[attr-defined,unused-ignore]
847+
break
848+
849+
nursery.cancel_scope.cancel()

0 commit comments

Comments
 (0)