Skip to content

Commit d91736d

Browse files
authored
Merge pull request #49 from ceph/ctrl-c
exec: Clean up subprocesses on ctrl-C
2 parents 74b5f8b + da6ce45 commit d91736d

8 files changed

Lines changed: 302 additions & 62 deletions

File tree

ceph_devstack/cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ def main() -> int:
4848
obj = CephDevStack()
4949

5050
async def run():
51-
if not await asyncio.gather(
52-
check_requirements(),
53-
obj.check_requirements(),
51+
if not all(
52+
await asyncio.gather(
53+
check_requirements(),
54+
obj.check_requirements(),
55+
)
5456
):
5557
logger.error("Requirements not met!")
5658
return 1

ceph_devstack/exec.py

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,74 @@
11
import asyncio
22
from asyncio.subprocess import SubprocessStreamProtocol
3+
import contextlib
34
import functools
45
import os
56
import pathlib
7+
import psutil
8+
import signal
69
import subprocess
710

811
from typing import Dict, List, Optional
912

1013
from ceph_devstack import logger, VERBOSE
1114

15+
_TERMINATE_TIMEOUT = 3.0
16+
_KILL_TIMEOUT = 1.0
1217

13-
class LoggingStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol):
14-
def __init__(self, limit, loop, log_level):
15-
self.log_level = log_level
16-
super().__init__(limit=limit, loop=loop)
1718

18-
def pipe_data_received(self, fd, data):
19-
logger.log(
20-
self.log_level,
21-
(data.decode() if isinstance(data, bytes) else str(data)).rstrip("\n"),
22-
)
23-
super().pipe_data_received(fd, data)
19+
class Subprocess(asyncio.subprocess.Process):
20+
async def _close_transport(self) -> None:
21+
transport = getattr(self, "_transport", None)
22+
if transport is not None and not transport.is_closing():
23+
transport.close()
24+
25+
async def _wait_for_exit(self, timeout: float) -> None:
26+
with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError):
27+
await asyncio.wait_for(asyncio.shield(super().wait()), timeout=timeout)
28+
29+
async def _terminate(self) -> None:
30+
if self.returncode is not None:
31+
await self._close_transport()
32+
return
33+
self.signal_children(signal.SIGTERM, recursive=True)
34+
with contextlib.suppress(ProcessLookupError):
35+
self.kill()
36+
await self._wait_for_exit(_TERMINATE_TIMEOUT)
37+
if self.returncode is None:
38+
self.signal_children(signal.SIGKILL, recursive=True)
39+
with contextlib.suppress(ProcessLookupError):
40+
self.kill()
41+
await self._wait_for_exit(_KILL_TIMEOUT)
42+
await self._close_transport()
43+
44+
async def wait(self) -> int:
45+
try:
46+
return await super().wait()
47+
except asyncio.CancelledError:
48+
await self._terminate()
49+
raise
50+
51+
async def communicate(self, input=None):
52+
try:
53+
return await super().communicate(input)
54+
except asyncio.CancelledError:
55+
await self._terminate()
56+
raise
57+
58+
def child_pids(self, recursive=True):
59+
if self.pid is None:
60+
return []
61+
return [
62+
child.pid
63+
for child in psutil.Process(self.pid).children(recursive=recursive)
64+
]
65+
66+
def signal_children(self, signal: signal.Signals, recursive=True):
67+
for pid in self.child_pids(recursive=recursive):
68+
with contextlib.suppress(ProcessLookupError):
69+
os.kill(pid, signal)
70+
with contextlib.suppress(ProcessLookupError):
71+
os.killpg(pid, signal)
2472

2573

2674
class Command:
@@ -57,37 +105,28 @@ def run(self) -> subprocess.Popen:
57105
proc.wait()
58106
return proc
59107

60-
async def arun(self) -> asyncio.subprocess.Process:
108+
async def arun(self) -> Subprocess:
61109
logger.log(VERBOSE, self._make_log_msg())
62110
loop = asyncio.get_running_loop()
63-
protocol_factory: (
64-
functools.partial[SubprocessStreamProtocol]
65-
| functools.partial[LoggingStreamProtocol]
66-
)
111+
kwargs = dict(self.kwargs)
67112
if self.stream_output:
68-
protocol_factory = functools.partial(
69-
LoggingStreamProtocol,
70-
limit=2**16,
71-
loop=loop,
72-
log_level=VERBOSE,
73-
)
74-
else:
75-
protocol_factory = functools.partial(
76-
asyncio.subprocess.SubprocessStreamProtocol,
77-
limit=2**16,
78-
loop=loop,
79-
)
113+
# Inherit stdout/stderr so long-running commands (e.g. dnf builddep)
114+
# are not blocked when their output exceeds the StreamReader limit.
115+
kwargs["stdout"] = None
116+
kwargs["stderr"] = None
117+
protocol_factory = functools.partial(
118+
SubprocessStreamProtocol,
119+
limit=2**16,
120+
loop=loop,
121+
)
80122
transport, protocol = await loop.subprocess_exec(
81123
protocol_factory,
82124
*self.args,
83125
env=self.env,
84-
**self.kwargs,
85-
)
86-
return asyncio.subprocess.Process(
87-
transport,
88-
protocol,
89-
loop,
126+
start_new_session=True,
127+
**kwargs,
90128
)
129+
return Subprocess(transport, protocol, loop)
91130

