Skip to content

Commit 09ad7f5

Browse files
committed
Generalize process tree termination/killing to all controllers
In particular, Solanum needs it in some circumstances.
1 parent d4f34ac commit 09ad7f5

File tree

4 files changed

+51
-21
lines changed

4 files changed

+51
-21
lines changed

irctest/basecontrollers.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
from pathlib import Path
88
import shutil
9+
import signal
910
import socket
1011
import subprocess
1112
import tempfile
@@ -112,7 +113,7 @@ def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]:
112113
def get_hostname_and_port(self) -> Tuple[str, int]:
113114
with self._used_ports() as used_ports:
114115
while True:
115-
(hostname, port) = find_hostname_and_port()
116+
hostname, port = find_hostname_and_port()
116117
if (hostname, port) not in used_ports:
117118
# double-checking in self._used_ports to prevent collisions
118119
# between controllers starting at the same time.
@@ -129,15 +130,41 @@ def check_is_alive(self) -> None:
129130
if self.proc.returncode is not None:
130131
raise ProcessStopped(f"process returned {self.proc.returncode}")
131132

133+
def _terminate_process_group(self, sig: signal.Signals) -> bool:
134+
"""Try to send a signal to the entire process group.
135+
136+
Returns True if successful, False if we should fall back to single process.
137+
"""
138+
assert self.proc
139+
try:
140+
os.killpg(os.getpgid(self.proc.pid), sig)
141+
return True
142+
except (ProcessLookupError, PermissionError, OSError):
143+
# Process group doesn't exist or we don't have permission
144+
return False
145+
132146
def kill_proc(self) -> None:
133147
"""Terminates the controlled process, waits for it to exit, and
134-
eventually kills it."""
148+
eventually kills it.
149+
150+
This method kills the entire process group to ensure child processes
151+
(e.g., Solanum's authd and ssld helpers) are also terminated.
152+
"""
135153
assert self.proc
136-
self.proc.terminate()
154+
155+
# Try to terminate the entire process group first
156+
if not self._terminate_process_group(signal.SIGTERM):
157+
# Fall back to terminating just this process
158+
self.proc.terminate()
159+
137160
try:
138161
self.proc.wait(5)
139162
except subprocess.TimeoutExpired:
140-
self.proc.kill()
163+
# If still running, send SIGKILL to the process group
164+
if not self._terminate_process_group(signal.SIGKILL):
165+
# Fall back to killing just this process
166+
self.proc.kill()
167+
self.proc.wait(timeout=10) # Wait for it to actually die
141168
self.proc = None
142169

143170
def kill(self) -> None:
@@ -154,6 +181,10 @@ def execute(
154181
self, command: Sequence[Union[str, Path]], **kwargs: Any
155182
) -> subprocess.Popen:
156183
output_to = None if self.debug_mode else subprocess.DEVNULL
184+
# Start in a new process group so we can kill all children together
185+
# Note: start_new_session and preexec_fn cannot be used together
186+
if "start_new_session" not in kwargs and "preexec_fn" not in kwargs:
187+
kwargs["start_new_session"] = True
157188
return subprocess.Popen(command, stderr=output_to, stdout=output_to, **kwargs)
158189

159190

@@ -176,7 +207,9 @@ def kill(self) -> None:
176207
def terminate(self) -> None:
177208
"""Stops the process gracefully, and does not clean its config."""
178209
assert self.proc
179-
self.proc.terminate()
210+
# Terminate the entire process group to kill child processes too
211+
if not self._terminate_process_group(signal.SIGTERM):
212+
self.proc.terminate()
180213
self.proc.wait()
181214
self.proc = None
182215

irctest/controllers/mammon.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import shutil
2+
import signal
23
from typing import Optional, Set, Type
34

45
from irctest.basecontrollers import (
@@ -76,9 +77,14 @@ def create_config(self) -> None:
7677
pass
7778

7879
def kill_proc(self) -> None:
79-
# Mammon does not seem to handle SIGTERM very well
80+
# Mammon does not seem to handle SIGTERM very well, so use SIGKILL
8081
assert self.proc
81-
self.proc.kill()
82+
# Kill the entire process group to handle child processes
83+
if not self._terminate_process_group(signal.SIGKILL):
84+
# Fall back to killing just this process
85+
self.proc.kill()
86+
self.proc.wait()
87+
self.proc = None
8288

8389
def run(
8490
self,

irctest/controllers/sable.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import os
22
from pathlib import Path
33
import shutil
4-
import signal
54
import subprocess
65
import tempfile
76
import time
@@ -410,10 +409,8 @@ def run(
410409
self.directory / "configs/network_config.conf",
411410
],
412411
cwd=self.directory,
413-
preexec_fn=os.setsid,
414412
env={"RUST_BACKTRACE": "1", **os.environ},
415413
)
416-
self.pgroup_id = os.getpgid(self.proc.pid)
417414

418415
if run_services:
419416
self.services_controller = SableServicesController(self.test_config, self)
@@ -423,10 +420,6 @@ def run(
423420
server_port=services_port,
424421
)
425422

426-
def kill_proc(self) -> None:
427-
os.killpg(self.pgroup_id, signal.SIGKILL)
428-
super().kill_proc()
429-
430423
def registerUser(
431424
self,
432425
case: BaseServerTestCase, # type: ignore
@@ -488,14 +481,8 @@ def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
488481
self.server_controller.directory / "configs/network.conf",
489482
],
490483
cwd=self.server_controller.directory,
491-
preexec_fn=os.setsid,
492484
env={"RUST_BACKTRACE": "1", **os.environ},
493485
)
494-
self.pgroup_id = os.getpgid(self.proc.pid)
495-
496-
def kill_proc(self) -> None:
497-
os.killpg(self.pgroup_id, signal.SIGKILL)
498-
super().kill_proc()
499486

500487
def wait_for_services(self) -> None:
501488
# by default, wait_for_services() connects a user that sends a HELP command

irctest/controllers/unrealircd.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import functools
44
from pathlib import Path
55
import shutil
6+
import signal
67
import subprocess
78
import textwrap
89
from typing import Callable, ContextManager, Iterator, Optional, Type
@@ -288,7 +289,10 @@ def kill_proc(self) -> None:
288289
assert self.proc
289290

290291
with _STARTSTOP_LOCK():
291-
self.proc.kill()
292+
# Kill the entire process group to handle child processes
293+
if not self._terminate_process_group(signal.SIGKILL):
294+
# Fall back to killing just this process
295+
self.proc.kill()
292296
self.proc.wait(5) # wait for it to actually die
293297
self.proc = None
294298

0 commit comments

Comments
 (0)