Merge lp:~allenap/maas/terminate-process into lp:~maas-committers/maas/trunk
- terminate-process
- Merge into 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 |
Related bugs: |
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, ProcessGroupLea
Description of the change
To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote : | # |
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. |
This looks nice. I'm going to take a closer look.