92131
def __str__(self):
93132
return " ".join(self.args)

ceph_devstack/host.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import json
32
import logging
43
import os
@@ -10,7 +9,7 @@
109
from packaging.version import parse as parse_version, Version
1110
from typing import Dict, List, Optional, Union
1211

13-
from .exec import Command
12+
from .exec import Command, Subprocess
1413

1514
logger = logging.getLogger(__name__)
1615

@@ -46,7 +45,7 @@ async def arun(
4645
cwd: Optional[pathlib.Path] = None,
4746
env: Optional[Dict] = None,
4847
stream_output: bool = False,
49-
) -> asyncio.subprocess.Process:
48+
) -> Subprocess:
5049
return await self.cmd(
5150
args, cwd=cwd, env=env, stream_output=stream_output
5251
).arun()
@@ -145,7 +144,15 @@ class LocalHost(Host):
145144

146145
class RemoteHost(Host):
147146
type = "remote"
148-
base_args = ["podman", "machine", "ssh", "--"]
147+
base_args = ["podman", "machine", "ssh"]
148+
149+
def _remote_args(self, args: List[str], stream_output: bool) -> List[str]:
150+
remote = list(self.base_args)
151+
if stream_output:
152+
remote.append("-t")
153+
remote.append("--")
154+
remote.extend(args)
155+
return remote
149156

150157
def cmd(
151158
self,
@@ -155,7 +162,7 @@ def cmd(
155162
stream_output: bool = False,
156163
):
157164
if args[0] != "podman":
158-
args = self.base_args + args
165+
args = self._remote_args(args, stream_output)
159166
return super().cmd(args, cwd=cwd, env=env, stream_output=stream_output)
160167

161168
def path_exists(self, path: Union[str, pathlib.Path]):

ceph_devstack/resources/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/bin/env python3
22
import argparse
3-
import asyncio
43
import json
54
import os
65
import subprocess
@@ -9,6 +8,7 @@
98
from subprocess import CalledProcessError
109
from typing import List, Dict, Set
1110

11+
from ceph_devstack.exec import Subprocess
1212
from ceph_devstack.host import host, local_host
1313

1414

@@ -56,15 +56,13 @@ async def cmd(
5656
check: bool = False,
5757
force_local: bool = False,
5858
stream_output: bool = False,
59-
) -> asyncio.subprocess.Process:
59+
) -> Subprocess:
6060
exec_host = local_host if force_local else host
6161
proc = await exec_host.arun(
6262
args,
6363
cwd=Path(self.cwd),
6464
stream_output=stream_output,
6565
)
66-
assert proc.stderr is not None
67-
assert proc.stdout is not None
6866
returncode = await proc.wait()
6967
if check and returncode != 0:
7068
# out = await proc.stderr.read()

ceph_devstack/resources/ceph/__init__.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -205,22 +205,17 @@ async def watch(self):
205205
containers.append(object)
206206
logger.info(f"Watching {containers}")
207207
while True:
208-
try:
209-
for container in containers:
210-
with contextlib.suppress(CalledProcessError):
211-
if not await container.exists():
212-
logger.info(
213-
f"Container {container.name} was removed; replacing"
214-
)
215-
await container.create()
216-
await container.start()
217-
elif not await container.is_running():
218-
logger.info(
219-
f"Container {container.name} stopped; restarting"
220-
)
221-
await container.start()
222-
except KeyboardInterrupt:
223-
break
208+
for container in containers:
209+
with contextlib.suppress(CalledProcessError):
210+
if not await container.exists():
211+
logger.info(
212+
f"Container {container.name} was removed; replacing"
213+
)
214+
await container.create()
215+
await container.start()
216+
elif not await container.is_running():
217+
logger.info(f"Container {container.name} stopped; restarting")
218+
await container.start()
224219

225220
async def wait(self, container_name: str):
226221
for spec in self.service_specs.values():

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ classifiers = [
1313
keywords = ["podman", "ceph"]
1414
description = "Run a full teuthology lab on your laptop!"
1515
requires-python = ">=3.12"
16-
dependencies = ["packaging", "pre-commit", "PyYAML", "tomlkit"]
16+
dependencies = [
17+
"packaging",
18+
"pre-commit",
19+
"psutil>=7.2.2",
20+
"PyYAML",
21+
"tomlkit",
22+
]
1723
dynamic = ["version"]
1824

1925
[project.readme]

0 commit comments

Comments
 (0)