Merge lp:~allenap/maas/terminate-process into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5398
Proposed branch: lp:~allenap/maas/terminate-process
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 418 lines (+264/-11)
9 files modified
src/maasserver/static/js/angular/services/pollingmanager.js (+1/-1)
src/maasserver/tests/test_auth.py (+1/-1)
src/maasserver/views/__init__.py (+1/-1)
src/maasserver/views/prefs.py (+1/-1)
src/maasserver/views/tests/test_account.py (+1/-1)
src/maasserver/views/tests/test_prefs.py (+1/-1)
src/maasserver/websockets/handlers/user.py (+1/-1)
src/provisioningserver/utils/tests/test_twisted.py (+176/-1)
src/provisioningserver/utils/twisted.py (+81/-3)
To merge this branch: bzr merge lp:~allenap/maas/terminate-process
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+306217@code.launchpad.net

Commit message

New function terminateProcess that helps with clean termination of processes spawned via Twisted.

Also includes a new process protocol, ProcessGroupLeaderMixin, which helps ensure that spawned processes cooperate with the termination protocol defined by terminateProcess.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

This looks nice. I'm going to take a closer look.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I didn't get a chance to dig into this branch as much as I wanted to, but the code looks good and I tested it some last week. So I'll go ahead and approve. Thanks for the help with this, Gavin.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/static/js/angular/services/pollingmanager.js'
2--- src/maasserver/static/js/angular/services/pollingmanager.js 2016-09-22 15:05:38 +0000
3+++ src/maasserver/static/js/angular/services/pollingmanager.js 2016-09-26 06:36:24 +0000
4@@ -1,4 +1,4 @@
5-/* Copyright 2015-2016 Canonical Ltd. This software is licensed under the
6+/* Copyright 2016 Canonical Ltd. This software is licensed under the
7 * GNU Affero General Public License version 3 (see the file LICENSE).
8 *
9 * MAAS Space Manager
10
11=== modified file 'src/maasserver/tests/test_auth.py'
12--- src/maasserver/tests/test_auth.py 2016-09-22 02:53:33 +0000
13+++ src/maasserver/tests/test_auth.py 2016-09-26 06:36:24 +0000
14@@ -1,4 +1,4 @@
15-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
16+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
17 # GNU Affero General Public License version 3 (see the file LICENSE).
18
19 """Test permissions."""
20
21=== modified file 'src/maasserver/views/__init__.py'
22--- src/maasserver/views/__init__.py 2016-09-23 16:22:10 +0000
23+++ src/maasserver/views/__init__.py 2016-09-26 06:36:24 +0000
24@@ -1,4 +1,4 @@
25-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
26+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
27 # GNU Affero General Public License version 3 (see the file LICENSE).
28
29 """Views."""
30
31=== modified file 'src/maasserver/views/prefs.py'
32--- src/maasserver/views/prefs.py 2016-09-22 13:16:12 +0000
33+++ src/maasserver/views/prefs.py 2016-09-26 06:36:24 +0000
34@@ -1,4 +1,4 @@
35-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
36+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
37 # GNU Affero General Public License version 3 (see the file LICENSE).
38
39 """Preferences views."""
40
41=== modified file 'src/maasserver/views/tests/test_account.py'
42--- src/maasserver/views/tests/test_account.py 2016-09-22 02:53:33 +0000
43+++ src/maasserver/views/tests/test_account.py 2016-09-26 06:36:24 +0000
44@@ -1,4 +1,4 @@
45-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
46+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
47 # GNU Affero General Public License version 3 (see the file LICENSE).
48
49 """Test maasserver account views."""
50
51=== modified file 'src/maasserver/views/tests/test_prefs.py'
52--- src/maasserver/views/tests/test_prefs.py 2016-09-22 13:16:12 +0000
53+++ src/maasserver/views/tests/test_prefs.py 2016-09-26 06:36:24 +0000
54@@ -1,4 +1,4 @@
55-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
56+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
57 # GNU Affero General Public License version 3 (see the file LICENSE).
58
59 """Test maasserver preferences views."""
60
61=== modified file 'src/maasserver/websockets/handlers/user.py'
62--- src/maasserver/websockets/handlers/user.py 2016-09-22 02:53:33 +0000
63+++ src/maasserver/websockets/handlers/user.py 2016-09-26 06:36:24 +0000
64@@ -1,4 +1,4 @@
65-# Copyright 2015 Canonical Ltd. This software is licensed under the
66+# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
67 # GNU Affero General Public License version 3 (see the file LICENSE).
68
69 """The user handler for the WebSocket connection."""
70
71=== modified file 'src/provisioningserver/utils/tests/test_twisted.py'
72--- src/provisioningserver/utils/tests/test_twisted.py 2016-09-17 06:15:12 +0000
73+++ src/provisioningserver/utils/tests/test_twisted.py 2016-09-26 06:36:24 +0000
74@@ -6,15 +6,21 @@
75 __all__ = []
76
77 from functools import partial
78+import io
79 from itertools import cycle
80 import operator
81 from operator import attrgetter
82+import os
83 from random import (
84 randint,
85 random,
86+ randrange,
87 )
88 import re
89+import signal
90+import sys
91 import threading
92+from unittest import mock
93 from unittest.mock import (
94 ANY,
95 Mock,
96@@ -29,6 +35,8 @@
97 IsFiredDeferred,
98 IsUnfiredDeferred,
99 MockCalledOnceWith,
100+ MockCallsMatch,
101+ MockNotCalled,
102 Provides,
103 )
104 from maastesting.testcase import (
105@@ -56,13 +64,16 @@
106 LONGTIME,
107 makeDeferredWithProcessProtocol,
108 pause,
109+ ProcessGroupLeaderMixin,
110 retries,
111 RPCFetcher,
112 synchronous,
113+ terminateProcess,
114 ThreadPool,
115 ThreadPoolLimiter,
116 ThreadUnpool,
117 )
118+from testtools.content import content_from_stream
119 from testtools.deferredruntest import assert_fails_with
120 from testtools.matchers import (
121 AfterPreprocessing,
122@@ -88,7 +99,11 @@
123 inlineCallbacks,
124 succeed,
125 )
126-from twisted.internet.error import ProcessDone
127+from twisted.internet.error import (
128+ ProcessDone,
129+ ProcessTerminated,
130+)
131+from twisted.internet.protocol import ProcessProtocol
132 from twisted.internet.task import Clock
133 from twisted.internet.threads import (
134 deferToThread,
135@@ -1677,3 +1692,163 @@
136 protocol.processEnded(Failure(exception))
137 with ExpectedException(type(exception)):
138 yield d
139+
140+
141+# A script that prints the signals it receives, as long as they're SIGTERM or
142+# SIGQUIT. It prints "Ready." on stderr once it's registered signal handlers.
143+signal_printer = """\
144+from signal import Signals, signal
145+from sys import stderr
146+from time import sleep
147+from traceback import print_exc
148+
149+def print_signal(signum, frame):
150+ print(Signals(signum).name, flush=True)
151+
152+signal(Signals.SIGTERM, print_signal)
153+signal(Signals.SIGQUIT, print_signal)
154+
155+print("Ready.", file=stderr, flush=True)
156+
157+sleep(5.0)
158+"""
159+
160+
161+class SignalPrinterProtocol(ProcessProtocol):
162+ """Process protocol for use with `signal_printer`."""
163+
164+ def __init__(self):
165+ super(SignalPrinterProtocol, self).__init__()
166+ self.ready = Deferred()
167+ self.done = Deferred()
168+ self.out = io.BytesIO()
169+ self.err = io.BytesIO()
170+
171+ def outReceived(self, data):
172+ self.out.write(data)
173+
174+ def errReceived(self, data):
175+ self.err.write(data)
176+ if not self.ready.called:
177+ self.ready.callback(data)
178+
179+ def processEnded(self, reason):
180+ self.done.callback(reason)
181+
182+
183+class SignalPrinterProtocolAsGroupLeader(
184+ ProcessGroupLeaderMixin, SignalPrinterProtocol):
185+ """Process protocol for use with `signal_printer` that ...
186+
187+ ...configures itself as a process group leader.
188+ """
189+
190+
191+class TestTerminateProcess(MAASTestCase):
192+ """Tests for `terminateProcess`."""
193+
194+ run_tests_with = MAASTwistedRunTest.make_factory(timeout=10)
195+
196+ def setUp(self):
197+ super(TestTerminateProcess, self).setUp()
198+ self.sigprint = self.make_file("sigprint.py", signal_printer)
199+ # Allow spying on calls to os.kill and os.killpg by terminateProcess.
200+ self.assertThat(twisted_module._os_kill, Is(os.kill))
201+ self.patch(
202+ twisted_module, "_os_kill",
203+ Mock(wraps=twisted_module._os_kill))
204+ self.assertThat(twisted_module._os_killpg, Is(os.killpg))
205+ self.patch(
206+ twisted_module, "_os_killpg",
207+ Mock(wraps=twisted_module._os_killpg))
208+
209+ def startSignalPrinter(self, protocol):
210+ self.assertThat(protocol, IsInstance(SignalPrinterProtocol))
211+ self.addDetail("out", content_from_stream(protocol.out, seek_offset=0))
212+ self.addDetail("err", content_from_stream(protocol.err, seek_offset=0))
213+ python = sys.executable.encode("utf-8")
214+ process = reactor.spawnProcess(
215+ protocol, python, (python, self.sigprint.encode("utf-8")))
216+
217+ # Wait for the spawned subprocess to tell us that it's ready.
218+ def cbReady(message):
219+ self.assertThat(message.decode("utf-8"), Equals("Ready.\n"))
220+ return process
221+
222+ return protocol.ready.addCallback(cbReady)
223+
224+ def terminateSignalPrinter(self, process, protocol):
225+ # Terminate with some short timings; no point waiting long in a test,
226+ # and we need to do it before the subprocess finishes sleeping.
227+ terminateProcess(
228+ process.pid, protocol.done, quit_after=0.1,
229+ kill_after=0.2)
230+
231+ @inlineCallbacks
232+ def test__terminates_process_with_TERM_QUIT_then_KILL(self):
233+ protocol = SignalPrinterProtocol()
234+ process = yield self.startSignalPrinter(protocol)
235+ self.terminateSignalPrinter(process, protocol)
236+ # The subprocess is terminated with SIGKILL but received SIGTERM then
237+ # SIGQUIT prior to that.
238+ try:
239+ yield protocol.done
240+ except ProcessTerminated as reason:
241+ self.assertThat(reason, IsInstance(ProcessTerminated))
242+ self.assertThat(reason.signal, Equals(signal.SIGKILL))
243+ self.assertThat(
244+ protocol.out.getvalue().decode("utf-8").split(),
245+ Equals(["SIGTERM", "SIGQUIT"]))
246+
247+ @inlineCallbacks
248+ def test__terminates_with_kill_and_killpg(self):
249+ protocol = SignalPrinterProtocolAsGroupLeader()
250+ process = yield self.startSignalPrinter(protocol)
251+ # Capture the pid now; it gets cleared when the process exits.
252+ pid = process.pid
253+ # Terminate and wait for it to exit.
254+ self.terminateSignalPrinter(process, protocol)
255+ yield protocol.done.addErrback(Failure.trap, ProcessTerminated)
256+ # os.kill was called once then os.killpg was called twice because the
257+ # subprocess made itself a process group leader.
258+ self.assertThat(
259+ twisted_module._os_kill, MockCallsMatch(
260+ mock.call(pid, signal.SIGTERM),
261+ ))
262+ self.assertThat(
263+ twisted_module._os_killpg, MockCallsMatch(
264+ mock.call(pid, signal.SIGQUIT),
265+ mock.call(pid, signal.SIGKILL),
266+ ))
267+
268+ @inlineCallbacks
269+ def test__terminates_with_kill_if_not_in_separate_process_group(self):
270+ protocol = SignalPrinterProtocol()
271+ process = yield self.startSignalPrinter(protocol)
272+ # Capture the pid now; it gets cleared when the process exits.
273+ pid = process.pid
274+ # Terminate and wait for it to exit.
275+ self.terminateSignalPrinter(process, protocol)
276+ yield protocol.done.addErrback(Failure.trap, ProcessTerminated)
277+ # os.kill was called 3 times because the subprocess did not make
278+ # itself a process group leader.
279+ self.assertThat(
280+ twisted_module._os_kill, MockCallsMatch(
281+ mock.call(pid, signal.SIGTERM),
282+ mock.call(pid, signal.SIGQUIT),
283+ mock.call(pid, signal.SIGKILL),
284+ ))
285+ self.assertThat(
286+ twisted_module._os_killpg, MockNotCalled())
287+
288+
289+class TestProcessGroupLeaderMixin(MAASTestCase):
290+ """Tests for `ProcessGroupLeaderMixin`."""
291+
292+ def test__calls_setpgid_on_child_process(self):
293+ self.assertThat(twisted_module._os_setpgid, Is(os.setpgid))
294+ setpgid = self.patch(twisted_module, "_os_setpgid")
295+ protocol = ProcessGroupLeaderMixin()
296+ transport = Mock(pid=randrange(99999, 9999999))
297+ protocol.makeConnection(transport)
298+ self.assertThat(setpgid, MockCalledOnceWith(transport.pid, 0))
299
300=== modified file 'src/provisioningserver/utils/twisted.py'
301--- src/provisioningserver/utils/twisted.py 2016-09-17 05:25:56 +0000
302+++ src/provisioningserver/utils/twisted.py 2016-09-26 06:36:24 +0000
303@@ -2,9 +2,6 @@
304 # GNU Affero General Public License version 3 (see the file LICENSE).
305
306 """Utilities related to the Twisted/Crochet execution environment."""
307-from twisted.internet.error import ProcessDone
308-from twisted.internet.protocol import ProcessProtocol
309-
310
311 __all__ = [
312 'asynchronous',
313@@ -23,6 +20,7 @@
314 'LONGTIME',
315 'makeDeferredWithProcessProtocol',
316 'pause',
317+ 'ProcessGroupLeaderMixin',
318 'retries',
319 'synchronous',
320 'ThreadPool',
321@@ -40,6 +38,13 @@
322 )
323 from itertools import repeat
324 from operator import attrgetter
325+import os
326+from os import (
327+ kill as _os_kill,
328+ killpg as _os_killpg,
329+ setpgid as _os_setpgid,
330+)
331+import signal
332 import threading
333
334 from crochet import run_in_reactor
335@@ -51,6 +56,8 @@
336 maybeDeferred,
337 succeed,
338 )
339+from twisted.internet.error import ProcessDone
340+from twisted.internet.protocol import ProcessProtocol
341 from twisted.internet.threads import deferToThread
342 from twisted.logger import Logger
343 from twisted.python import (
344@@ -956,3 +963,74 @@
345 done.errback(reason) if (reason and not reason.check(ProcessDone))
346 else done.callback(None))
347 return done, protocol
348+
349+
350+def terminateProcess(pid, done, *, quit_after=5.0, kill_after=10.0):
351+ """Terminate the given process.
352+
353+ A "sensible" way to terminate a process. Does the following:
354+
355+ 1. Sends SIGTERM to the process identified by `pid`.
356+ 2. Waits for up to 5 seconds.
357+ 3. Sends SIGQUIT to the process *group* of process `pid`.
358+ 4. Waits for up to an additional 5 seconds.
359+ 5. Sends SIGKILL to the process *group* of process `pid`.
360+
361+ Steps #3 and #5 have a safeguard: if the process identified by `pid` has
362+ the same process group as the invoking process the signal is sent only to
363+ the process and not to the process group. This prevents the caller from
364+ inadvertently killing itself. You may want to use this in conjunction with
365+ `ProcessGroupLeaderMixin` to ensure that the child process becomes a
366+ process group leader soon after spawning.
367+
368+ :param pid: The PID to terminate.
369+ :param done: A `Deferred` that fires when the process exits.
370+ """
371+ ppgid = os.getpgrp()
372+
373+ def kill(sig):
374+ """Attempt to send `signal` to the given `pid`."""
375+ try:
376+ _os_kill(pid, sig)
377+ except ProcessLookupError:
378+ pass # Already exited.
379+
380+ def killpg(sig):
381+ """Attempt to send `signal` to the progress group of `pid`.
382+
383+ If `pid` is running in the same process group as the invoking process,
384+ this falls back to using kill(2) instead of killpg(2).
385+ """
386+ try:
387+ pgid = os.getpgid(pid)
388+ if pgid == ppgid:
389+ _os_kill(pid, sig)
390+ else:
391+ _os_killpg(pgid, sig)
392+ except ProcessLookupError:
393+ pass # Already exited.
394+
395+ killers = (
396+ reactor.callLater(0.0, kill, signal.SIGTERM),
397+ reactor.callLater(quit_after, killpg, signal.SIGQUIT),
398+ reactor.callLater(kill_after, killpg, signal.SIGKILL),
399+ )
400+
401+ def ended():
402+ for killer in killers:
403+ if killer.active():
404+ killer.cancel()
405+
406+ done.addBoth(callOut, ended)
407+
408+
409+class ProcessGroupLeaderMixin(ProcessProtocol):
410+ """Mix-in to ensure that the spawned process is a process group leader."""
411+
412+ def connectionMade(self):
413+ super().connectionMade()
414+ if self.transport.pid is not None:
415+ try:
416+ _os_setpgid(self.transport.pid, 0)
417+ except ProcessLookupError:
418+ pass # Already exited.