Skip to content

Commit 4f33344

Browse files
authored
Merge pull request #65 from xcube-dev/pont-62-sigint
Stop running container on SIGINT
2 parents f83c453 + 2fd8f4d commit 4f33344

4 files changed

Lines changed: 82 additions & 2 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## Changes in 0.1.2 (in development)
22

33
* Improve handling of environment file specification (#63)
4+
* Stop running container on SIGINT (#62)
45

56
## Changes in 0.1.1
67

docs/xcetool.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ This subcommand runs an xcengine container image. An image can also be run using
2121
`docker run` command, but `xcetool image run` provides some additional convenience
2222
(e.g. easy configuration of the HTTP port).
2323

24+
If you give the `--server` flag, `xcetool` will run the container indefinitely as an
25+
xcube server. You can stop the container and force `xcetool` to exit by pressing
26+
ctrl-C on the command line (or by sending it an interrupt signal in some other way).
27+
2428
### `xcetool make-script`
2529

2630
This subcommand does not generate a container image, but a directory containing

test/test_core.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import datetime
22
import json
3+
import os
34
import pathlib
5+
import signal
6+
import threading
7+
import time
8+
49
import pytz
510
from io import BufferedReader
611
import yaml
@@ -11,6 +16,8 @@
1116

1217
import docker.models.images
1318
import schema_salad.exceptions
19+
from docker import DockerClient
20+
from docker.models.containers import Container
1421

1522
import xcengine.core
1623
import xcengine.parameters
@@ -90,6 +97,65 @@ def test_runner_init_with_image():
9097
)
9198
assert runner.image == image
9299

100+
@pytest.mark.parametrize("keep", [False, True])
101+
def test_runner_run_keep(keep: bool):
102+
runner = xcengine.core.ContainerRunner(
103+
image := Mock(docker.models.images.Image),
104+
None,
105+
client := Mock(DockerClient)
106+
)
107+
image.tags = []
108+
client.containers.run.return_value = (container := MagicMock(Container))
109+
container.status = "exited"
110+
runner.run(False, 8080, False, keep)
111+
if keep:
112+
container.remove.assert_not_called()
113+
else:
114+
container.remove.assert_called_once_with(force=True)
115+
116+
117+
def test_runner_sigint():
118+
runner = xcengine.core.ContainerRunner(
119+
image := Mock(docker.models.images.Image),
120+
None,
121+
client := Mock(DockerClient)
122+
)
123+
image.tags = []
124+
client.containers.run.return_value = (container := Mock(Container))
125+
container.status = "running"
126+
def container_stop():
127+
container.status = "stopped"
128+
container.stop = container_stop
129+
pid = os.getpid()
130+
131+
old_alarm_handler = signal.getsignal(signal.SIGALRM)
132+
class AlarmException(Exception):
133+
pass
134+
def alarm_handler(signum, frame):
135+
raise AlarmException()
136+
signal.signal(signal.SIGALRM, alarm_handler)
137+
138+
def interrupt_process():
139+
time.sleep(1) # allow one second for runner to start
140+
os.kill(pid, signal.SIGINT)
141+
thread = threading.Thread(target=interrupt_process, daemon=True)
142+
thread.start()
143+
144+
signal.alarm(5)
145+
try:
146+
# Should trap imminent SIGINT from interrupt_process and exit quickly
147+
runner.run(False, 8080, False, False)
148+
except AlarmException:
149+
# time-out, exception raised by alarm_handler
150+
# We need a time-out so that the test fails rather than hanging.
151+
assert False, "Container did not stop on SIGINT"
152+
finally:
153+
# Reset the alarm handler and cancel the alarm to avoid affecting
154+
# subsequent tests.
155+
signal.signal(signal.SIGALRM, old_alarm_handler)
156+
signal.alarm(0)
157+
assert container.status == "stopped"
158+
93159

94160
@patch("xcengine.core.subprocess.run")
95161
def test_pip(mock_run):

xcengine/core.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import json
77
import os
88
import shutil
9+
import signal
10+
import socket
911
import sys
1012
import tarfile
1113
import subprocess
@@ -337,7 +339,7 @@ class ContainerRunner:
337339
def __init__(
338340
self,
339341
image: Image | str,
340-
output_dir: pathlib.Path,
342+
output_dir: pathlib.Path | None,
341343
client: docker.DockerClient = None,
342344
):
343345
self._client = client
@@ -388,13 +390,20 @@ def run(
388390
run_args["ports"] = {"8080": host_port}
389391
container: Container = self.client.containers.run(**run_args)
390392
LOGGER.info(f"Waiting for container {container.short_id} to complete.")
393+
default_sigint_handler = signal.getsignal(signal.SIGINT)
394+
def signal_hander(signum, frame):
395+
signal.signal(signal.SIGINT, default_sigint_handler)
396+
LOGGER.info(f"Caught SIGINT. Stopping container {container.short_id}")
397+
container.stop()
398+
signal.signal(signal.SIGINT, signal_hander)
391399
while container.status in {"created", "running"}:
392400
LOGGER.debug(
393401
f"Waiting for {container.short_id} "
394402
f"(status: {container.status})"
395403
)
396404
time.sleep(2)
397405
container.reload()
406+
signal.signal(signal.SIGINT, default_sigint_handler)
398407
LOGGER.info(
399408
f'Container {container.short_id} has status "{container.status}".'
400409
)
@@ -404,7 +413,7 @@ def run(
404413
)
405414
self.extract_output_from_container(container)
406415
LOGGER.info(f"Results copied.")
407-
if host_port is None and not keep:
416+
if not keep:
408417
LOGGER.info(f"Removing container {container.short_id}...")
409418
container.remove(force=True)
410419
LOGGER.info(f"Container {container.short_id} removed.")

0 commit comments

Comments
 (0)